def test_advance_time_with_delays(): period = build_match_period(0, 50) delays = [ Delay(time=1, delay=1), Delay(time=5, delay=2), ] clock = MatchPeriodClock(period, delays) curr_time = clock.current_time assert 0 == curr_time, "Should start at the start of the period" clock.advance_time(1) # plus a delay of 2 at time=1 curr_time = clock.current_time assert 2 == curr_time, "Time should advance by the given amount (1)" \ " plus the size of the delay it meets" clock.advance_time(2) curr_time = clock.current_time assert 4 == curr_time, "Time should advance by the given amount (2)" \ " only; there are no intervening delays" clock.advance_time(2) # takes us to 6, plus a delay of 2 at time=5 curr_time = clock.current_time assert 8 == curr_time, "Time should advance by the given amount (2)" \ " plus the size of the intervening delay (2)" clock.advance_time(2) curr_time = clock.current_time assert 10 == curr_time, "Time should advance by the given amount (2)" \ " only; there are no intervening delays"
def test_current_time_beyond_max_end_with_delay(): period = build_match_period(0, 1, 2) clock = MatchPeriodClock(period, [Delay(time=1, delay=2)]) clock.advance_time(1) # now at 3 check_out_of_time(clock)
def test_current_time_beyond_end_with_delay(): period = build_match_period(0, 1) clock = MatchPeriodClock(period, [Delay(time=1, delay=1)]) clock.advance_time(1) # now at 2 check_out_of_time(clock)
def test_current_time_at_end_no_delay(): period = build_match_period(0, 1) clock = MatchPeriodClock(period, []) clock.advance_time(1) curr_time = clock.current_time assert 1 == curr_time, "Should be able to query time when at end"
def test_current_time_at_max_end_no_delay(): period = build_match_period(0, 1, 2) clock = MatchPeriodClock(period, []) clock.advance_time(2) check_out_of_time(clock, "Should be out of time when at max_end due" \ " to over-advancing")
def test_current_time_at_max_end_with_delay(): period = build_match_period(0, 1, 2) clock = MatchPeriodClock(period, [Delay(time=1, delay=1)]) clock.advance_time(1) curr_time = clock.current_time assert 2 == curr_time, "Should be able to query time when at max_end" \ " due to delays"
def _build_matchlist(self, yamldata): """Build the match list.""" self.matches = [] if yamldata is None: self.n_planned_league_matches = 0 return match_numbers = sorted(yamldata.keys()) self.n_planned_league_matches = len(match_numbers) if tuple(match_numbers) != tuple(range(len(match_numbers))): raise Exception("Matches are not a complete 0-N range") # Effectively just the .values(), except that it's ordered by number raw_matches = [yamldata[m] for m in match_numbers] match_n = 0 for period in self.match_periods: # Fill this period with matches clock = MatchPeriodClock(period, self.delays) # No extra spacing for matches at the start of a period # Fill this match period with matches for start in clock.iterslots(self.match_duration): try: arenas = raw_matches.pop(0) except IndexError: # no more matches left break m = {} end_time = start + self.match_duration for arena_name, teams in arenas.items(): teams = self.remove_drop_outs(teams, match_n) display_name = 'Match {n}'.format(n=match_n) match = Match(match_n, display_name, arena_name, teams, start, end_time, MatchType.league, use_resolved_ranking=False) m[arena_name] = match period.matches.append(m) self.matches.append(m) match_n += 1 extra_spacing = self._spacing.get(match_n) if extra_spacing: clock.advance_time(extra_spacing)
def test_slots_delay_after(): period = build_match_period(0, 4) clock = MatchPeriodClock(period, [Delay(time=6, delay=2)]) curr_time = clock.current_time assert 0 == curr_time, "Should start at the start of the period" slots = list(clock.iterslots(1)) expected = list(range(5)) assert expected == slots
def test_slots_extra_gap(): period = build_match_period(0, 6) clock = MatchPeriodClock(period, []) slots = [] first_time = True for start in clock.iterslots(2): slots.append(clock.current_time) if first_time: # Advance an extra 3 the first time clock.advance_time(3) # Now at 5 first_time = False expected = [0, 5] assert expected == slots
def test_advance_time_overlapping_delays(): period = build_match_period(0, 10) delays = [ Delay(time=1, delay=2), # from 1 -> 3 Delay(time=2, delay=1), # extra at 2, so 1 -> 4 ] clock = MatchPeriodClock(period, delays) curr_time = clock.current_time assert 0 == curr_time, "Should start at the start of the period" clock.advance_time(2) # plus a total delay of 3 curr_time = clock.current_time assert 5 == curr_time, "Time should advance by the given amount (2)" \ " plus the size of the intervening delays (1+2)"
def test_advance_time_touching_delays(): period = build_match_period(0, 10) delays = [ Delay(time=1, delay=1), # from 1 -> 2 Delay(time=2, delay=1), # from 2 -> 3 ] clock = MatchPeriodClock(period, delays) curr_time = clock.current_time assert 0 == curr_time, "Should start at the start of the period" clock.advance_time(2) # plus a total delay of 2 curr_time = clock.current_time assert 4 == curr_time, "Time should advance by the given amount (2)" \ " plus the size of the intervening delays (1+1)"
def test_advance_time_no_delays(): period = build_match_period(0, 10) clock = MatchPeriodClock(period, []) curr_time = clock.current_time assert 0 == curr_time, "Should start at the start of the period" clock.advance_time(1) curr_time = clock.current_time assert 1 == curr_time, "Time should advance by the given amount (1)" clock.advance_time(2) curr_time = clock.current_time assert 3 == curr_time, "Time should advance by the given amount (2)"
def add_knockouts(self): """Add the knockouts to the schedule.""" period = self.config["match_periods"]["knockout"][0] self.period = MatchPeriod(period["start_time"], period["end_time"], period["end_time"], period["description"], [], MatchType.knockout) self.clock = MatchPeriodClock(self.period, self.schedule.delays) knockout_conf = self.config["knockout"] round_spacing = timedelta(seconds=knockout_conf["round_spacing"]) self._add_first_round(conf_arity=knockout_conf.get('arity')) while len(self.knockout_rounds[-1]) > 1: # Add the delay between rounds self.clock.advance_time(round_spacing) # Number of rounds remaining to be added rounds_remaining = self.get_rounds_remaining(self.knockout_rounds[-1]) if rounds_remaining <= knockout_conf["single_arena"]["rounds"]: arenas = knockout_conf["single_arena"]["arenas"] else: arenas = self.arenas if len(self.knockout_rounds[-1]) == 2: "Extra delay before the final match" final_delay = timedelta(seconds=knockout_conf["final_delay"]) self.clock.advance_time(final_delay) self._add_round(arenas, rounds_remaining - 1)
def test_current_time_start(): period = build_match_period(0, 4) clock = MatchPeriodClock(period, []) curr_time = clock.current_time assert 0 == curr_time, "Should start at the start of the period"
def test_current_time_start_delayed(): period = build_match_period(0, 4) clock = MatchPeriodClock(period, [Delay(time=0, delay=1)]) curr_time = clock.current_time assert 1 == curr_time, "Start time should include delays"
def test_current_time_start_delayed_twice(): period = build_match_period(0, 10) delays = [ Delay(time=0, delay=2), Delay(time=1, delay=3), ] clock = MatchPeriodClock(period, delays) curr_time = clock.current_time assert 5 == curr_time, "Start time should include cumilative delays"
def add_knockouts(self): """Add the knockouts to the schedule.""" period = self.config["match_periods"]["knockout"][0] self.period = MatchPeriod(period["start_time"], period["end_time"], period["end_time"], period["description"], [], MatchType.knockout) self.clock = MatchPeriodClock(self.period, self.schedule.delays) knockout_conf = self.config["knockout"] round_spacing = timedelta(seconds=knockout_conf["round_spacing"]) self._add_first_round(conf_arity=knockout_conf.get('arity')) while len(self.knockout_rounds[-1]) > 1: # Add the delay between rounds self.clock.advance_time(round_spacing) # Number of rounds remaining to be added rounds_remaining = self.get_rounds_remaining( self.knockout_rounds[-1]) if rounds_remaining <= knockout_conf["single_arena"]["rounds"]: arenas = knockout_conf["single_arena"]["arenas"] else: arenas = self.arenas if len(self.knockout_rounds[-1]) == 2: "Extra delay before the final match" final_delay = timedelta(seconds=knockout_conf["final_delay"]) self.clock.advance_time(final_delay) self._add_round(arenas, rounds_remaining - 1)
def delay_at(self, date): """ Calculates the active delay at a given ``date``. Intended for use only in exposing the current delay value -- scheduling should be done using a :class:`.MatchPeriodClock` instead. :param datetime date: The date to find the delay for. :return: A :class:`datetime.timedelta` specifying the active delay. """ total = timedelta() period = self.period_at(date) if not period: # No current period, no delays active return total delays = MatchPeriodClock.delays_for_period(period, self.delays) for delay in delays: if delay.time > date: break total += delay.delay return total
class KnockoutScheduler(object): """ A class that can be used to generate a knockout schedule. :param schedule: The league schedule. :param scores: The scores. :param dict arenas: The arenas. :param dict teams: The teams. :param config: Custom configuration for the knockout scheduler. """ def __init__(self, schedule, scores, arenas, teams, config): self.schedule = schedule self.scores = scores self.arenas = arenas self.teams = teams self.config = config # The knockout matches appear in the normal matches list # but this list provides them in groups of rounds. # e.g. self.knockout_rounds[-2] gives the semi-final matches # and self.knockout_rounds[-1] gives the final match (in a list) # Note that the ordering of the matches within the rounds # in this list is important (e.g. self.knockout_rounds[0][0] is # will involve the top seed, whilst self.knockout_rounds[0][-1] will # involve the second seed). self.knockout_rounds = [] self.R = stable_random.Random() def _played_all_league_matches(self): """ Check if all league matches have been played. :return: :py:bool:`True` if we've played all league matches. """ for arena_matches in self.schedule.matches: for match in arena_matches.values(): if match.type != MatchType.league: continue if (match.arena, match.num) not in \ self.scores.league.game_points: return False return True @staticmethod def get_match_display_name(rounds_remaining, round_num, global_num): """ Get a human-readable match display name. :param rounds_remaining: The number of knockout rounds remaining. :param knockout_num: The match number within the knockout round. :param global_num: The global match number. """ if rounds_remaining == 0: display_name = 'Final (#{global_num})' elif rounds_remaining == 1: display_name = 'Semi {round_num} (#{global_num})' elif rounds_remaining == 2: display_name = 'Quarter {round_num} (#{global_num})' else: display_name = 'Match {global_num}' return display_name.format(round_num=round_num + 1, global_num=global_num) def _add_round_of_matches(self, matches, arenas, rounds_remaining): """ Add a whole round of matches. :param list matches: A list of lists of teams for each match. """ self.knockout_rounds += [[]] round_num = 0 while len(matches): # Deliberately not using iterslots since we need to ensure # that the time advances even after we've run out of matches start_time = self.clock.current_time end_time = start_time + self.schedule.match_duration new_matches = {} for arena in arenas: teams = matches.pop(0) if len(teams) < 4: "Fill empty zones with None" teams += [None] * (4 - len(teams)) # Randomise the zones self.R.shuffle(teams) num = len(self.schedule.matches) display_name = self.get_match_display_name(rounds_remaining, round_num, num) match = Match(num, display_name, arena, teams, start_time, end_time, MatchType.knockout, # Just the finals don't use the resolved ranking use_resolved_ranking = rounds_remaining != 0) self.knockout_rounds[-1].append(match) new_matches[arena] = match if len(matches) == 0: break self.clock.advance_time(self.schedule.match_duration) self.schedule.matches.append(new_matches) self.period.matches.append(new_matches) round_num += 1 def get_ranking(self, game): """ Get a ranking of the given match's teams. :param game: A game. """ desc = (game.arena, game.num) # Get the resolved positions if present (will be a tla -> position map) positions = self.scores.knockout.resolved_positions.get(desc, None) if positions is None: "Given match hasn't been scored yet" return [UNKNOWABLE_TEAM] * 4 # Extract just TLAs return list(positions.keys()) def get_winners(self, game): """ Find the parent match's winners. :param game: A game. """ ranking = self.get_ranking(game) return ranking[:2] def _add_round(self, arenas, rounds_remaining): prev_round = self.knockout_rounds[-1] matches = [] for i in range(0, len(prev_round), 2): winners = [] for parent in prev_round[i:i + 2]: winners += self.get_winners(parent) matches.append(winners) self._add_round_of_matches(matches, arenas, rounds_remaining) def _get_non_dropped_out_teams(self, for_match): teams = list(self.scores.league.positions.keys()) teams = [tla for tla in teams if self.teams[tla].is_still_around(for_match)] return teams def _add_first_round(self, conf_arity=None): next_match_num = len(self.schedule.matches) teams = self._get_non_dropped_out_teams(next_match_num) if not self._played_all_league_matches(): teams = [UNKNOWABLE_TEAM] * len(teams) arity = len(teams) if conf_arity is not None and conf_arity < arity: arity = conf_arity # Seed the random generator with the seeded team list # This makes it unpredictable which teams will be in which zones # until the league scores have been established self.R.seed("".join(teams).encode("utf-8")) matches = [] for seeds in knockout.first_round_seeding(arity): match_teams = [teams[seed] for seed in seeds] matches.append(match_teams) rounds_remaining = self.get_rounds_remaining(matches) self._add_round_of_matches(matches, self.arenas, rounds_remaining) @staticmethod def get_rounds_remaining(prev_matches): return int(math.log(len(prev_matches), 2)) def add_knockouts(self): """Add the knockouts to the schedule.""" period = self.config["match_periods"]["knockout"][0] self.period = MatchPeriod(period["start_time"], period["end_time"], period["end_time"], period["description"], [], MatchType.knockout) self.clock = MatchPeriodClock(self.period, self.schedule.delays) knockout_conf = self.config["knockout"] round_spacing = timedelta(seconds=knockout_conf["round_spacing"]) self._add_first_round(conf_arity=knockout_conf.get('arity')) while len(self.knockout_rounds[-1]) > 1: # Add the delay between rounds self.clock.advance_time(round_spacing) # Number of rounds remaining to be added rounds_remaining = self.get_rounds_remaining(self.knockout_rounds[-1]) if rounds_remaining <= knockout_conf["single_arena"]["rounds"]: arenas = knockout_conf["single_arena"]["arenas"] else: arenas = self.arenas if len(self.knockout_rounds[-1]) == 2: "Extra delay before the final match" final_delay = timedelta(seconds=knockout_conf["final_delay"]) self.clock.advance_time(final_delay) self._add_round(arenas, rounds_remaining - 1)
class KnockoutScheduler(object): """ A class that can be used to generate a knockout schedule. :param schedule: The league schedule. :param scores: The scores. :param dict arenas: The arenas. :param dict teams: The teams. :param config: Custom configuration for the knockout scheduler. """ def __init__(self, schedule, scores, arenas, teams, config): self.schedule = schedule self.scores = scores self.arenas = arenas self.teams = teams self.config = config # The knockout matches appear in the normal matches list # but this list provides them in groups of rounds. # e.g. self.knockout_rounds[-2] gives the semi-final matches # and self.knockout_rounds[-1] gives the final match (in a list) # Note that the ordering of the matches within the rounds # in this list is important (e.g. self.knockout_rounds[0][0] is # will involve the top seed, whilst self.knockout_rounds[0][-1] will # involve the second seed). self.knockout_rounds = [] self.R = stable_random.Random() def _played_all_league_matches(self): """ Check if all league matches have been played. :return: :py:bool:`True` if we've played all league matches. """ for arena_matches in self.schedule.matches: for match in arena_matches.values(): if match.type != MatchType.league: continue if (match.arena, match.num) not in \ self.scores.league.game_points: return False return True @staticmethod def get_match_display_name(rounds_remaining, round_num, global_num): """ Get a human-readable match display name. :param rounds_remaining: The number of knockout rounds remaining. :param knockout_num: The match number within the knockout round. :param global_num: The global match number. """ if rounds_remaining == 0: display_name = 'Final (#{global_num})' elif rounds_remaining == 1: display_name = 'Semi {round_num} (#{global_num})' elif rounds_remaining == 2: display_name = 'Quarter {round_num} (#{global_num})' else: display_name = 'Match {global_num}' return display_name.format(round_num=round_num + 1, global_num=global_num) def _add_round_of_matches(self, matches, arenas, rounds_remaining): """ Add a whole round of matches. :param list matches: A list of lists of teams for each match. """ self.knockout_rounds += [[]] round_num = 0 while len(matches): # Deliberately not using iterslots since we need to ensure # that the time advances even after we've run out of matches start_time = self.clock.current_time end_time = start_time + self.schedule.match_duration new_matches = {} for arena in arenas: teams = matches.pop(0) if len(teams) < 4: "Fill empty zones with None" teams += [None] * (4 - len(teams)) # Randomise the zones self.R.shuffle(teams) num = len(self.schedule.matches) display_name = self.get_match_display_name( rounds_remaining, round_num, num) match = Match( num, display_name, arena, teams, start_time, end_time, MatchType.knockout, # Just the finals don't use the resolved ranking use_resolved_ranking=rounds_remaining != 0) self.knockout_rounds[-1].append(match) new_matches[arena] = match if len(matches) == 0: break self.clock.advance_time(self.schedule.match_duration) self.schedule.matches.append(new_matches) self.period.matches.append(new_matches) round_num += 1 def get_ranking(self, game): """ Get a ranking of the given match's teams. :param game: A game. """ desc = (game.arena, game.num) # Get the resolved positions if present (will be a tla -> position map) positions = self.scores.knockout.resolved_positions.get(desc, None) if positions is None: "Given match hasn't been scored yet" return [UNKNOWABLE_TEAM] * 4 # Extract just TLAs return list(positions.keys()) def get_winners(self, game): """ Find the parent match's winners. :param game: A game. """ ranking = self.get_ranking(game) return ranking[:2] def _add_round(self, arenas, rounds_remaining): prev_round = self.knockout_rounds[-1] matches = [] for i in range(0, len(prev_round), 2): winners = [] for parent in prev_round[i:i + 2]: winners += self.get_winners(parent) matches.append(winners) self._add_round_of_matches(matches, arenas, rounds_remaining) def _get_non_dropped_out_teams(self, for_match): teams = list(self.scores.league.positions.keys()) teams = [ tla for tla in teams if self.teams[tla].is_still_around(for_match) ] return teams def _add_first_round(self, conf_arity=None): next_match_num = len(self.schedule.matches) teams = self._get_non_dropped_out_teams(next_match_num) if not self._played_all_league_matches(): teams = [UNKNOWABLE_TEAM] * len(teams) arity = len(teams) if conf_arity is not None and conf_arity < arity: arity = conf_arity # Seed the random generator with the seeded team list # This makes it unpredictable which teams will be in which zones # until the league scores have been established self.R.seed("".join(teams).encode("utf-8")) matches = [] for seeds in knockout.first_round_seeding(arity): match_teams = [teams[seed] for seed in seeds] matches.append(match_teams) rounds_remaining = self.get_rounds_remaining(matches) self._add_round_of_matches(matches, self.arenas, rounds_remaining) @staticmethod def get_rounds_remaining(prev_matches): return int(math.log(len(prev_matches), 2)) def add_knockouts(self): """Add the knockouts to the schedule.""" period = self.config["match_periods"]["knockout"][0] self.period = MatchPeriod(period["start_time"], period["end_time"], period["end_time"], period["description"], [], MatchType.knockout) self.clock = MatchPeriodClock(self.period, self.schedule.delays) knockout_conf = self.config["knockout"] round_spacing = timedelta(seconds=knockout_conf["round_spacing"]) self._add_first_round(conf_arity=knockout_conf.get('arity')) while len(self.knockout_rounds[-1]) > 1: # Add the delay between rounds self.clock.advance_time(round_spacing) # Number of rounds remaining to be added rounds_remaining = self.get_rounds_remaining( self.knockout_rounds[-1]) if rounds_remaining <= knockout_conf["single_arena"]["rounds"]: arenas = knockout_conf["single_arena"]["arenas"] else: arenas = self.arenas if len(self.knockout_rounds[-1]) == 2: "Extra delay before the final match" final_delay = timedelta(seconds=knockout_conf["final_delay"]) self.clock.advance_time(final_delay) self._add_round(arenas, rounds_remaining - 1)
def test_current_time_beyond_end_no_delay(): period = build_match_period(0, 1) clock = MatchPeriodClock(period, []) clock.advance_time(5) check_out_of_time(clock)
def test_slots_no_delays_2(): period = build_match_period(0, 4) clock = MatchPeriodClock(period, []) slots = list(clock.iterslots(2)) expected = [0, 2, 4] assert expected == slots
def test_current_time_beyond_max_end_no_delay(): period = build_match_period(0, 1, 2) clock = MatchPeriodClock(period, []) clock.advance_time(5) check_out_of_time(clock)
def test_slots_delay_during(): period = build_match_period(0, 4, 5) clock = MatchPeriodClock(period, [Delay(time=1, delay=3)]) slots = list(clock.iterslots(2)) expected = [0, 5] assert expected == slots
def test_slots_no_delays_1(): period = build_match_period(0, 4) clock = MatchPeriodClock(period, []) slots = list(clock.iterslots(1)) expected = list(range(5)) assert expected == slots