diff --git a/documentation/changelog.rst b/documentation/changelog.rst index c665c67c3..081ceab01 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -24,6 +24,8 @@ Infrastructure / Support * For weather forecasts, switch from Dark Sky (closed from Aug 1, 2021) to OpenWeatherMap API [see `PR #113 `_] * Re-use the database between automated tests, if possible. This shaves 2/3rd off of the time it takes for the FlexMeasures test suite to run [see `PR #115 `_] * Let CLI package and plugins use Marshmallow Field definitions [see `PR #125 `_] +* add time_utils.get_recent_clock_time_window() function [see `PR #135 `_] + v0.4.1 | May 7, 2021 diff --git a/flexmeasures/utils/tests/test_time_utils.py b/flexmeasures/utils/tests/test_time_utils.py index 6d3519bb1..f14d0a20f 100644 --- a/flexmeasures/utils/tests/test_time_utils.py +++ b/flexmeasures/utils/tests/test_time_utils.py @@ -6,6 +6,7 @@ from flexmeasures.utils.time_utils import ( server_now, naturalized_datetime_str, + get_most_recent_clocktime_window, ) @@ -41,3 +42,57 @@ def test_naturalized_datetime_str( h_ago = None print(h_ago) assert naturalized_datetime_str(h_ago, now=now) == exp_result + + +@pytest.mark.parametrize( + "window_size,now,exp_start,exp_end", + [ + ( + 5, + datetime(2021, 4, 30, 15, 1), + datetime(2021, 4, 30, 14, 55), + datetime(2021, 4, 30, 15), + ), + ( + 15, + datetime(2021, 4, 30, 3, 36), + datetime(2021, 4, 30, 3, 15), + datetime(2021, 4, 30, 3, 30), + ), + ( + 10, + datetime(2021, 4, 30, 0, 5), + datetime(2021, 4, 29, 23, 50), + datetime(2021, 4, 30, 0, 0), + ), + ( + 5, + datetime(2021, 5, 20, 10, 5, 34), # boundary condition + datetime(2021, 5, 20, 9, 55), + datetime(2021, 5, 20, 10, 0), + ), + ( + 60, + datetime(2021, 1, 1, 0, 4), # new year + datetime(2020, 12, 31, 23, 00), + datetime(2021, 1, 1, 0, 0), + ), + ( + 60, + datetime(2021, 3, 28, 3, 10), # DST transition + datetime(2021, 3, 28, 2), + datetime(2021, 3, 28, 3), + ), + ], +) +def test_recent_clocktime_window(window_size, now, exp_start, exp_end): + start, end = get_most_recent_clocktime_window(window_size, now=now) + assert start == exp_start + assert end == exp_end + + +def test_recent_clocktime_window_invalid_window(): + with pytest.raises(AssertionError): + get_most_recent_clocktime_window(25, now=datetime(2021, 4, 30, 3, 36)) + get_most_recent_clocktime_window(120, now=datetime(2021, 4, 30, 3, 36)) + get_most_recent_clocktime_window(0, now=datetime(2021, 4, 30, 3, 36)) diff --git a/flexmeasures/utils/time_utils.py b/flexmeasures/utils/time_utils.py index b72691ec0..c1b9b9ea5 100644 --- a/flexmeasures/utils/time_utils.py +++ b/flexmeasures/utils/time_utils.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import List, Union, Optional +from typing import List, Union, Tuple, Optional from flask import current_app from flask_security.core import current_user @@ -193,6 +193,40 @@ def get_most_recent_hour() -> datetime: return now.replace(minute=now.minute - (now.minute % 60), second=0, microsecond=0) +def get_most_recent_clocktime_window( + window_size_in_minutes: int, now: Optional[datetime] = None +) -> Tuple[datetime, datetime]: + """ + Calculate a recent time window, returning a start and end minute so that + a full hour can be filled with such windows, e.g.: + + Calling this function at 15:01:xx with window size 5 -> (14:55:00, 15:00:00) + Calling this function at 03:36:xx with window size 15 -> (03:15:00, 03:30:00) + + window_size_in_minutes is assumed to > 0 and < = 60, and a divisor of 60 (1, 2, ..., 30, 60). + + If now is not given, the current server time is used. + if now / the current time lies within a boundary minute (e.g. 15 when window_size_in_minutes=5), + then the window is not deemed over and the previous one is returned (in this case, [5, 10]) + + Returns two datetime objects. They'll be in the timezone (if given) of the now parameter, + or in the server timezone (see FLEXMEASURES_TIMEZONE setting). + """ + assert window_size_in_minutes > 0 + assert 60 % window_size_in_minutes == 0 + if now is None: + now = server_now() + last_full_minute = now.replace(second=0, microsecond=0) - timedelta(minutes=1) + last_round_minute = last_full_minute.minute - ( + last_full_minute.minute % window_size_in_minutes + ) + begin_time = last_full_minute.replace(minute=last_round_minute) - timedelta( + minutes=window_size_in_minutes + ) + end_time = begin_time + timedelta(minutes=window_size_in_minutes) + return begin_time, end_time + + def get_default_start_time() -> datetime: return get_most_recent_quarter() - timedelta(days=1)