Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
'PyYAML >=5.1.2, <7',
'league-ranker >=0.1, <2',
'python-dateutil >=2.7, <3',
'typing-extensions >=4, <5',
'typing-extensions >=4.6, <5',
],
python_requires='>=3.10',
classifiers=[
Expand Down
8 changes: 7 additions & 1 deletion sr/comp/comp.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from subprocess import check_output
from typing import cast

from . import arenas, matches, ranker, scores, teams, venue
from . import arenas, match_operations, matches, ranker, scores, teams, venue
from .types import RankerType, ScorerType
from .winners import compute_awards

Expand Down Expand Up @@ -128,6 +128,12 @@ def __init__(self, root: str | Path) -> None:
)
"""A :class:`sr.comp.matches.MatchSchedule` instance."""

self.operations = match_operations.MatchOperations.create(
self.root / 'operations.yaml',
self.schedule,
)
"""A :class:`sr.comp.match_operations.MatchOperations` instance."""

self.timezone = self.schedule.timezone
"""The timezone of the competition."""

Expand Down
236 changes: 236 additions & 0 deletions sr/comp/match_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
from __future__ import annotations

import dataclasses
import datetime
import enum
from collections.abc import Collection
from pathlib import Path

from . import yaml_loader
from .match_period import Match
from .matches import MatchSchedule
from .types import MatchNumber, OperationsData, ReleasedMatchData


class MatchState(enum.Enum):
"""
The state of a match from the perspective of match operations.

- Matches are initially all `FUTURE`.
- Once a match is released it will become `RELEASED`.
- If the current time is past the release threshold for a given match and it
has not be released, then it is `HELD`.
"""

FUTURE = 'future'
HELD = 'held'
RELEASED = 'released'


@dataclasses.dataclass(frozen=True)
class ArenaTimes:
release_threshold: datetime.datetime
start: datetime.datetime
end: datetime.datetime


@dataclasses.dataclass(frozen=True)
class OperationsMatches:
time: datetime.datetime
matches: Collection[Match]
staging_matches: Collection[Match]
shepherding_matches: Collection[Match]


class InvalidResetDurationError(ValueError):
def __init__(
self,
release_threshold: datetime.timedelta,
reset_duration: datetime.timedelta,
) -> None:
super().__init__(release_threshold, reset_duration)
self.release_threshold = release_threshold
self.reset_duration = reset_duration

def __str__(self) -> str:
return (
"Match reset duration must be at least as long as the release "
f"threshold. (threshold: {self.release_threshold}, "
f"reset duration: {self.reset_duration})"
)


class InvalidReleasedMatchNumberError(ValueError):
def __init__(
self,
number: MatchNumber,
final_number: MatchNumber,
) -> None:
super().__init__(number, final_number)
self.number = number
self.final_number = final_number

def __str__(self) -> str:
return (
f"Invalid released match number {self.number}, must be in range "
f"0-{self.final_number}"
)


class MatchOperations:
@staticmethod
def create(path: Path, schedule: MatchSchedule) -> MatchOperations:
try:
y = yaml_loader.load(path)
operations_data: OperationsData = y['operations']

release_threshold = datetime.timedelta(
seconds=operations_data['release_threshold'],
)
reset_duration = datetime.timedelta(
seconds=operations_data['reset_duration'],
)
released_match = operations_data['released_match']

return MatchOperations(
schedule,
release_threshold=release_threshold,
reset_duration=reset_duration,
released_match_data=released_match,
)
except FileNotFoundError:
final_match = schedule.final_match
return MatchOperations(
schedule,
release_threshold=datetime.timedelta(0),
reset_duration=datetime.timedelta(0),
released_match_data={
'number': final_match.num,
'time': final_match.start_time,
},
)

def __init__(
self,
schedule: MatchSchedule,
release_threshold: datetime.timedelta,
reset_duration: datetime.timedelta,
released_match_data: ReleasedMatchData | None,
) -> None:
if reset_duration < release_threshold:
raise InvalidResetDurationError(
release_threshold=release_threshold,
reset_duration=reset_duration,
)

if released_match_data:
if released_match_data['number'] not in range(schedule.n_matches()):
raise InvalidReleasedMatchNumberError(
number=released_match_data['number'],
final_number=schedule.final_match.num,
)

self.schedule = schedule
self.release_threshold = release_threshold
self.reset_duration = reset_duration
self.released_match_data = released_match_data

@property
def last_released_match(self) -> MatchNumber | None:
"""The most recently released match."""
if not self.released_match_data:
return None
return self.released_match_data['number']

