Skip to content

Commit a06fa2a

Browse files
author
Michael
committed
Added preferences and tests
1 parent af81598 commit a06fa2a

File tree

3 files changed

+197
-20
lines changed

3 files changed

+197
-20
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,6 @@ var/
3535
.installed.cfg
3636
*.egg
3737
MANIFES
38+
39+
# Test files
40+
*.xlsx

team_former/make_teams.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,51 @@
77
from ortools.sat.python import cp_model
88

99

10+
def parse_preferences(df):
11+
"""Parse positive and negative preferences from the DataFrame columns."""
12+
id_to_index = {row["Student_ID"]: idx for idx, row in df.iterrows()}
13+
14+
positive_prefs = []
15+
negative_prefs = []
16+
17+
has_pos = "Prefer_With" in df.columns
18+
has_neg = "Prefer_Not_With" in df.columns
19+
20+
for _, row in df.iterrows():
21+
student = row["Student_ID"].strip()
22+
23+
# Positive preferences
24+
if has_pos and pd.notna(row["Prefer_With"]) and row["Prefer_With"].strip():
25+
preferred = [s.strip() for s in row["Prefer_With"].split(",") if s.strip()]
26+
for target in preferred:
27+
positive_prefs.append((student, target))
28+
29+
# Negative preferences
30+
if (
31+
has_neg
32+
and pd.notna(row["Prefer_Not_With"])
33+
and row["Prefer_Not_With"].strip()
34+
):
35+
not_preferred = [
36+
s.strip() for s in row["Prefer_Not_With"].split(",") if s.strip()
37+
]
38+
for target in not_preferred:
39+
negative_prefs.append((student, target))
40+
41+
positive_prefs = [(id_to_index[a], id_to_index[b]) for (a, b) in positive_prefs]
42+
negative_prefs = [(id_to_index[a], id_to_index[b]) for (a, b) in negative_prefs]
43+
44+
return positive_prefs, negative_prefs
45+
46+
1047
def allocate_teams(
1148
*,
1249
input_file="students.xlsx",
1350
sheet_name=0,
1451
output_file="class_teams.xlsx",
1552
wam_weight=0.05,
53+
pos_pref_weight=0.05,
54+
neg_pref_weight=0.1,
1655
min_team_size=4,
1756
max_team_size=5,
1857
max_solve_time=60,
@@ -25,6 +64,8 @@ def allocate_teams(
2564
sheet_name (int or str): Sheet index or name.
2665
output_file (str): Output Excel file with team assignments.
2766
wam_weight (float): Weight for WAM balancing in the objective.
67+
pos_pref_weight (float): Weight for positive preference balancing in the objective.
68+
neg_pref_weight (float): Weight for negative preference balancing in the objective.
2869
min_team_size (int): Minimum number of students per team.
2970
max_team_size (int): Maximum number of students per team.
3071
max_solve_time (int): Solver timeout in seconds.
@@ -41,6 +82,8 @@ def allocate_teams(
4182
global_avg_wam = sum(wams) // len(wams)
4283
max_teams = num_students // min_team_size
4384

85+
pos_preferences, neg_preferences = parse_preferences(student_df)
86+
4487
model = cp_model.CpModel()
4588

4689
# Variables
@@ -103,8 +146,34 @@ def allocate_teams(
103146
model.AddMultiplicationEquality(squared_diff, [diff, diff])
104147
squared_deviation_terms.append(squared_diff)
105148

149+
pref_bonus_terms = [] # student indices who prefer each other
150+
for i, j in pos_preferences:
151+
for team in range(max_teams):
152+
together = model.NewBoolVar(f"prefer_{i}_{j}_team_{team}")
153+
model.AddBoolAnd([assign[i, team], assign[j, team]]).OnlyEnforceIf(together)
154+
model.AddBoolOr(
155+
[assign[i, team].Not(), assign[j, team].Not()]
156+
).OnlyEnforceIf(together.Not())
157+
pref_bonus_terms.append(together)
158+
159+
negative_terms = []
160+
for i, j in neg_preferences:
161+
together_vars = []
162+
for team in range(max_teams):
163+
both = model.NewBoolVar(f"neg_pref_{i}_{j}_team_{team}")
164+
model.AddBoolAnd([assign[i, team], assign[j, team]]).OnlyEnforceIf(both)
165+
model.AddBoolOr(
166+
[assign[i, team].Not(), assign[j, team].Not()]
167+
).OnlyEnforceIf(both.Not())
168+
together_vars.append(both)
169+
negative_terms.append(model.NewBoolVar(f"neg_pref_{i}_{j}_some_team"))
170+
model.AddMaxEquality(negative_terms[-1], together_vars)
171+
106172
model.Minimize(
107-
sum(team_used) + int(wam_weight * 1000) * sum(squared_deviation_terms)
173+
sum(team_used)
174+
+ int(wam_weight * 1000) * sum(squared_deviation_terms)
175+
- pos_pref_weight * sum(pref_bonus_terms)
176+
+ neg_pref_weight * sum(negative_terms)
108177
)
109178

110179
# Solve

tests/test_teams.py

Lines changed: 124 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Unit tests for the team allocation logic using pytest and Faker."""
22

3+
import random
34
from unittest import mock
45

56
import pandas as pd
@@ -9,16 +10,42 @@
910
from team_former.make_teams import allocate_teams
1011

1112

13+
def generate_random_preferences(
14+
student_ids, p_pos=0.4, p_neg=0.3, max_pos=3, max_neg=2
15+
):
16+
"""Generate random positive and negative preferences for students."""
17+
prefs_with = {s: [] for s in student_ids}
18+
prefs_not_with = {s: [] for s in student_ids}
19+
20+
for s in student_ids:
21+
others = [o for o in student_ids if o != s]
22+
if random.random() < p_pos and others:
23+
prefs_with[s] = random.sample(
24+
others, k=random.randint(1, min(max_pos, len(others)))
25+
)
26+
if random.random() < p_neg and others:
27+
prefs_not_with[s] = random.sample(
28+
others, k=random.randint(1, min(max_neg, len(others)))
29+
)
30+
return prefs_with, prefs_not_with
31+
32+
1233
@pytest.fixture
1334
def fake_student_df_fixture():
14-
"""Generate a fake student dataframe with Faker."""
35+
"""Fixture to generate a fake student DataFrame with preferences."""
1536
fake = Faker()
1637
Faker.seed(1234)
38+
random.seed(1234)
39+
40+
n = 100
41+
student_ids = [f"S{i+1}" for i in range(n)]
42+
pos_prefs, neg_prefs = generate_random_preferences(student_ids)
1743

1844
students = []
19-
for _ in range(100):
45+
for sid in student_ids:
2046
students.append(
2147
{
48+
"Student_ID": sid,
2249
"first_name": fake.first_name(),
2350
"last_name": fake.last_name(),
2451
"email": fake.email(),
@@ -34,65 +61,143 @@ def fake_student_df_fixture():
3461
2,
3562
),
3663
"lab": fake.random_int(min=1, max=4),
64+
"Prefer_With": ", ".join(pos_prefs[sid]) if pos_prefs[sid] else "",
65+
"Prefer_Not_With": ", ".join(neg_prefs[sid]) if neg_prefs[sid] else "",
3766
}
3867
)
39-
4068
return pd.DataFrame(students)
4169

4270

43-
def test_allocate_teams_returns_df(df_in=fake_student_df_fixture):
71+
def report_wam_balance(df_out):
72+
"""Report per-team WAM averages and optionally check balance."""
73+
team_groups = df_out.groupby("team")
74+
team_wams = team_groups["wam"].mean()
75+
overall_mean = df_out["wam"].mean()
76+
77+
print("\n📊 WAM balance report per team:")
78+
for team, avg_wam in team_wams.items():
79+
print(f" Team {team}: avg WAM = {avg_wam:.2f}")
80+
print(f"\n🎯 Overall mean WAM: {overall_mean:.2f}")
81+
print(f"⚖️ Max deviation: {abs(team_wams - overall_mean).max():.2f}")
82+
83+
84+
def test_allocate_teams_returns_df(request):
4485
"""Check that allocate_teams returns a valid DataFrame with a team column."""
45-
with mock.patch("pandas.read_excel", return_value=df_in), mock.patch(
86+
fake_df = request.getfixturevalue("fake_student_df_fixture")
87+
with mock.patch("pandas.read_excel", return_value=fake_df), mock.patch(
4688
"pandas.DataFrame.to_excel"
4789
):
48-
4990
df_out = allocate_teams(
5091
input_file="fake.xlsx",
5192
sheet_name=0,
5293
output_file="output.xlsx",
5394
max_solve_time=40,
5495
wam_weight=0.05,
96+
pos_pref_weight=0.8,
97+
neg_pref_weight=0.8,
5598
min_team_size=3,
5699
max_team_size=5,
57100
)
58-
59101
assert isinstance(df_out, pd.DataFrame)
60102
assert "team" in df_out.columns
61-
assert len(df_out) == len(df_in)
103+
assert len(df_out) == len(fake_df)
62104
assert df_out["team"].notna().all()
63105

64106

65-
def test_teams_have_valid_sizes(df_in=fake_student_df_fixture):
66-
"""Verify each team is within the size limits."""
67-
with mock.patch("pandas.read_excel", return_value=df_in), mock.patch(
107+
def test_teams_have_valid_sizes(request):
108+
"""Verify each team is within the size limits and report WAM balance."""
109+
fake_df = request.getfixturevalue("fake_student_df_fixture")
110+
with mock.patch("pandas.read_excel", return_value=fake_df), mock.patch(
68111
"pandas.DataFrame.to_excel"
69112
):
70-
71113
df_out = allocate_teams(
72114
input_file="fake.xlsx",
73115
sheet_name=0,
74116
output_file="output.xlsx",
75117
max_solve_time=40,
76118
wam_weight=0.05,
119+
pos_pref_weight=0.8,
120+
neg_pref_weight=0.8,
77121
min_team_size=3,
78122
max_team_size=5,
79123
)
80-
81124
team_sizes = df_out.groupby("team").size()
82125
assert (team_sizes >= 3).all()
83126
assert (team_sizes <= 5).all()
127+
report_wam_balance(df_out)
84128

85129

86-
def test_fake_student_df_content(df_in=fake_student_df_fixture):
87-
"""Verify the fake data fixture is correct."""
88-
assert len(df_in) == 100
89-
assert set(df_in.columns) == {
130+
def test_fake_student_df_content(request):
131+
"""Verify the fake data fixture content and columns."""
132+
fake_df = request.getfixturevalue("fake_student_df_fixture")
133+
assert len(fake_df) == 100
134+
expected_cols = {
135+
"Student_ID",
90136
"first_name",
91137
"last_name",
92138
"email",
93139
"gender",
94140
"wam",
95141
"lab",
142+
"Prefer_With",
143+
"Prefer_Not_With",
96144
}
97-
assert df_in["lab"].between(1, 4).all()
98-
assert df_in["wam"].between(50, 90).all()
145+
assert set(fake_df.columns) == expected_cols
146+
assert fake_df["lab"].between(1, 4).all()
147+
assert fake_df["wam"].between(50, 90).all()
148+
149+
150+
@pytest.mark.filterwarnings("ignore:R0914")
151+
def test_preferences_satisfaction(request):
152+
"""Check how many preferences are satisfied in the allocation."""
153+
fake_df = request.getfixturevalue("fake_student_df_fixture")
154+
with mock.patch("pandas.read_excel", return_value=fake_df), mock.patch(
155+
"pandas.DataFrame.to_excel"
156+
):
157+
df_out = allocate_teams(
158+
input_file="fake.xlsx",
159+
sheet_name=0,
160+
output_file="output.xlsx",
161+
max_solve_time=40,
162+
wam_weight=0.05,
163+
pos_pref_weight=0.8,
164+
neg_pref_weight=0.8,
165+
min_team_size=3,
166+
max_team_size=5,
167+
)
168+
169+
team_map = df_out.set_index("Student_ID")["team"].to_dict()
170+
171+
pos_prefs = []
172+
neg_prefs = []
173+
174+
for _, row in fake_df.iterrows():
175+
student = row["Student_ID"].strip()
176+
177+
if pd.notna(row["Prefer_With"]) and row["Prefer_With"].strip():
178+
preferred = [
179+
s.strip() for s in row["Prefer_With"].split(",") if s.strip()
180+
]
181+
for p in preferred:
182+
pos_prefs.append((student, p))
183+
184+
if pd.notna(row["Prefer_Not_With"]) and row["Prefer_Not_With"].strip():
185+
not_preferred = [
186+
s.strip() for s in row["Prefer_Not_With"].split(",") if s.strip()
187+
]
188+
for np in not_preferred:
189+
neg_prefs.append((student, np))
190+
191+
pos_satisfied = sum(
192+
a in team_map and b in team_map and team_map[a] == team_map[b]
193+
for a, b in pos_prefs
194+
)
195+
neg_satisfied = sum(
196+
a in team_map and b in team_map and team_map[a] != team_map[b]
197+
for a, b in neg_prefs
198+
)
199+
200+
assert pos_satisfied > 0, "No positive preferences satisfied"
201+
assert neg_satisfied > 0, "No negative preferences satisfied"
202+
print(f"Positive preferences satisfied: {pos_satisfied}/{len(pos_prefs)}")
203+
print(f"Negative preferences satisfied: {neg_satisfied}/{len(neg_prefs)}")

0 commit comments

Comments
 (0)