Source code for sr.comp.match_period_clock
"""A clock to manage match periods."""
from __future__ import annotations
import datetime
from typing import Iterable, Iterator
from .match_period import Delay, MatchPeriod
[docs]class OutOfTimeException(Exception):
"""
An exception representing no more time available at the competition to run
matches.
"""
[docs]class MatchPeriodClock:
"""
A clock for use in scheduling matches within a ``MatchPeriod``.
It is generally expected that the time information here will be in the form
of ``datetime`` and ``timedelta`` instances, though any data which can be
compared and added appropriately should work.
Delay rules:
- Only delays which are scheduled after the start of the given period will
be considered.
- Delays are cumulative.
- Delays take effect as soon as their ``time`` is reached.
"""
[docs] @staticmethod
def delays_for_period(period: MatchPeriod, delays: Iterable[Delay]) -> list[Delay]:
r"""
Filter and sort a list of all possible delays to include only those
which occur after the start of the given `period`.
:param `.MatchPeriod` period: The period to get the delays for.
:param list delays: The list of :class:`.Delay`\s to consider.
:return: A sorted list of delays which occur after the start of the period.
"""
# Only consider delays which are at the start of the period or later
valid_delays = [d for d in delays if d.time >= period.start_time]
# Sort by when the delays occur
valid_delays.sort(key=lambda d: d.time)
return valid_delays
def __init__(self, period: MatchPeriod, delays: Iterable[Delay]):
"""Create a new clock for the given period and collection of delays."""
self._period = period
self._delays = self.delays_for_period(period, delays)
# The current time, including any delays
self._current_time = period.start_time
# The total applied delay
self._total_delay: datetime.timedelta | None = None
# Apply any delays which occur at the start
self._apply_delays()
@property
def current_time(self) -> datetime.datetime:
"""
Get the apparent current time. This is a combination of the time which
has passed (through calls to ``advance_time``) and the delays which
have occurred.
Will raise an :class:`.OutOfTimeException` if either:
- the end of the period has been reached (i.e: the sum of durations
passed to ``advance_time`` has exceeded the planned duration of the
period), or
- the maximum end of the period has been reached (i.e: the current time
would be after the period's ``max_end_time``).
"""
ct = self._current_time
# Ensure we haven't exceeded the maximum time limit
# (if we have then matches will get pushed into the next period)
if ct > self._period.max_end_time:
# we've filled this up to the maximum end time
raise OutOfTimeException(
"Current time (including delays) is beyond the maximum end time "
"of the period. Consider removing matches or delays, or adjusting "
"the maximum end time.",
)
# Ensure we haven't attempted to pack in more time than will
# fit in this period
if self._time_without_delays() > self._period.end_time:
# we've filled up this period
raise OutOfTimeException(
"Current time (without delays) is beyond the natural end time "
"of the period. Consider removing matches or adjusting the "
"natural end time.",
)
return ct
[docs] def advance_time(self, duration: datetime.timedelta) -> None:
"""
Make a given amount of time pass. This is expected to be called after
scheduling some matches in order to move to the next timeslot.
.. note::
It is assumed that the duration value will always be 'positive',
i.e. that time will not go backwards. The results of the duration
value being 'negative' are undefined.
"""
self._current_time += duration
self._apply_delays()
def _apply_delays(self) -> None:
delays = self._delays
while len(delays) and delays[0].time <= self._current_time:
self._apply_delay(delays.pop(0).delay)
def _apply_delay(self, delay: datetime.timedelta) -> None:
self._current_time += delay
if self._total_delay is None:
self._total_delay = delay
else:
self._total_delay += delay
def _time_without_delays(self) -> datetime.datetime:
if self._total_delay is None:
return self._current_time
else:
return self._current_time - self._total_delay
[docs] def iterslots(self, slot_duration: datetime.timedelta) -> Iterator[datetime.datetime]:
"""
Iterate through all the available timeslots of the given size within
the ``MatchPeriod``, taking into account delays.
This is equivalent to checking the current_time and repeatedly calling
``advance_time`` with the given duration. As a result, it is safe to
call ``advance_time`` between iterations if additional gaps between
slots are needed.
"""
try:
while True:
yield self.current_time
self.advance_time(slot_duration)
except OutOfTimeException:
# Reached the end of the period
pass