mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-03-12 09:36:17 +00:00
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>
1042 lines
41 KiB
Python
1042 lines
41 KiB
Python
"""Tests for configabc.TimeWindow and TimeWindowSequence.
|
||
|
||
Timezone contract under test:
|
||
|
||
* ``start_time`` is always **naive** (no ``tzinfo``).
|
||
* ``date`` is inherently timezone-free (a calendar date).
|
||
* ``date_time`` / ``reference_date`` passed to ``contains()``,
|
||
``earliest_start_time()``, and ``latest_start_time()`` may be
|
||
timezone-aware or naive.
|
||
* When a timezone-aware datetime is supplied, ``start_time`` is
|
||
interpreted as wall-clock time **in that timezone** — no tz
|
||
conversion is applied to ``start_time`` itself.
|
||
* Constructing a ``TimeWindow`` with an aware ``start_time`` raises
|
||
``ValidationError``.
|
||
"""
|
||
|
||
import datetime
|
||
import os
|
||
import sys
|
||
|
||
sys.path.insert(0, os.path.dirname(__file__))
|
||
|
||
import numpy as np
|
||
import pendulum
|
||
import pytest
|
||
from pydantic import ValidationError
|
||
|
||
from akkudoktoreos.config.configabc import TimeWindow
|
||
from akkudoktoreos.config.configabc import TimeWindow as _TW_check
|
||
from akkudoktoreos.config.configabc import ( # noqa — ensure Time is importable
|
||
TimeWindowSequence,
|
||
ValueTimeWindow,
|
||
ValueTimeWindowSequence,
|
||
)
|
||
from akkudoktoreos.utils.datetimeutil import Time
|
||
|
||
# ===========================================================================
|
||
# Helpers
|
||
# ===========================================================================
|
||
|
||
def naive_dt(year, month, day, hour=0, minute=0, second=0):
|
||
"""Return a truly naive DateTime (no timezone)."""
|
||
return pendulum.instance(
|
||
datetime.datetime(year, month, day, hour, minute, second)
|
||
).naive()
|
||
|
||
|
||
def aware_dt(year, month, day, hour=0, minute=0, second=0, tz="Europe/Berlin"):
|
||
"""Return a timezone-aware pendulum DateTime."""
|
||
return pendulum.datetime(year, month, day, hour, minute, second, tz=tz)
|
||
|
||
|
||
def make_window(start_h, duration_h, **kwargs):
|
||
"""Build a TimeWindow with a naive start_time at ``start_h:00``."""
|
||
return TimeWindow(
|
||
start_time=f"{start_h:02d}:00:00",
|
||
duration=f"{duration_h} hours",
|
||
**kwargs,
|
||
)
|
||
|
||
|
||
# ===========================================================================
|
||
# Construction / validation
|
||
# ===========================================================================
|
||
|
||
class TestTimeWindowConstruction:
|
||
def test_naive_start_time_accepted(self):
|
||
w = make_window(8, 2)
|
||
assert w.start_time.tzinfo is None
|
||
|
||
def test_aware_start_time_stripped_to_naive(self):
|
||
"""An aware start_time is silently stripped to naive (to_time may add a tz)."""
|
||
w = TimeWindow(
|
||
start_time=Time(8, 0, 0, tzinfo=pendulum.timezone("Europe/Berlin")),
|
||
duration="2 hours",
|
||
)
|
||
assert w.start_time.tzinfo is None
|
||
assert w.start_time.hour == 8
|
||
|
||
def test_duration_string_parsed(self):
|
||
w = make_window(8, 3)
|
||
assert w.duration.total_seconds() == 3 * 3600
|
||
|
||
def test_day_of_week_integer_valid(self):
|
||
w = make_window(8, 2, day_of_week=0)
|
||
assert w.day_of_week == 0
|
||
|
||
def test_day_of_week_integer_out_of_range(self):
|
||
with pytest.raises(ValidationError):
|
||
make_window(8, 2, day_of_week=7)
|
||
|
||
def test_day_of_week_english_string(self):
|
||
w = make_window(8, 2, day_of_week="Monday")
|
||
assert w.day_of_week == 0
|
||
|
||
def test_day_of_week_english_string_case_insensitive(self):
|
||
w = make_window(8, 2, day_of_week="friday")
|
||
assert w.day_of_week == 4
|
||
|
||
def test_day_of_week_invalid_string(self):
|
||
with pytest.raises(ValidationError, match="Invalid weekday"):
|
||
make_window(8, 2, day_of_week="notaday")
|
||
|
||
def test_day_of_week_localized_german(self):
|
||
w = make_window(8, 2, day_of_week="Montag", locale="de")
|
||
assert w.day_of_week == 0
|
||
|
||
|
||
# ===========================================================================
|
||
# _window_start_end
|
||
# ===========================================================================
|
||
|
||
class TestWindowStartEnd:
|
||
def test_naive_reference_date(self):
|
||
w = make_window(8, 2)
|
||
ref = naive_dt(2024, 6, 15, 10, 0, 0)
|
||
start, end = w._window_start_end(ref)
|
||
assert start.hour == 8
|
||
assert start.minute == 0
|
||
assert end.hour == 10
|
||
assert end.minute == 0
|
||
assert start.timezone is None
|
||
|
||
def test_aware_reference_date_berlin(self):
|
||
w = make_window(8, 2)
|
||
ref = aware_dt(2024, 6, 15, 10, 0, 0, tz="Europe/Berlin")
|
||
start, end = w._window_start_end(ref)
|
||
assert start.hour == 8
|
||
assert end.hour == 10
|
||
assert str(start.timezone) == "Europe/Berlin"
|
||
|
||
def test_aware_reference_date_utc(self):
|
||
w = make_window(6, 4)
|
||
ref = aware_dt(2024, 1, 10, 9, 0, 0, tz="UTC")
|
||
start, end = w._window_start_end(ref)
|
||
assert start.hour == 6
|
||
assert end.hour == 10
|
||
assert str(start.timezone) == "UTC"
|
||
|
||
def test_aware_reference_date_eastern(self):
|
||
w = make_window(20, 4)
|
||
ref = aware_dt(2024, 6, 15, 21, 0, 0, tz="US/Eastern")
|
||
start, end = w._window_start_end(ref)
|
||
assert start.hour == 20
|
||
assert end.hour == 0 # midnight next day
|
||
assert str(start.timezone) == "US/Eastern"
|
||
|
||
|
||
# ===========================================================================
|
||
# contains() — naive datetime
|
||
# ===========================================================================
|
||
|
||
class TestContainsNaive:
|
||
def setup_method(self, method):
|
||
self.w = make_window(8, 2)
|
||
|
||
def test_inside_window(self):
|
||
assert self.w.contains(naive_dt(2024, 6, 15, 9, 0, 0))
|
||
|
||
def test_at_start(self):
|
||
assert self.w.contains(naive_dt(2024, 6, 15, 8, 0, 0))
|
||
|
||
def test_at_end_exclusive(self):
|
||
assert not self.w.contains(naive_dt(2024, 6, 15, 10, 0, 0))
|
||
|
||
def test_before_window(self):
|
||
assert not self.w.contains(naive_dt(2024, 6, 15, 7, 59, 59))
|
||
|
||
def test_after_window(self):
|
||
assert not self.w.contains(naive_dt(2024, 6, 15, 10, 0, 1))
|
||
|
||
def test_with_fitting_duration(self):
|
||
dt = naive_dt(2024, 6, 15, 8, 0, 0)
|
||
assert self.w.contains(dt, duration=pendulum.duration(hours=2))
|
||
|
||
def test_with_duration_too_long(self):
|
||
dt = naive_dt(2024, 6, 15, 8, 0, 0)
|
||
assert not self.w.contains(dt, duration=pendulum.duration(hours=3))
|
||
|
||
def test_with_duration_starting_late(self):
|
||
dt = naive_dt(2024, 6, 15, 9, 30, 0)
|
||
assert not self.w.contains(dt, duration=pendulum.duration(hours=1))
|
||
|
||
def test_with_duration_exactly_fitting_late(self):
|
||
dt = naive_dt(2024, 6, 15, 9, 0, 0)
|
||
assert self.w.contains(dt, duration=pendulum.duration(hours=1))
|
||
|
||
|
||
# ===========================================================================
|
||
# contains() — aware datetime
|
||
# ===========================================================================
|
||
|
||
class TestContainsAware:
|
||
def setup_method(self, method):
|
||
self.w = make_window(8, 2)
|
||
|
||
def test_inside_window_berlin(self):
|
||
dt = aware_dt(2024, 6, 15, 9, 0, 0, tz="Europe/Berlin")
|
||
assert self.w.contains(dt)
|
||
|
||
def test_before_window_berlin(self):
|
||
dt = aware_dt(2024, 6, 15, 7, 30, 0, tz="Europe/Berlin")
|
||
assert not self.w.contains(dt)
|
||
|
||
def test_after_window_berlin(self):
|
||
dt = aware_dt(2024, 6, 15, 10, 30, 0, tz="Europe/Berlin")
|
||
assert not self.w.contains(dt)
|
||
|
||
def test_start_time_is_local_not_utc(self):
|
||
# 06:00 UTC is before the 08:00 UTC window → outside
|
||
dt_utc = aware_dt(2024, 6, 15, 6, 0, 0, tz="UTC")
|
||
assert not self.w.contains(dt_utc)
|
||
|
||
# 08:30 UTC is inside the 08:00–10:00 UTC window
|
||
dt_utc_inside = aware_dt(2024, 6, 15, 8, 30, 0, tz="UTC")
|
||
assert self.w.contains(dt_utc_inside)
|
||
|
||
def test_crossing_midnight(self):
|
||
w = make_window(23, 2)
|
||
dt_inside = aware_dt(2024, 6, 15, 23, 30, 0, tz="Europe/Berlin")
|
||
dt_outside = aware_dt(2024, 6, 15, 22, 59, 0, tz="Europe/Berlin")
|
||
assert w.contains(dt_inside)
|
||
assert not w.contains(dt_outside)
|
||
|
||
def test_same_wall_clock_different_tz(self):
|
||
"""Naive start_time means 12:00 wall clock in *whatever* tz is passed."""
|
||
w = make_window(12, 2)
|
||
dt_berlin = aware_dt(2024, 6, 15, 13, 0, 0, tz="Europe/Berlin")
|
||
dt_ny = aware_dt(2024, 6, 15, 13, 0, 0, tz="US/Eastern")
|
||
assert w.contains(dt_berlin)
|
||
assert w.contains(dt_ny)
|
||
|
||
|
||
# ===========================================================================
|
||
# contains() — day_of_week and date constraints
|
||
# ===========================================================================
|
||
|
||
class TestContainsConstraints:
|
||
def test_day_of_week_match_naive(self):
|
||
# 2024-06-17 is a Monday (day_of_week == 0)
|
||
w = make_window(8, 4, day_of_week=0)
|
||
assert w.contains(naive_dt(2024, 6, 17, 9, 0, 0))
|
||
|
||
def test_day_of_week_no_match_naive(self):
|
||
w = make_window(8, 4, day_of_week=0)
|
||
# 2024-06-18 is a Tuesday
|
||
assert not w.contains(naive_dt(2024, 6, 18, 9, 0, 0))
|
||
|
||
def test_day_of_week_match_aware(self):
|
||
w = make_window(8, 4, day_of_week=0)
|
||
dt = aware_dt(2024, 6, 17, 9, 0, 0, tz="Europe/Berlin")
|
||
assert w.contains(dt)
|
||
|
||
def test_date_constraint_match(self):
|
||
w = make_window(8, 4, date=pendulum.date(2024, 6, 17))
|
||
assert w.contains(naive_dt(2024, 6, 17, 9, 0, 0))
|
||
|
||
def test_date_constraint_no_match(self):
|
||
w = make_window(8, 4, date=pendulum.date(2024, 6, 17))
|
||
assert not w.contains(naive_dt(2024, 6, 18, 9, 0, 0))
|
||
|
||
def test_date_constraint_aware_datetime(self):
|
||
w = make_window(8, 4, date=pendulum.date(2024, 6, 17))
|
||
dt = aware_dt(2024, 6, 17, 9, 0, 0, tz="US/Eastern")
|
||
assert w.contains(dt)
|
||
|
||
def test_date_and_day_of_week_both_must_hold(self):
|
||
# 2024-06-18 is Tuesday; day_of_week=0 (Monday) → False even on matching date
|
||
w = make_window(8, 4, date=pendulum.date(2024, 6, 18), day_of_week=0)
|
||
assert not w.contains(naive_dt(2024, 6, 18, 9, 0, 0))
|
||
|
||
|
||
# ===========================================================================
|
||
# earliest_start_time / latest_start_time
|
||
# ===========================================================================
|
||
|
||
class TestStartTimes:
|
||
def setup_method(self, method):
|
||
self.w = make_window(8, 4) # 08:00–12:00
|
||
|
||
def test_earliest_naive(self):
|
||
ref = naive_dt(2024, 6, 15)
|
||
result = self.w.earliest_start_time(pendulum.duration(hours=2), reference_date=ref)
|
||
assert result is not None
|
||
assert result.hour == 8
|
||
|
||
def test_latest_naive(self):
|
||
ref = naive_dt(2024, 6, 15)
|
||
result = self.w.latest_start_time(pendulum.duration(hours=2), reference_date=ref)
|
||
assert result is not None
|
||
assert result.hour == 10 # 12:00 - 2h = 10:00
|
||
|
||
def test_earliest_aware_berlin(self):
|
||
ref = aware_dt(2024, 6, 15, tz="Europe/Berlin")
|
||
result = self.w.earliest_start_time(pendulum.duration(hours=1), reference_date=ref)
|
||
assert result is not None
|
||
assert result.hour == 8
|
||
assert str(result.timezone) == "Europe/Berlin"
|
||
|
||
def test_latest_aware_berlin(self):
|
||
ref = aware_dt(2024, 6, 15, tz="Europe/Berlin")
|
||
result = self.w.latest_start_time(pendulum.duration(hours=1), reference_date=ref)
|
||
assert result is not None
|
||
assert result.hour == 11
|
||
assert str(result.timezone) == "Europe/Berlin"
|
||
|
||
def test_duration_too_long_returns_none(self):
|
||
ref = naive_dt(2024, 6, 15)
|
||
result = self.w.earliest_start_time(pendulum.duration(hours=5), reference_date=ref)
|
||
assert result is None
|
||
|
||
def test_wrong_day_of_week_returns_none(self):
|
||
w = make_window(8, 4, day_of_week=0) # Monday only
|
||
ref = naive_dt(2024, 6, 18) # Tuesday
|
||
assert w.earliest_start_time(pendulum.duration(hours=1), reference_date=ref) is None
|
||
|
||
def test_wrong_date_returns_none(self):
|
||
w = make_window(8, 4, date=pendulum.date(2024, 6, 17))
|
||
ref = naive_dt(2024, 6, 18)
|
||
assert w.earliest_start_time(pendulum.duration(hours=1), reference_date=ref) is None
|
||
|
||
def test_earliest_aware_utc(self):
|
||
ref = aware_dt(2024, 6, 15, tz="UTC")
|
||
result = self.w.earliest_start_time(pendulum.duration(hours=2), reference_date=ref)
|
||
assert result is not None
|
||
assert result.hour == 8
|
||
assert str(result.timezone) == "UTC"
|
||
|
||
def test_latest_equals_window_end_minus_duration(self):
|
||
ref = naive_dt(2024, 6, 15)
|
||
result = self.w.latest_start_time(pendulum.duration(hours=4), reference_date=ref)
|
||
assert result is not None
|
||
assert result.hour == 8 # exactly at window start when duration == window size
|
||
|
||
def test_latest_duration_leaves_no_room(self):
|
||
# duration > window → None
|
||
ref = naive_dt(2024, 6, 15)
|
||
result = self.w.latest_start_time(pendulum.duration(hours=5), reference_date=ref)
|
||
assert result is None
|
||
|
||
|
||
# ===========================================================================
|
||
# can_fit_duration / available_duration
|
||
# ===========================================================================
|
||
|
||
class TestFitAndAvailable:
|
||
def setup_method(self, method):
|
||
self.w = make_window(8, 3) # 08:00–11:00
|
||
|
||
def test_can_fit_exact(self):
|
||
assert self.w.can_fit_duration(pendulum.duration(hours=3), naive_dt(2024, 6, 15))
|
||
|
||
def test_can_fit_shorter(self):
|
||
assert self.w.can_fit_duration(pendulum.duration(hours=1), naive_dt(2024, 6, 15))
|
||
|
||
def test_cannot_fit_longer(self):
|
||
assert not self.w.can_fit_duration(pendulum.duration(hours=4), naive_dt(2024, 6, 15))
|
||
|
||
def test_available_duration_no_constraint(self):
|
||
result = self.w.available_duration(naive_dt(2024, 6, 15))
|
||
assert result == pendulum.duration(hours=3)
|
||
|
||
def test_available_duration_wrong_date(self):
|
||
w = make_window(8, 3, date=pendulum.date(2024, 6, 17))
|
||
result = w.available_duration(naive_dt(2024, 6, 15))
|
||
assert result is None
|
||
|
||
|
||
# ===========================================================================
|
||
# TimeWindowSequence
|
||
# ===========================================================================
|
||
|
||
class TestTimeWindowSequence:
|
||
def setup_method(self, method):
|
||
self.seq = TimeWindowSequence(
|
||
windows=[
|
||
make_window(8, 2), # 08:00–10:00
|
||
make_window(14, 3), # 14:00–17:00
|
||
]
|
||
)
|
||
|
||
def test_contains_first_window(self):
|
||
assert self.seq.contains(naive_dt(2024, 6, 15, 9, 0, 0))
|
||
|
||
def test_contains_second_window(self):
|
||
assert self.seq.contains(naive_dt(2024, 6, 15, 15, 0, 0))
|
||
|
||
def test_contains_gap_between_windows(self):
|
||
assert not self.seq.contains(naive_dt(2024, 6, 15, 12, 0, 0))
|
||
|
||
def test_contains_with_duration_fits_second(self):
|
||
dt = naive_dt(2024, 6, 15, 14, 0, 0)
|
||
assert self.seq.contains(dt, duration=pendulum.duration(hours=2))
|
||
|
||
def test_contains_aware(self):
|
||
dt = aware_dt(2024, 6, 15, 9, 0, 0, tz="Europe/Berlin")
|
||
assert self.seq.contains(dt)
|
||
|
||
def test_earliest_start_time(self):
|
||
ref = naive_dt(2024, 6, 15)
|
||
result = self.seq.earliest_start_time(pendulum.duration(hours=1), ref)
|
||
assert result is not None
|
||
assert result.hour == 8
|
||
|
||
def test_latest_start_time(self):
|
||
ref = naive_dt(2024, 6, 15)
|
||
result = self.seq.latest_start_time(pendulum.duration(hours=1), ref)
|
||
assert result is not None
|
||
assert result.hour == 16 # 17:00 - 1h
|
||
|
||
def test_available_duration_sum(self):
|
||
ref = naive_dt(2024, 6, 15)
|
||
result = self.seq.available_duration(ref)
|
||
assert result == pendulum.duration(hours=5)
|
||
|
||
def test_empty_sequence_contains_false(self):
|
||
seq = TimeWindowSequence()
|
||
assert not seq.contains(naive_dt(2024, 6, 15, 9, 0, 0))
|
||
|
||
def test_empty_sequence_earliest_none(self):
|
||
seq = TimeWindowSequence()
|
||
assert seq.earliest_start_time(pendulum.duration(hours=1), naive_dt(2024, 6, 15)) is None
|
||
|
||
def test_empty_sequence_available_none(self):
|
||
seq = TimeWindowSequence()
|
||
assert seq.available_duration(naive_dt(2024, 6, 15)) is None
|
||
|
||
def test_get_applicable_windows(self):
|
||
ref = naive_dt(2024, 6, 15)
|
||
applicable = self.seq.get_applicable_windows(ref)
|
||
assert len(applicable) == 2
|
||
|
||
def test_find_windows_for_duration_fits_both(self):
|
||
ref = naive_dt(2024, 6, 15)
|
||
fits = self.seq.find_windows_for_duration(pendulum.duration(hours=1), ref)
|
||
assert len(fits) == 2
|
||
|
||
def test_find_windows_for_duration_fits_only_second(self):
|
||
ref = naive_dt(2024, 6, 15)
|
||
fits = self.seq.find_windows_for_duration(pendulum.duration(hours=3), ref)
|
||
assert len(fits) == 1
|
||
assert fits[0].start_time.hour == 14
|
||
|
||
def test_sort_windows_by_start_time(self):
|
||
seq = TimeWindowSequence(
|
||
windows=[make_window(14, 1), make_window(8, 1)]
|
||
)
|
||
ref = naive_dt(2024, 6, 15)
|
||
seq.sort_windows_by_start_time(ref)
|
||
assert seq.windows[0].start_time.hour == 8
|
||
assert seq.windows[1].start_time.hour == 14
|
||
|
||
def test_add_and_remove_window(self):
|
||
seq = TimeWindowSequence()
|
||
w = make_window(10, 1)
|
||
seq.add_window(w)
|
||
assert len(seq) == 1
|
||
removed = seq.remove_window(0)
|
||
assert removed == w
|
||
assert len(seq) == 0
|
||
|
||
def test_remove_from_empty_raises(self):
|
||
seq = TimeWindowSequence()
|
||
with pytest.raises(IndexError):
|
||
seq.remove_window(0)
|
||
|
||
def test_get_all_possible_start_times(self):
|
||
ref = naive_dt(2024, 6, 15)
|
||
result = self.seq.get_all_possible_start_times(pendulum.duration(hours=1), ref)
|
||
assert len(result) == 2
|
||
earliest_hours = sorted(e.hour for e, _, _ in result)
|
||
assert earliest_hours == [8, 14]
|
||
|
||
def test_iter_and_len_and_getitem(self):
|
||
assert len(self.seq) == 2
|
||
windows = list(self.seq)
|
||
assert len(windows) == 2
|
||
assert self.seq[0].start_time.hour == 8
|
||
|
||
|
||
# ===========================================================================
|
||
# ValueTimeWindow / ValueTimeWindowSequence
|
||
# ===========================================================================
|
||
|
||
class TestValueTimeWindow:
|
||
def test_value_stored(self):
|
||
w = ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.288)
|
||
assert w.value == pytest.approx(0.288)
|
||
|
||
def test_value_default_none(self):
|
||
w = ValueTimeWindow(start_time="08:00:00", duration="2 hours")
|
||
assert w.value is None
|
||
|
||
def test_inherits_aware_start_time_stripped(self):
|
||
"""ValueTimeWindow inherits the strip-to-naive behaviour from TimeWindow."""
|
||
w = ValueTimeWindow(
|
||
start_time=Time(8, 0, 0, tzinfo=pendulum.timezone("UTC")),
|
||
duration="2 hours",
|
||
value=0.1,
|
||
)
|
||
assert w.start_time.tzinfo is None
|
||
assert w.start_time.hour == 8
|
||
|
||
|
||
class TestValueTimeWindowSequence:
|
||
def setup_method(self, method):
|
||
self.seq = ValueTimeWindowSequence(
|
||
windows=[
|
||
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.25),
|
||
ValueTimeWindow(start_time="18:00:00", duration="4 hours", value=0.35),
|
||
]
|
||
)
|
||
|
||
def test_get_value_morning(self):
|
||
dt = naive_dt(2024, 6, 15, 9, 0, 0)
|
||
assert self.seq.get_value_for_datetime(dt) == pytest.approx(0.25)
|
||
|
||
def test_get_value_evening(self):
|
||
dt = naive_dt(2024, 6, 15, 19, 0, 0)
|
||
assert self.seq.get_value_for_datetime(dt) == pytest.approx(0.35)
|
||
|
||
def test_get_value_outside_all_windows(self):
|
||
dt = naive_dt(2024, 6, 15, 13, 0, 0)
|
||
assert self.seq.get_value_for_datetime(dt) == pytest.approx(0.0)
|
||
|
||
def test_get_value_none_value_returns_zero(self):
|
||
seq = ValueTimeWindowSequence(
|
||
windows=[ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=None)]
|
||
)
|
||
assert seq.get_value_for_datetime(naive_dt(2024, 6, 15, 9, 0, 0)) == pytest.approx(0.0)
|
||
|
||
def test_get_value_aware_datetime(self):
|
||
dt = aware_dt(2024, 6, 15, 9, 0, 0, tz="Europe/Berlin")
|
||
assert self.seq.get_value_for_datetime(dt) == pytest.approx(0.25)
|
||
|
||
|
||
# ===========================================================================
|
||
# TimeWindowSequence.to_array
|
||
# ===========================================================================
|
||
|
||
class TestTimeWindowSequenceToArray:
|
||
"""Tests for TimeWindowSequence.to_array.
|
||
|
||
Window layout used throughout:
|
||
win1: 08:00–10:00 (2 h)
|
||
win2: 14:00–17:00 (3 h)
|
||
|
||
Grid step = 1 hour unless stated otherwise.
|
||
"""
|
||
|
||
def setup_method(self, method):
|
||
self.seq = TimeWindowSequence(
|
||
windows=[
|
||
make_window(8, 2), # 08:00–10:00
|
||
make_window(14, 3), # 14:00–17:00
|
||
]
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# basic correctness
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_basic_1h_steps_naive(self):
|
||
start = naive_dt(2024, 6, 15, 0)
|
||
end = naive_dt(2024, 6, 15, 0).add(hours=24)
|
||
arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
|
||
assert arr.shape == (24,)
|
||
# Window 1: hours 8, 9
|
||
assert arr[8] == pytest.approx(1.0)
|
||
assert arr[9] == pytest.approx(1.0)
|
||
assert arr[10] == pytest.approx(0.0)
|
||
# Window 2: hours 14, 15, 16
|
||
assert arr[14] == pytest.approx(1.0)
|
||
assert arr[15] == pytest.approx(1.0)
|
||
assert arr[16] == pytest.approx(1.0)
|
||
assert arr[17] == pytest.approx(0.0)
|
||
# Gap between windows
|
||
assert arr[12] == pytest.approx(0.0)
|
||
|
||
def test_basic_1h_steps_aware_berlin(self):
|
||
start = aware_dt(2024, 6, 15, 0, tz="Europe/Berlin")
|
||
end = aware_dt(2024, 6, 16, 0, tz="Europe/Berlin")
|
||
arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
|
||
assert arr.shape == (24,)
|
||
assert arr[8] == pytest.approx(1.0)
|
||
assert arr[9] == pytest.approx(1.0)
|
||
assert arr[10] == pytest.approx(0.0)
|
||
assert arr[14] == pytest.approx(1.0)
|
||
assert arr[16] == pytest.approx(1.0)
|
||
assert arr[17] == pytest.approx(0.0)
|
||
|
||
def test_outside_all_windows_all_zeros(self):
|
||
# Only 2 hours at midnight — no overlap with any window
|
||
start = naive_dt(2024, 6, 15, 0)
|
||
end = naive_dt(2024, 6, 15, 2)
|
||
arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
|
||
assert np.all(arr == 0.0)
|
||
|
||
def test_inside_one_window_all_ones(self):
|
||
# Entirely inside window 1 (08:00–10:00)
|
||
start = naive_dt(2024, 6, 15, 8)
|
||
end = naive_dt(2024, 6, 15, 10)
|
||
arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
|
||
assert np.all(arr == 1.0)
|
||
|
||
def test_dtype_is_float64(self):
|
||
start = naive_dt(2024, 6, 15, 0)
|
||
end = naive_dt(2024, 6, 15, 4)
|
||
arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
|
||
assert arr.dtype == np.float64
|
||
|
||
def test_end_is_exclusive(self):
|
||
# end == window start → 0 steps inside
|
||
start = naive_dt(2024, 6, 15, 6)
|
||
end = naive_dt(2024, 6, 15, 8) # exclusive — 08:00 itself not emitted
|
||
arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
|
||
assert arr.shape == (2,)
|
||
assert np.all(arr == 0.0)
|
||
|
||
# ------------------------------------------------------------------
|
||
# sub-hour steps
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_30min_steps(self):
|
||
start = naive_dt(2024, 6, 15, 8)
|
||
end = naive_dt(2024, 6, 15, 10)
|
||
arr = self.seq.to_array(start, end, pendulum.duration(minutes=30))
|
||
# Steps: 08:00, 08:30 → both inside [08:00, 10:00)
|
||
assert arr.shape == (4,)
|
||
assert np.all(arr == 1.0)
|
||
|
||
def test_15min_steps_boundary(self):
|
||
# Steps at 09:45, 10:00, 10:15; only 09:45 inside window
|
||
start = naive_dt(2024, 6, 15, 9, 45)
|
||
end = naive_dt(2024, 6, 15, 10, 30)
|
||
arr = self.seq.to_array(start, end, pendulum.duration(minutes=15))
|
||
# align_to_interval=True floors to interval boundary
|
||
# interval=15 min; 09:45 is already on a 15-min boundary
|
||
assert arr[0] == pytest.approx(1.0) # 09:45 inside win1
|
||
assert arr[1] == pytest.approx(0.0) # 10:00 outside (exclusive end)
|
||
|
||
# ------------------------------------------------------------------
|
||
# align_to_interval
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_align_to_interval_false_preserves_start(self):
|
||
# Start at 08:10 — not on a whole-hour boundary
|
||
start = naive_dt(2024, 6, 15, 8, 10)
|
||
end = naive_dt(2024, 6, 15, 10, 10)
|
||
arr = self.seq.to_array(
|
||
start, end, pendulum.duration(hours=1), align_to_interval=False
|
||
)
|
||
# Steps: 08:10 (inside win1), 09:10 (inside win1), 10:10 (outside)
|
||
assert arr.shape == (2,)
|
||
assert arr[0] == pytest.approx(1.0)
|
||
assert arr[1] == pytest.approx(1.0)
|
||
|
||
def test_align_to_interval_true_floors_start(self):
|
||
# Start at 08:10; floored to 08:00 with 1h interval
|
||
start = naive_dt(2024, 6, 15, 8, 10)
|
||
end = naive_dt(2024, 6, 15, 10, 10)
|
||
arr = self.seq.to_array(
|
||
start, end, pendulum.duration(hours=1), align_to_interval=True
|
||
)
|
||
# After flooring: steps 08:00, 09:00, 10:00 → 3 steps
|
||
assert arr.shape == (3,)
|
||
assert arr[0] == pytest.approx(1.0) # 08:00
|
||
assert arr[1] == pytest.approx(1.0) # 09:00
|
||
assert arr[2] == pytest.approx(0.0) # 10:00
|
||
|
||
# ------------------------------------------------------------------
|
||
# boundary validation
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_unsupported_boundary_raises(self):
|
||
start = naive_dt(2024, 6, 15, 0)
|
||
end = naive_dt(2024, 6, 15, 4)
|
||
with pytest.raises(ValueError, match="boundary"):
|
||
self.seq.to_array(start, end, pendulum.duration(hours=1), boundary="strict")
|
||
|
||
# ------------------------------------------------------------------
|
||
# dropna (no effect for binary windows — accepted for compat)
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_dropna_true_no_effect(self):
|
||
start = naive_dt(2024, 6, 15, 0)
|
||
end = naive_dt(2024, 6, 15, 4)
|
||
arr_t = self.seq.to_array(start, end, pendulum.duration(hours=1), dropna=True)
|
||
arr_f = self.seq.to_array(start, end, pendulum.duration(hours=1), dropna=False)
|
||
np.testing.assert_array_equal(arr_t, arr_f)
|
||
|
||
# ------------------------------------------------------------------
|
||
# empty sequence
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_empty_sequence_all_zeros(self):
|
||
seq = TimeWindowSequence()
|
||
start = naive_dt(2024, 6, 15, 0)
|
||
end = naive_dt(2024, 6, 15, 4)
|
||
arr = seq.to_array(start, end, pendulum.duration(hours=1))
|
||
assert arr.shape == (4,)
|
||
assert np.all(arr == 0.0)
|
||
|
||
# ------------------------------------------------------------------
|
||
# day_of_week and date constraints propagate
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_day_of_week_constraint_respected(self):
|
||
# Monday-only window; 2024-06-17 is Monday, 2024-06-18 is Tuesday
|
||
seq = TimeWindowSequence(windows=[make_window(8, 2, day_of_week=0)])
|
||
monday_start = naive_dt(2024, 6, 17, 7)
|
||
tuesday_start = naive_dt(2024, 6, 18, 7)
|
||
end_offset = pendulum.duration(hours=4)
|
||
|
||
arr_mon = seq.to_array(monday_start, monday_start.add(hours=4),
|
||
pendulum.duration(hours=1))
|
||
arr_tue = seq.to_array(tuesday_start, tuesday_start.add(hours=4),
|
||
pendulum.duration(hours=1))
|
||
|
||
assert arr_mon[1] == pytest.approx(1.0) # 08:00 Monday — inside
|
||
assert np.all(arr_tue == 0.0) # Tuesday — all outside
|
||
|
||
|
||
# ===========================================================================
|
||
# ValueTimeWindowSequence.to_array
|
||
# ===========================================================================
|
||
|
||
class TestValueTimeWindowSequenceToArray:
|
||
"""Tests for ValueTimeWindowSequence.to_array.
|
||
|
||
Window layout:
|
||
win1: 08:00–12:00 value=0.25
|
||
win2: 18:00–22:00 value=0.35
|
||
"""
|
||
|
||
def setup_method(self, method):
|
||
self.seq = ValueTimeWindowSequence(
|
||
windows=[
|
||
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.25),
|
||
ValueTimeWindow(start_time="18:00:00", duration="4 hours", value=0.35),
|
||
]
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# basic correctness
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_basic_1h_steps_values(self):
|
||
start = naive_dt(2024, 6, 15, 0)
|
||
end = naive_dt(2024, 6, 16, 0)
|
||
arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
|
||
assert arr.shape == (24,)
|
||
# win1: hours 8–11
|
||
assert arr[8] == pytest.approx(0.25)
|
||
assert arr[11] == pytest.approx(0.25)
|
||
assert arr[12] == pytest.approx(0.0)
|
||
# win2: hours 18–21
|
||
assert arr[18] == pytest.approx(0.35)
|
||
assert arr[21] == pytest.approx(0.35)
|
||
assert arr[22] == pytest.approx(0.0)
|
||
# Gap
|
||
assert arr[0] == pytest.approx(0.0)
|
||
assert arr[14] == pytest.approx(0.0)
|
||
|
||
def test_dtype_is_float64(self):
|
||
start = naive_dt(2024, 6, 15, 0)
|
||
end = naive_dt(2024, 6, 15, 4)
|
||
arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
|
||
assert arr.dtype == np.float64
|
||
|
||
def test_zero_outside_all_windows(self):
|
||
start = naive_dt(2024, 6, 15, 12)
|
||
end = naive_dt(2024, 6, 15, 18)
|
||
arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
|
||
assert np.all(arr == 0.0)
|
||
|
||
def test_aware_datetime_berlin(self):
|
||
start = aware_dt(2024, 6, 15, 0, tz="Europe/Berlin")
|
||
end = aware_dt(2024, 6, 16, 0, tz="Europe/Berlin")
|
||
arr = self.seq.to_array(start, end, pendulum.duration(hours=1))
|
||
assert arr.shape == (24,)
|
||
assert arr[8] == pytest.approx(0.25)
|
||
assert arr[18] == pytest.approx(0.35)
|
||
assert arr[7] == pytest.approx(0.0)
|
||
|
||
# ------------------------------------------------------------------
|
||
# dropna semantics
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_dropna_false_none_value_emits_nan(self):
|
||
seq = ValueTimeWindowSequence(
|
||
windows=[
|
||
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=None),
|
||
ValueTimeWindow(start_time="12:00:00", duration="2 hours", value=0.5),
|
||
]
|
||
)
|
||
start = naive_dt(2024, 6, 15, 8)
|
||
end = naive_dt(2024, 6, 15, 15)
|
||
arr = seq.to_array(start, end, pendulum.duration(hours=1), dropna=False)
|
||
# Steps: 08, 09 (nan), 10 (0), 11 (0), 12 (0.5), 13 (0.5), 14 (0)
|
||
assert arr.shape == (7,)
|
||
assert np.isnan(arr[0])
|
||
assert np.isnan(arr[1])
|
||
assert arr[2] == pytest.approx(0.0)
|
||
assert arr[4] == pytest.approx(0.5)
|
||
|
||
def test_dropna_true_none_value_step_omitted(self):
|
||
seq = ValueTimeWindowSequence(
|
||
windows=[
|
||
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=None),
|
||
ValueTimeWindow(start_time="12:00:00", duration="2 hours", value=0.5),
|
||
]
|
||
)
|
||
start = naive_dt(2024, 6, 15, 8)
|
||
end = naive_dt(2024, 6, 15, 15)
|
||
arr = seq.to_array(start, end, pendulum.duration(hours=1), dropna=True)
|
||
# 08 and 09 dropped (None value), remaining 5 steps: 10,11,12,13,14
|
||
assert arr.shape == (5,)
|
||
assert arr[0] == pytest.approx(0.0) # 10:00
|
||
assert arr[1] == pytest.approx(0.0) # 11:00
|
||
assert arr[2] == pytest.approx(0.5) # 12:00
|
||
assert arr[3] == pytest.approx(0.5) # 13:00
|
||
assert arr[4] == pytest.approx(0.0) # 14:00
|
||
|
||
def test_dropna_no_none_values_same_result(self):
|
||
start = naive_dt(2024, 6, 15, 0)
|
||
end = naive_dt(2024, 6, 15, 6)
|
||
arr_t = self.seq.to_array(start, end, pendulum.duration(hours=1), dropna=True)
|
||
arr_f = self.seq.to_array(start, end, pendulum.duration(hours=1), dropna=False)
|
||
np.testing.assert_array_equal(arr_t, arr_f)
|
||
|
||
# ------------------------------------------------------------------
|
||
# align_to_interval and boundary
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_align_to_interval_false(self):
|
||
# Start at 08:30 — between steps
|
||
start = naive_dt(2024, 6, 15, 8, 30)
|
||
end = naive_dt(2024, 6, 15, 12, 30)
|
||
arr = self.seq.to_array(
|
||
start, end, pendulum.duration(hours=1), align_to_interval=False
|
||
)
|
||
# Steps: 08:30, 09:30, 10:30, 11:30 → all inside win1 [08:00–12:00)
|
||
assert arr.shape == (4,)
|
||
assert np.all(arr == pytest.approx(0.25))
|
||
|
||
def test_unsupported_boundary_raises(self):
|
||
start = naive_dt(2024, 6, 15, 0)
|
||
end = naive_dt(2024, 6, 15, 4)
|
||
with pytest.raises(ValueError, match="boundary"):
|
||
self.seq.to_array(start, end, pendulum.duration(hours=1), boundary="inner")
|
||
|
||
# ------------------------------------------------------------------
|
||
# empty sequence
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_empty_sequence_all_zeros(self):
|
||
seq = ValueTimeWindowSequence()
|
||
start = naive_dt(2024, 6, 15, 0)
|
||
end = naive_dt(2024, 6, 15, 4)
|
||
arr = seq.to_array(start, end, pendulum.duration(hours=1))
|
||
assert arr.shape == (4,)
|
||
assert np.all(arr == 0.0)
|
||
|
||
# ------------------------------------------------------------------
|
||
# overlapping windows — first match wins
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_overlapping_windows_first_wins(self):
|
||
seq = ValueTimeWindowSequence(
|
||
windows=[
|
||
ValueTimeWindow(start_time="08:00:00", duration="4 hours", value=0.10),
|
||
ValueTimeWindow(start_time="09:00:00", duration="4 hours", value=0.99),
|
||
]
|
||
)
|
||
start = naive_dt(2024, 6, 15, 9)
|
||
end = naive_dt(2024, 6, 15, 11)
|
||
arr = seq.to_array(start, end, pendulum.duration(hours=1))
|
||
# 09:00 and 10:00 are in both windows; first (0.10) must win
|
||
assert arr[0] == pytest.approx(0.10)
|
||
assert arr[1] == pytest.approx(0.10)
|
||
|
||
|
||
# ===========================================================================
|
||
# align_to_interval — timezone-invariance
|
||
#
|
||
# These tests reproduce the bug that existed before the wall-clock floor fix.
|
||
# The old epoch-arithmetic implementation gave wrong results when the machine's
|
||
# local timezone was non-UTC:
|
||
# - For naive datetimes, pendulum.instance() attached the *local* timezone,
|
||
# so subtracting a UTC epoch shifted the floored start.
|
||
# - For aware datetimes, subtracting a UTC epoch converted to UTC first,
|
||
# then epoch.add() returned a UTC datetime instead of preserving the
|
||
# original timezone.
|
||
#
|
||
# The `set_other_timezone` fixture (from conftest.py) temporarily changes
|
||
# pendulum's local timezone via pendulum.set_local_timezone() and restores it
|
||
# after the test. Calling it with no argument picks a non-UTC default
|
||
# ("Atlantic/Canary" or "Asia/Singapore"); calling it with "UTC" sets UTC.
|
||
#
|
||
# Each scenario runs two tests:
|
||
# _utc — local tz = UTC (passes even with the old code)
|
||
# _nonUTC — local tz = non-UTC (would have FAILED with the old code)
|
||
# ===========================================================================
|
||
|
||
|
||
class TestAlignToIntervalTimezoneInvariance:
|
||
"""Verify align_to_interval produces identical results regardless of
|
||
the machine's local timezone.
|
||
|
||
Tests are paired: ``_utc`` sets local tz to UTC, ``_non_utc`` sets it to
|
||
a non-UTC zone via ``set_other_timezone()``. The pair must produce the
|
||
same array — any divergence indicates a timezone-dependent bug.
|
||
"""
|
||
|
||
# ------------------------------------------------------------------
|
||
# helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _tws_naive():
|
||
"""TimeWindowSequence with one window 08:00–10:00."""
|
||
return TimeWindowSequence(windows=[make_window(8, 2)])
|
||
|
||
@staticmethod
|
||
def _tws_naive_start():
|
||
return naive_dt(2024, 6, 15, 8, 10)
|
||
|
||
@staticmethod
|
||
def _tws_naive_end():
|
||
return naive_dt(2024, 6, 15, 10, 10)
|
||
|
||
# ------------------------------------------------------------------
|
||
# TimeWindowSequence — naive datetime, 1-hour steps
|
||
# floor 08:10 → 08:00; expect steps 08:00(1), 09:00(1), 10:00(0)
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_tws_naive_floor_utc(self, set_other_timezone):
|
||
set_other_timezone("UTC")
|
||
arr = self._tws_naive().to_array(
|
||
self._tws_naive_start(), self._tws_naive_end(),
|
||
pendulum.duration(hours=1), align_to_interval=True,
|
||
)
|
||
assert arr.shape == (3,)
|
||
assert arr[0] == pytest.approx(1.0)
|
||
assert arr[1] == pytest.approx(1.0)
|
||
assert arr[2] == pytest.approx(0.0)
|
||
|
||
def test_tws_naive_floor_non_utc(self, set_other_timezone):
|
||
set_other_timezone()
|
||
arr = self._tws_naive().to_array(
|
||
self._tws_naive_start(), self._tws_naive_end(),
|
||
pendulum.duration(hours=1), align_to_interval=True,
|
||
)
|
||
assert arr.shape == (3,)
|
||
assert arr[0] == pytest.approx(1.0)
|
||
assert arr[1] == pytest.approx(1.0)
|
||
assert arr[2] == pytest.approx(0.0)
|
||
|
||
# ------------------------------------------------------------------
|
||
# TimeWindowSequence — naive datetime, 30-min steps
|
||
# floor 08:10 → 08:00; expect steps 08:00(1), 08:30(1), 09:00(1), 09:30(1), 10:00(0)
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_tws_naive_30min_floor_utc(self, set_other_timezone):
|
||
set_other_timezone("UTC")
|
||
arr = self._tws_naive().to_array(
|
||
self._tws_naive_start(), self._tws_naive_end(),
|
||
pendulum.duration(minutes=30), align_to_interval=True,
|
||
)
|
||
assert arr.shape == (5,)
|
||
assert np.all(arr[:4] == pytest.approx(1.0))
|
||
assert arr[4] == pytest.approx(0.0)
|
||
|
||
def test_tws_naive_30min_floor_non_utc(self, set_other_timezone):
|
||
set_other_timezone()
|
||
arr = self._tws_naive().to_array(
|
||
self._tws_naive_start(), self._tws_naive_end(),
|
||
pendulum.duration(minutes=30), align_to_interval=True,
|
||
)
|
||
assert arr.shape == (5,)
|
||
assert np.all(arr[:4] == pytest.approx(1.0))
|
||
assert arr[4] == pytest.approx(0.0)
|
||
|
||
# ------------------------------------------------------------------
|
||
# TimeWindowSequence — aware datetime (Europe/Berlin), 1-hour steps
|
||
# floor 08:10 Berlin → 08:00 Berlin; timezone must be preserved
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_tws_aware_floor_utc(self, set_other_timezone):
|
||
set_other_timezone("UTC")
|
||
seq = self._tws_naive()
|
||
start = aware_dt(2024, 6, 15, 8, 10, tz="Europe/Berlin")
|
||
end = aware_dt(2024, 6, 15, 10, 10, tz="Europe/Berlin")
|
||
arr = seq.to_array(start, end, pendulum.duration(hours=1), align_to_interval=True)
|
||
assert arr.shape == (3,)
|
||
assert arr[0] == pytest.approx(1.0)
|
||
assert arr[1] == pytest.approx(1.0)
|
||
assert arr[2] == pytest.approx(0.0)
|
||
|
||
def test_tws_aware_floor_non_utc(self, set_other_timezone):
|
||
set_other_timezone()
|
||
seq = self._tws_naive()
|
||
start = aware_dt(2024, 6, 15, 8, 10, tz="Europe/Berlin")
|
||
end = aware_dt(2024, 6, 15, 10, 10, tz="Europe/Berlin")
|
||
arr = seq.to_array(start, end, pendulum.duration(hours=1), align_to_interval=True)
|
||
assert arr.shape == (3,)
|
||
assert arr[0] == pytest.approx(1.0)
|
||
assert arr[1] == pytest.approx(1.0)
|
||
assert arr[2] == pytest.approx(0.0)
|
||
|
||
# ------------------------------------------------------------------
|
||
# ValueTimeWindowSequence — naive datetime, 1-hour steps
|
||
# floor 08:10 → 08:00; values 0.25 at 08:00, 09:00; 0.0 at 10:00
|
||
# ------------------------------------------------------------------
|
||
|
||
def test_vtws_naive_floor_utc(self, set_other_timezone):
|
||
set_other_timezone("UTC")
|
||
seq = ValueTimeWindowSequence(windows=[
|
||
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.25)
|
||
])
|
||
start = naive_dt(2024, 6, 15, 8, 10)
|
||
end = naive_dt(2024, 6, 15, 10, 10)
|
||
arr = seq.to_array(start, end, pendulum.duration(hours=1), align_to_interval=True)
|
||
assert arr.shape == (3,)
|
||
assert arr[0] == pytest.approx(0.25)
|
||
assert arr[1] == pytest.approx(0.25)
|
||
assert arr[2] == pytest.approx(0.0)
|
||
|
||
def test_vtws_naive_floor_non_utc(self, set_other_timezone):
|
||
set_other_timezone()
|
||
seq = ValueTimeWindowSequence(windows=[
|
||
ValueTimeWindow(start_time="08:00:00", duration="2 hours", value=0.25)
|
||
])
|
||
start = naive_dt(2024, 6, 15, 8, 10)
|
||
end = naive_dt(2024, 6, 15, 10, 10)
|
||
arr = seq.to_array(start, end, pendulum.duration(hours=1), align_to_interval=True)
|
||
assert arr.shape == (3,)
|
||
assert arr[0] == pytest.approx(0.25)
|
||
assert arr[1] == pytest.approx(0.25)
|
||
assert arr[2] == pytest.approx(0.0)
|