def get_arena_times(self, match: Match) -> ArenaTimes:
match_start = match.start_time + self.schedule.match_slot_lengths['pre']
return ArenaTimes(
release_threshold=match_start - self.release_threshold,
start=match_start,
end=match_start + self.schedule.match_slot_lengths['match'],
)

def get_match_state(self, match: Match, when: datetime.datetime) -> MatchState:
last_released_match = self.last_released_match
if last_released_match is not None and match.num <= last_released_match:
return MatchState.RELEASED

times = self.get_arena_times(match)
if times.release_threshold <= when:
return MatchState.HELD

return MatchState.FUTURE

def _get_effective_time(self, when: datetime.datetime) -> datetime.datetime:
"""
Get the "effective" time for a given wall-clock time.

The returned value accounts for any unreleased matches and can be safely
used with queries against the schedule (which is otherwise unaware of
operationally driven changes).
"""

# For the next, yet to be released match
num = (
self.released_match_data['number'] + 1
if self.released_match_data
else 0
)

if num >= self.schedule.n_matches():
# All matches have been released
return when

slot = self.schedule.matches[num]
match = next(iter(slot.values()))

times = self.get_arena_times(match)
if times.release_threshold > when:
# Haven't reached the threshold yet -- all is well
return when

# In a held state, things are effectively paused at the release
# threshold time
return times.release_threshold

def get_matches_at(self, when: datetime.datetime) -> OperationsMatches:
"""
Get all the matches with a useful relation to a given time.

This accounts for both delays committed to the schedule and ongoing
operational changes such as non-released matches.
"""

real_when = when
when = self._get_effective_time(when)

matches = []
staging_matches = []
shepherding_matches = []

for slot in self.schedule.matches:
for match in slot.values():
if match.start_time <= when < match.end_time:
matches.append(match)

staging_times = self.schedule.get_staging_times(match)

if when > staging_times['closes']:
# Already done staging
continue

if staging_times['opens'] <= when:
staging_matches.append(match)

signal_shepherds = staging_times['signal_shepherds']
if signal_shepherds:
first_signal = min(signal_shepherds.values())
if first_signal <= when:
shepherding_matches.append(match)

return OperationsMatches(
time=real_when,
matches=matches,
staging_matches=staging_matches,
shepherding_matches=shepherding_matches,
)
15 changes: 11 additions & 4 deletions sr/comp/matches.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from collections.abc import Iterable, Iterator, Mapping, Sequence
from pathlib import Path
from typing import Any, TypeVar
from typing_extensions import TypedDict
from typing_extensions import deprecated, TypedDict

import dateutil.tz
from league_ranker import RankedPosition
Expand Down Expand Up @@ -423,12 +423,19 @@ def delay_at(self, date: datetime.datetime) -> datetime.timedelta:

return total

@deprecated("Use SRComp.operations.get_matches_at instead.")
def matches_at(self, date: datetime.datetime) -> Iterator[Match]:
"""
Get all the matches that occur around a specific ``date``.
Deprecated in favour of ``MatchOperations.get_matches_at``.

:param datetime date: The date at which matches occur.
:return: An iterable list of matches.
Get all the matches scheduled to occur around a specific ``date``.

This accounts for delays committed to the schedule, but does not account
for ongoing operational changes such as non-released matches.

All known consumers want the latter to be included and should use
``MatchOperations.get_matches_at`` instead. If usages which need
this behaviour are found, please report them.
"""

for slot in self.matches:
Expand Down
37 changes: 37 additions & 0 deletions sr/comp/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,40 @@ class DelayData(TypedDict):


AwardsData = NewType('AwardsData', dict[str, Union[TLA, list[TLA]]])


class ReleasedMatchData(TypedDict):
number: MatchNumber
time: datetime.datetime


class OperationsData(TypedDict):
"""
Information relating to the operation of matches.
"""

release_threshold: int
"""
Duration prior to the start of a match to pause if the match has not been
"released". In seconds relative to the start of the game (not the slot).
"""

reset_duration: int
"""
Duration prior to the start of a match to reset to if the match is "reset".
In seconds relative to the start of the game (not the slot). Must be greater
than or equal to `release_threshold`.
"""

released_match: ReleasedMatchData | None
"""
Information about the currently released match.

Either:
- None, meaning that no matches have been released, or
- the most recently released match & when it was released

History is not recorded. Delays are used to correct for "late" releases.
Resets are performed by changing this to a suitable value for the previous
match and (if needed) adding a delay.
"""