diff --git a/tabbycat/draw/generator/__init__.py b/tabbycat/draw/generator/__init__.py index e7f161a7c1b..0ea2b6cf1ac 100644 --- a/tabbycat/draw/generator/__init__.py +++ b/tabbycat/draw/generator/__init__.py @@ -25,6 +25,11 @@ ("bub_dn_accom", _("Bubble down (to accommodate)")), ("no_bub_updn", _("Can't bubble up/down")), ("pullup", _("Pull-up team")), + ("side_imb", _("Side imbalance")), + ("seen_pullup", _("Team previously saw pullup")), + ("deviation", _("Pairing deviation")), + ("history", _("History conflict")), + ("inst", _("Institution conflict")), ) def get_two_team_generator(draw_type, avoid_conflicts='australs', side_allocations=None, **kwargs): diff --git a/tabbycat/draw/generator/graph.py b/tabbycat/draw/generator/graph.py index ba03e5da36c..b1e7e47b226 100644 --- a/tabbycat/draw/generator/graph.py +++ b/tabbycat/draw/generator/graph.py @@ -23,14 +23,18 @@ def avoid_conflicts(self, pairings): """Graph optimisation avoids conflicts, so method is extraneous.""" pass - def assignment_cost(self, t1, t2, size, bracket=None) -> Optional[int]: + def assignment_cost(self, t1, t2, size, flags, team_flags, bracket=None) -> Optional[int]: if t1 is t2: # Same team return penalty = 0 if self.options["avoid_history"]: - penalty += t1.seen(t2) * self.options["history_penalty"] + seen = t1.seen(t2) + if seen: + flags.append(f'history|{seen}') + penalty += seen * self.options["history_penalty"] if self.options["avoid_institution"] and t1.same_institution(t2): + flags.append('inst') penalty += self.options["institution_penalty"] # Add penalty of a side imbalance @@ -40,7 +44,7 @@ def assignment_cost(self, t1, t2, size, bracket=None) -> Optional[int]: if self.options["max_times_on_one_side"] > 0: if max(t1_affs, t1_negs, t2_affs, t1_negs) > self.options["max_times_on_one_side"]: - return None + return # Only declare an imbalance if both sides have been on the same side more often # Affs are positive, negs are negative. If teams have opposite signs, negative imbalance @@ -53,6 +57,9 @@ def assignment_cost(self, t1, t2, size, bracket=None) -> Optional[int]: # (+5 - +4) becoming (+4 - +5), in a severe case. magnitude = (abs(t1_affs - t1_negs) + abs(t2_affs - t2_negs)) // 2 + if imbalance and magnitude: + flags.append(f'side_imb|{magnitude}') + penalty += imbalance * magnitude * self.options["side_penalty"] return penalty @@ -71,14 +78,17 @@ def generate_pairings(self, brackets): n_teams = self.get_n_teams(teams) for k, t1 in enumerate(teams): for t2 in teams[k+1:]: - penalty = self.assignment_cost(t1, t2, n_teams, j) + flags = [] + team_flags = {t: [] for t in [t1, t2]} + penalty = self.assignment_cost(t1, t2, n_teams, flags, team_flags, j) if penalty is not None: - graph.add_edge(t1, t2, weight=penalty) + graph.add_edge(t1, t2, weight=penalty, flags=flags, team_flags=team_flags) # nx.nx_pydot.write_dot(graph, sys.stdout) for pairing in sorted(nx.min_weight_matching(graph), key=lambda p: self.room_rank_ordering(p)): i += 1 - pairings[points].append(Pairing(teams=pairing, bracket=points, room_rank=i)) + edge = graph.get_edge_data(*pairing) + pairings[points].append(Pairing(teams=pairing, bracket=points, room_rank=i, flags=edge['flags'], team_flags=edge['team_flags'])) return pairings @@ -92,8 +102,8 @@ class GraphAllocatedSidesMixin(GraphGeneratorMixin): This is possible as assigning the sides creates a bipartite graph rather than a more complete graph.""" - def assignment_cost(self, t1, t2, size): - penalty = super().assignment_cost(t1, t2, size) + def assignment_cost(self, t1, t2, size, flags, team_flags): + penalty = super().assignment_cost(t1, t2, size, flags, team_flags) if penalty is None: return munkres.DISALLOWED return penalty @@ -105,7 +115,7 @@ def generate_pairings(self, brackets): for points, pool in brackets.items(): pairings[points] = [] n_teams = len(pool[DebateSide.AFF]) + len(pool[DebateSide.NEG]) - matrix = [[self.assignment_cost(aff, neg, n_teams) for neg in pool[DebateSide.NEG]] for aff in pool[DebateSide.AFF]] + matrix = [[self.assignment_cost(aff, neg, n_teams, [], {}) for neg in pool[DebateSide.NEG]] for aff in pool[DebateSide.AFF]] for i_aff, i_neg in munkres.Munkres().compute(matrix): i += 1 diff --git a/tabbycat/draw/generator/powerpair.py b/tabbycat/draw/generator/powerpair.py index 0dc2c651c87..da49a0ad2ee 100644 --- a/tabbycat/draw/generator/powerpair.py +++ b/tabbycat/draw/generator/powerpair.py @@ -288,28 +288,30 @@ def update_subranks(self, brackets): pass -class GraphCostMixin: +class PowerPairedGraphCostMixin: def get_n_teams(self, teams: list['Team']) -> int: # Use max subrank to get the penalties for match deviations; # necessary for enumerated seed values return max([t.subrank for t in teams if t.subrank is not None], default=0) - def assignment_cost(self, t1, t2, size, bracket=None) -> Optional[int]: - penalty = super().assignment_cost(t1, t2, size) + def assignment_cost(self, t1, t2, size, flags, team_flags, bracket=None) -> Optional[int]: + penalty = super().assignment_cost(t1, t2, size, flags, team_flags) if penalty is None: return None # Add penalty for seeing the pullup again if self.options["pullup_debates_penalty"] and t1.points != t2.points: - penalty += max(t1.pullup_debates, t2.pullup_debates) * self.options["pullup_debates_penalty"] + if (add_penalty := max(t1.pullup_debates, t2.pullup_debates)): + team_flags[max([t1, t2], key=attrgetter('points'))].append(f'seen_pullup|{add_penalty}') + penalty += add_penalty * self.options["pullup_debates_penalty"] # Add penalty for deviations in the pairing method if self.options["pairing_method"] != "random": - penalty += self.calculate_pairing_penalty(t1, t2, size, bracket) + penalty += self.calculate_pairing_penalty(t1, t2, size, flags, team_flags, bracket) return penalty - def calculate_pairing_penalty(self, t1, t2, size, bracket=None) -> int: + def calculate_pairing_penalty(self, t1, t2, size, flags, team_flags, bracket=None) -> int: subpool_penalty_func = self.get_option_function("pairing_method", self.PAIRING_FUNCTIONS) # Set the subrank to be last for pulled-up teams @@ -319,7 +321,10 @@ def calculate_pairing_penalty(self, t1, t2, size, bracket=None) -> int: subranks.append(size) else: subranks.append(t.subrank) - return subpool_penalty_func(subranks, size, bracket) * self.options["pairing_penalty"] + + if imbalance := subpool_penalty_func(subranks, size, bracket): + flags.append(f'deviation|{subpool_penalty_func(subranks, size, bracket)}') + return imbalance * self.options["pairing_penalty"] @staticmethod def _pairings_slide(teams, size: int, bracket: Optional[int] = None) -> int: @@ -477,11 +482,16 @@ def _one_up_one_down(self, pairings): pairing.teams = list(new) -class GraphPowerPairedDrawGenerator(GraphCostMixin, GraphGeneratorMixin, BasePowerPairedDrawGenerator): - pass +class GraphPowerPairedDrawGenerator(PowerPairedGraphCostMixin, GraphGeneratorMixin, BasePowerPairedDrawGenerator): + def annotate_team_flags(self, pairings): + """Only flag that can be added is 'pullup', and can only be determined after generation""" + for pairing in pairings: + for team in pairing.teams: + if team.points < max(t.points for t in pairing.teams): + pairing.add_team_flags(team, ['pullup']) -class SingleGraphPowerPairedDrawGenerator(GraphCostMixin, GraphGeneratorMixin, BasePowerPairedDrawGenerator): +class SingleGraphPowerPairedDrawGenerator(PowerPairedGraphCostMixin, GraphGeneratorMixin, BasePowerPairedDrawGenerator): def generate(self): max_points = max([t.points for t in self.teams if t.points is not None], default=0) @@ -498,11 +508,11 @@ def generate(self): self.annotate_team_flags(draw) # operates in-place return draw - def assignment_cost(self, t1, t2, size, bracket=None) -> Optional[int]: + def assignment_cost(self, t1, t2, size, flags, team_flags, bracket=None) -> Optional[int]: min_points = min(t1.points, t2.points) max_points = max(t1.points, t2.points) size = self.n_teams_per_points[max_points] - penalty = super().assignment_cost(t1, t2, size) + penalty = super().assignment_cost(t1, t2, size, flags, team_flags) if penalty is None: return None @@ -513,18 +523,25 @@ def assignment_cost(self, t1, t2, size, bracket=None) -> Optional[int]: return None pullup_team = min([t1, t2], key=attrgetter('points')) # Include penalty for the pulled up team + team_flags[pullup_team].append(f'pullup|{pullup_team.pullup_magnitude + 1}') penalty += pullup_team.pullup_magnitude return penalty - def calculate_pairing_penalty(self, t1, t2, size, bracket=None) -> int: + def calculate_pairing_penalty(self, t1, t2, size, flags, team_flags, bracket=None) -> int: subpool_penalty_func = self.get_option_function("pairing_method", self.PAIRING_FUNCTIONS) # Set the subrank to be last for pulled-up teams if t1.points != t2.points: team_in_bracket = max([t1, t2], key=attrgetter('points')) - return subpool_penalty_func([team_in_bracket.subrank, size+1], size+1, bracket) * self.options["pairing_penalty"] + penalty = subpool_penalty_func([team_in_bracket.subrank, size+1], size+1, bracket) + if penalty: + flags.append(f'deviation|{penalty}') + return penalty * self.options["pairing_penalty"] - return subpool_penalty_func([t1.subrank, t2.subrank], size, bracket) * self.options["pairing_penalty"] + penalty = subpool_penalty_func([t1.subrank, t2.subrank], size, bracket) + if penalty: + flags.append(f'deviation|{penalty}') + return penalty * self.options["pairing_penalty"] def annotate_team_pullup_precedence(self, teams): sort_function = self.get_option_function("odd_bracket", self.ODD_BRACKET_FUNCTIONS) @@ -571,13 +588,6 @@ def _pullup_lowest_ds_rank(team, size=None): def _pullup_lowest_ds_rank_npulls(team, size=None): return [team.npullups, -team.draw_strength_rank] - def annotate_team_flags(self, pairings): - """Only flag that can be added is 'pullup', and can only be determined after generation""" - for pairing in pairings: - for team in pairing.teams: - if team.points < max(t.points for t in pairing.teams): - pairing.add_team_flags(team, ['pullup']) - class AustralsPowerPairedDrawGenerator(AustralsPairingMixin, BasePowerPairedDrawGenerator): pass @@ -845,7 +855,7 @@ def _intermediate_brackets_with_up_down(): raise NotImplementedError("Intermediate brackets with conflict avoidance isn't supported with allocated sides.") -class GraphPowerPairedWithAllocatedSidesDrawGenerator(GraphCostMixin, GraphAllocatedSidesMixin, PowerPairedWithAllocatedSidesDrawGenerator): +class GraphPowerPairedWithAllocatedSidesDrawGenerator(PowerPairedGraphCostMixin, GraphAllocatedSidesMixin, PowerPairedWithAllocatedSidesDrawGenerator): pass diff --git a/tabbycat/draw/tests/test_generator.py b/tabbycat/draw/tests/test_generator.py index 0f15689886e..259a296ed4b 100644 --- a/tabbycat/draw/tests/test_generator.py +++ b/tabbycat/draw/tests/test_generator.py @@ -451,18 +451,18 @@ class TestPowerPairedDrawGenerator(unittest.TestCase): pairing_penalty=1, ), [(12, 2, [], [], ['pullup'], True), - (3, 14, [], [], [], True), - (17, 11, [], [], [], True), # Prefers a 2-pairing deviation + (3, 14, ['deviation|1'], [], [], True), + (17, 11, ['deviation|2'], [], [], True), # Prefers a 2-pairing deviation (8, 6, [], [], [], True), - (4, 7, [], [], ['pullup'], True), - (9, 24, [], [], [], False), - (15, 23, [], [], [], True), + (4, 7, ['deviation|1'], [], ['pullup'], True), + (9, 24, ['deviation|1'], [], [], False), + (15, 23, ['deviation|1'], [], [], True), (18, 25, [], [], [], False), (22, 1, [], [], ['pullup'], True), (5, 21, [], [], [], True), - (10, 20, [], [], [], False), + (10, 20, ['deviation|2'], [], [], False), (16, 26, [], [], [], True), - (19, 13, [], [], ['pullup'], True)]] + (19, 13, ['deviation|2'], [], ['pullup'], True)]] expected[6] = [ # Should be identical to [5] dict( @@ -478,19 +478,19 @@ class TestPowerPairedDrawGenerator(unittest.TestCase): pairing_penalty=1, pullup_penalty=10, ), - [(12, 2, [], [], ['pullup'], True), - (3, 14, [], [], [], True), - (17, 11, [], [], [], True), + [(12, 2, [], [], ['pullup|1'], True), + (3, 14, ['deviation|1'], [], [], True), + (17, 11, ['deviation|2'], [], [], True), (8, 6, [], [], [], True), - (4, 7, [], [], ['pullup'], True), - (9, 24, [], [], [], False), - (15, 23, [], [], [], True), + (4, 7, ['deviation|1'], [], ['pullup|1'], True), + (9, 24, ['deviation|1'], [], [], False), + (15, 23, ['deviation|1'], [], [], True), (18, 25, [], [], [], False), - (22, 1, [], [], ['pullup'], True), + (22, 1, [], [], ['pullup|1'], True), (5, 21, [], [], [], True), - (10, 20, [], [], [], False), + (10, 20, ['deviation|2'], [], [], False), (16, 26, [], [], [], True), - (19, 13, [], [], ['pullup'], True)]] + (19, 13, ['deviation|2'], [], ['pullup|1'], True)]] combinations = [(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6)] @@ -510,19 +510,20 @@ def test_draw(self): actual_teams = tuple([t.id for t in actual.teams]) expected_teams = (exp_aff, exp_neg) - if same_affs: - self.assertEqual(set(actual_teams), set(expected_teams)) - else: - self.assertEqual(actual_teams, expected_teams) + with self.subTest(aff=exp_aff, neg=exp_neg): + if same_affs: + self.assertEqual(set(actual_teams), set(expected_teams)) + else: + self.assertEqual(actual_teams, expected_teams) - self.assertEqual(actual.flags, exp_flags) + self.assertEqual(actual.flags, exp_flags) - if exp_aff == actual.teams[0].id: - self.assertEqual(actual.get_team_flags(actual.teams[0]), exp_aff_flags) - self.assertEqual(actual.get_team_flags(actual.teams[1]), exp_neg_flags) - else: - self.assertEqual(actual.get_team_flags(actual.teams[1]), exp_aff_flags) - self.assertEqual(actual.get_team_flags(actual.teams[0]), exp_neg_flags) + if exp_aff == actual.teams[0].id: + self.assertEqual(actual.get_team_flags(actual.teams[0]), exp_aff_flags) + self.assertEqual(actual.get_team_flags(actual.teams[1]), exp_neg_flags) + else: + self.assertEqual(actual.get_team_flags(actual.teams[1]), exp_aff_flags) + self.assertEqual(actual.get_team_flags(actual.teams[0]), exp_neg_flags) class TestPowerPairedWithAllocatedSidesDrawGeneratorPartOddBrackets(unittest.TestCase): diff --git a/tabbycat/draw/tests/test_graph_allocations.py b/tabbycat/draw/tests/test_graph_allocations.py index abee9ae4950..2902d532557 100644 --- a/tabbycat/draw/tests/test_graph_allocations.py +++ b/tabbycat/draw/tests/test_graph_allocations.py @@ -1,7 +1,7 @@ import unittest from .utils import TestTeam -from ..generator.powerpair import GraphCostMixin, GraphPowerPairedDrawGenerator +from ..generator.powerpair import GraphPowerPairedDrawGenerator, PowerPairedGraphCostMixin from ..types import DebateSide DUMMY_TEAMS = [TestTeam(1, 'A', allocated_side=DebateSide.AFF), TestTeam(2, 'B', allocated_side=DebateSide.NEG)] @@ -23,7 +23,7 @@ def test_pairings_slide_deviation_top(self): A - G: 2 A - H: 3""" with self.subTest(i=i): - self.assertEqual(GraphCostMixin._pairings_slide([teams[0].subrank, team.subrank], 8), abs(i - 4)) + self.assertEqual(PowerPairedGraphCostMixin._pairings_slide([teams[0].subrank, team.subrank], 8), abs(i - 4)) def test_pairings_slide_deviation(self): teams = [TestTeam(i+1, chr(ord('A') + i), subrank=i+1) for i in range(8)] @@ -39,7 +39,7 @@ def test_pairings_slide_deviation(self): D - G: 1 D - H: 0""" with self.subTest(i=i): - self.assertEqual(GraphCostMixin._pairings_slide([teams[3].subrank, team.subrank], 8), 4 - abs(i - 3)) + self.assertEqual(PowerPairedGraphCostMixin._pairings_slide([teams[3].subrank, team.subrank], 8), 4 - abs(i - 3)) def test_pairings_fold_deviation_top(self): teams = [TestTeam(i+1, chr(ord('A') + i), subrank=i+1) for i in range(8)] @@ -55,7 +55,7 @@ def test_pairings_fold_deviation_top(self): A - G: 1 A - H: 0""" with self.subTest(i=i): - self.assertEqual(GraphCostMixin._pairings_fold([teams[0].subrank, team.subrank], 8), 7-i) + self.assertEqual(PowerPairedGraphCostMixin._pairings_fold([teams[0].subrank, team.subrank], 8), 7-i) def test_pairings_fold_deviation(self): teams = [TestTeam(i+1, chr(ord('A') + i), subrank=i+1) for i in range(8)] @@ -71,13 +71,13 @@ def test_pairings_fold_deviation(self): D - G: 2 D - H: 3""" with self.subTest(i=i): - self.assertEqual(GraphCostMixin._pairings_fold([teams[3].subrank, team.subrank], 8), abs(4-i)) + self.assertEqual(PowerPairedGraphCostMixin._pairings_fold([teams[3].subrank, team.subrank], 8), abs(4-i)) return [abs(4-i) for i in range(8)] def test_pairings_random_deviation_zero(self): teams = [TestTeam(i+1, chr(ord('A') + i), subrank=i+1) for i in range(8)] # Always 0 - self.assertEqual(GraphCostMixin._pairings_random([teams[0].subrank, teams[1].subrank], 8), 0) + self.assertEqual(PowerPairedGraphCostMixin._pairings_random([teams[0].subrank, teams[1].subrank], 8), 0) def test_pairings_adjacent_deviation_top(self): teams = [TestTeam(i+1, chr(ord('A') + i), subrank=i+1) for i in range(8)] @@ -93,7 +93,7 @@ def test_pairings_adjacent_deviation_top(self): A - G: 5 A - H: 6""" with self.subTest(i=i): - self.assertEqual(GraphCostMixin._pairings_adjacent([teams[0].subrank, team.subrank], 8), i-1) + self.assertEqual(PowerPairedGraphCostMixin._pairings_adjacent([teams[0].subrank, team.subrank], 8), i-1) def test_pairings_adjacent_deviation(self): teams = [TestTeam(i+1, chr(ord('A') + i), subrank=i+1) for i in range(8)] @@ -109,7 +109,7 @@ def test_pairings_adjacent_deviation(self): D - G: 2 D - H: 3""" with self.subTest(i=i): - self.assertEqual(GraphCostMixin._pairings_adjacent([teams[3].subrank, team.subrank], 8), abs(i - 3) - 1) + self.assertEqual(PowerPairedGraphCostMixin._pairings_adjacent([teams[3].subrank, team.subrank], 8), abs(i - 3) - 1) return [abs(i - 3) - 1 for i in range(8)] def test_pairings_fold_adj_deviation(self): @@ -117,31 +117,31 @@ def test_pairings_fold_adj_deviation(self): methods = [self.test_pairings_fold_deviation, self.test_pairings_adjacent_deviation] for i, method in enumerate(methods): for j, (team, expected) in enumerate(zip(teams, method())): - self.assertEqual(GraphCostMixin._pairings_fold_top_adjacent_rest([teams[3].subrank, team.subrank], 8, bracket=i), expected) + self.assertEqual(PowerPairedGraphCostMixin._pairings_fold_top_adjacent_rest([teams[3].subrank, team.subrank], 8, bracket=i), expected) def test_add_pullup_penalty(self): teams = [TestTeam(i+1, chr(ord('A') + i), points=i, subrank=i+1, pullup_debates=i+1) for i in range(2)] gcm = GraphPowerPairedDrawGenerator(teams) gcm.options = {'pullup_debates_penalty': 1, 'pairing_method': 'random', 'avoid_history': False, 'avoid_institution': False, 'side_allocations': False} gcm.team_flags = {teams[0]: ['pullup']} - self.assertEqual(gcm.assignment_cost(*teams, 2), 2) + self.assertEqual(gcm.assignment_cost(*teams, 2, [], {t: [] for t in teams}), 2) def test_add_subrank_pullup(self): teams = [TestTeam(i+1, chr(ord('A') + i), subrank=(None if i else 1)) for i in range(2)] gcm = GraphPowerPairedDrawGenerator(teams) gcm.options = {'pullup_debates_penalty': 1, 'pairing_method': 'fold', 'avoid_history': False, 'avoid_institution': False, 'side_allocations': False, 'pairing_penalty': 1} - self.assertEqual(gcm.assignment_cost(*teams, 2), 0) + self.assertEqual(gcm.assignment_cost(*teams, 2, [], {t: [] for t in teams}), 0) def test_none_self_penalty(self): team = TestTeam(1, 'A') gcm = GraphPowerPairedDrawGenerator([team, team]) gcm.options = {'pullup_debates_penalty': 1, 'pairing_method': 'fold', 'avoid_history': False, 'avoid_institution': False, 'side_allocations': False, 'pairing_penalty': 1} - self.assertEqual(gcm.assignment_cost(team, team, 2), None) + self.assertEqual(gcm.assignment_cost(team, team, 2, [], {t: [] for t in [team]}), None) def test_none_max_side_balance_penalty(self): teams = [TestTeam(1, 'A', side_history=(2, 0), subrank=1), TestTeam(2, 'B', side_history=(1, 1), subrank=2)] gcm = GraphPowerPairedDrawGenerator(teams) gcm.options = {'side_allocations': 'balance', 'max_times_on_one_side': 1, 'avoid_history': False, 'avoid_institution': False, 'side_penalty': 1, 'pairing_method': 'fold', 'pairing_penalty': 1, 'pullup_debates_penalty': 1} - cost = gcm.assignment_cost(*teams, 2) + cost = gcm.assignment_cost(*teams, 2, [], {t: [] for t in teams}) self.assertEqual(cost, None) diff --git a/tabbycat/utils/tables.py b/tabbycat/utils/tables.py index 9a817c8a7ab..cb271905281 100644 --- a/tabbycat/utils/tables.py +++ b/tabbycat/utils/tables.py @@ -29,6 +29,11 @@ _draw_flags_dict = dict(DRAW_FLAG_DESCRIPTIONS) +def get_flag_description(flag): + flag_parts = flag.split('|') + return _draw_flags_dict.get(flag_parts[0], flag_parts[0]) + (_(' × %s') % flag_parts[1] if len(flag_parts) == 2 and int(flag_parts[1]) > 1 else '') + + def escape_if_unsafe(s): return s if type(s) is SafeString else escape(s) @@ -759,11 +764,11 @@ def add_draw_conflicts_columns(self, debates, venue_conflicts, adjudicator_confl conflicts_by_debate = [] for debate in debates: # conflicts is a list of (level, message) tuples - conflicts = [("secondary", _draw_flags_dict.get(flag, flag)) for flag in debate.flags] + conflicts = [("secondary", get_flag_description(flag)) for flag in debate.flags] if not debate.is_bye: conflicts += [("secondary", "%(team)s: %(flag)s" % { 'team': self._team_short_name(dt.team), - 'flag': _draw_flags_dict.get(flag, flag), + 'flag': get_flag_description(flag), }) for dt in debate.debateteams for flag in dt.flags] if self.tournament.pref('avoid_team_history'):