Source code for sr.comp.match_period_clock

"""A clock to manage match periods."""

from __future__ import annotations

import dataclasses
import datetime
from collections.abc import Callable, Iterable, Iterator
from typing_extensions import Self

from .match_period import Delay, MatchPeriod


[docs] @dataclasses.dataclass(frozen=True, slots=True) class Spacing: """ Spacing which can be applied to a ``MatchPeriodClock``. nominal == delay_flex + minimum. """ delay_flex: datetime.timedelta """The maximum amount of delay time to absorb.""" minimum: datetime.timedelta """The minimum amount of time to advance.""" nominal: datetime.timedelta """The initial amount of time to advance, assuming there are no delays."""
[docs] @classmethod def fixed(cls, nominal: datetime.timedelta) -> Self: return cls( delay_flex=datetime.timedelta(0), minimum=nominal, nominal=nominal, )
def __post_init__(self) -> None: zero = datetime.timedelta(0) if self.delay_flex < zero or self.minimum < zero or self.nominal < zero: raise ValueError("Spacing values cannot be negative.") if self.delay_flex + self.minimum != self.nominal: raise ValueError( "Spacing durations are inconsistent. " f"({self.delay_flex} + {self.minimum} != {self.nominal})", )
[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()
[docs] def apply_spacing(self, spacing: Spacing, recover_time: Callable[[Delay], None]) -> None: """ Apply a given spacing to make some time pass. This is expected to be called when inserting a variable length gap between matches. The ``recover_time`` parameter should be a callable which updates the central view of delays in the system and will be called with a negative delay when time is recovered. """ if self._total_delay is not None: delay_to_absorb = min(self._total_delay, spacing.delay_flex) if delay_to_absorb: recovered_time = Delay( # A negative delay recovers time. delay=-delay_to_absorb, time=self._current_time, ) self._delays.insert(0, recovered_time) recover_time(recovered_time) self.advance_time(spacing.nominal)
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