Skip to content

Commit 9a3aada

Browse files
committed
Enhance meeting matching logic to exclude the first day and add new tests for availability scenarios
1 parent 7439fa1 commit 9a3aada

3 files changed

Lines changed: 130 additions & 16 deletions

File tree

.vscode/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,7 @@
77
"*test.py"
88
],
99
"python.testing.pytestEnabled": false,
10-
"python.testing.unittestEnabled": true
10+
"python.testing.unittestEnabled": true,
11+
"python-envs.defaultEnvManager": "ms-python.python:system",
12+
"python-envs.pythonProjects": []
1113
}

src/mip_matching/match_meetings.py

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,26 @@
1212
# Hvor stort buffer man ønsker å ha mellom intervjuene
1313
APPLICANT_BUFFER_LENGTH = timedelta(minutes=15)
1414

15-
# Et mål på hvor viktig det er at intervjuer er i nærheten av hverandre
16-
CLUSTERING_WEIGHT = 0.001
17-
1815
# Når på dagen man helst vil ha intervjuene rundt
1916
CLUSTERING_TIME_BASELINE = time(12, 00)
20-
MAX_SCALE_CLUSTERING_TIME = timedelta(seconds=43200) # TODO: Rename variable
17+
MAX_SCALE_CLUSTERING_TIME = timedelta(seconds=43200)
18+
19+
# En liste med alle sekundærmål-vekter.
20+
# Hver vekt bestemmer hvor mye det tilhørende sekundærmålet påvirker optimeringen.
21+
# Høyere vekt = sterkere preferanse for det målet.
22+
23+
# n^2*x + n*x + h, der n er antall intervjuer, x er en konstant.
24+
def calculate_secondary_objective_weights(num_interviews: int) -> dict[str, float]:
25+
# Vekten for clustering øker kvadratisk med antall intervjuer, for å prioritere det mer når det er mange intervjuer.
26+
clustering_weight = 0.001 * (num_interviews ** 2)
27+
28+
# Vekten for spredning over perioden øker lineært med antall intervjuer, for å sikre at det fortsatt har en betydelig effekt.
29+
firstDay_weight = 0.001 * num_interviews
30+
31+
return {
32+
"clustering": clustering_weight,
33+
"first_day": firstDay_weight
34+
}
2135

2236

2337
def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> MeetingMatch:
@@ -26,6 +40,7 @@ def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> Me
2640

2741
m: dict[Matching, mip.Var] = {}
2842

43+
2944
# Lager alle maksimeringsvariabler
3045
for applicant in applicants:
3146
for committee in applicant.get_committees():
@@ -52,35 +67,61 @@ def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> Me
5267
for room in committee.get_rooms(interval)
5368
# type: ignore
5469
) <= 1
55-
5670
# Legger inn begrensninger for at en søker ikke kan ha overlappende intervjutider
5771
# og minst har et buffer mellom hvert intervju som angitt
5872
for applicant in applicants:
59-
potential_interviews = set(slot for slot in m.keys() if slot[0] == applicant)
60-
61-
for interview_a, interview_b in combinations(potential_interviews, r=2):
62-
if interview_a[2].intersects(interview_b[2]) or interview_a[2].is_within_distance(interview_b[2], APPLICANT_BUFFER_LENGTH):
63-
model += m[interview_a] + m[interview_b] <= 1 # type: ignore
73+
# Grupper variabler per unikt (komité, intervall) — rom er irrelevant for overlap
74+
unique_slots: dict[tuple, list[mip.Var]] = {}
75+
for slot in m.keys():
76+
if slot[0] == applicant:
77+
key = (slot[1], slot[2]) # (committee, interval)
78+
if key not in unique_slots:
79+
unique_slots[key] = []
80+
unique_slots[key].append(m[slot])
81+
82+
slot_keys = list(unique_slots.keys())
83+
for i, key_a in enumerate(slot_keys):
84+
for key_b in slot_keys[i + 1:]:
85+
interval_a = key_a[1]
86+
interval_b = key_b[1]
87+
if interval_a.intersects(interval_b) or interval_a.is_within_distance(interval_b, APPLICANT_BUFFER_LENGTH):
88+
# Sum av alle rom-variabler for begge slots <= 1
89+
model += mip.xsum(unique_slots[key_a]) + mip.xsum(unique_slots[key_b]) <= 1 # type: ignore
90+
91+
SECONDARY_OBJECTIVE_WEIGHTS = calculate_secondary_objective_weights(model.num_cols);
6492

6593
# Legger til sekundærmål om at man ønsker å sentrere intervjuer rundt CLUSTERING_TIME_BASELINE
66-
clustering_penalties = []
94+
# og at man foretrekker intervjuer senere i søknadsperioden
95+
secondary_penalties = []
96+
97+
# Finn den tidligste og seneste datoen blant alle intervjuer for å normalisere
98+
all_dates = [interval.start for (_, _, interval, _) in m.keys()]
99+
min_date = min(all_dates)
67100

68101
for name, variable in m.items():
69102
applicant, committee, interval, room = name
103+
104+
# Sekundærmål 1: Clustering rundt CLUSTERING_TIME_BASELINE
70105
if interval.start.time() < CLUSTERING_TIME_BASELINE:
71106
relative_distance_from_baseline = subtract_time(CLUSTERING_TIME_BASELINE,
72107
interval.end.time()) / MAX_SCALE_CLUSTERING_TIME
73108
else:
74109
relative_distance_from_baseline = subtract_time(interval.start.time(),
75110
CLUSTERING_TIME_BASELINE) / MAX_SCALE_CLUSTERING_TIME
76111

77-
clustering_penalties.append(
78-
CLUSTERING_WEIGHT * relative_distance_from_baseline * variable) # type: ignore
112+
secondary_penalties.append(
113+
SECONDARY_OBJECTIVE_WEIGHTS["clustering"] * relative_distance_from_baseline * variable) # type: ignore
79114

115+
# Sekundærmål 2: Foretrekk intervjuer senere i perioden
116+
# Gir lavere straff jo senere i perioden intervjuet er
117+
if interval.start.date() == min_date.date():
118+
secondary_penalties.append(
119+
SECONDARY_OBJECTIVE_WEIGHTS["first_day"] * variable) # type: ignore
120+
80121
# Setter mål til å være maksimering av antall møter
81-
# med sekundærmål om å samle intervjuene rundt CLUSTERING_TIME_BASELINE
122+
# med sekundærmål om å samle intervjuene og foretrekke senere datoer
82123
model.objective = mip.maximize(
83-
mip.xsum(m.values()) - mip.xsum(clustering_penalties))
124+
mip.xsum(m.values()) - mip.xsum(secondary_penalties))
84125

85126
# Kjør optimeringen
86127
solver_status = model.optimize()
@@ -95,6 +136,9 @@ def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> Me
95136

96137
total_wanted_meetings = sum(
97138
len(applicant.get_committees()) for applicant in applicants)
139+
140+
print(f"Matched {total_matched_meetings} out of {total_wanted_meetings} wanted meetings.")
141+
print(model.num_cols)
98142

99143
match_object: MeetingMatch = {
100144
"solver_status": solver_status,

tests/mip_test.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,75 @@ def test_clustering_not_available_12(self):
187187
self.assertEqual(match["matched_meetings"], 1)
188188

189189
self.assertIn(match["matchings"][0], possible_matchings, "Møte var ikke blant matchede intervjuer")
190+
191+
def test_all_availability_first_day_still_matches(self):
192+
"""Tester at intervjuer fortsatt blir matchet selv om all ledig tid
193+
og alle komité-slots kun er på første dag av perioden."""
190194

195+
appkom = Committee(name="Appkom", interview_length=timedelta(minutes=20))
196+
prokom = Committee(name="Prokom", interview_length=timedelta(minutes=20))
197+
198+
# Begge komiteer har kun slots på første (og eneste) dag
199+
appkom.add_interview_slot(
200+
TimeInterval(datetime(2025, 10, 24, 8, 0), datetime(2025, 10, 24, 12, 0)), "AppkomRom"
201+
)
202+
prokom.add_interview_slot(
203+
TimeInterval(datetime(2025, 10, 24, 8, 0), datetime(2025, 10, 24, 12, 0)), "ProkomRom"
204+
)
205+
206+
simen: Applicant = Applicant(name="Simen")
207+
simen.add_committees({appkom, prokom})
208+
simen.add_interval(
209+
TimeInterval(datetime(2025, 10, 24, 8, 0), datetime(2025, 10, 24, 12, 0))
210+
)
211+
212+
julian: Applicant = Applicant(name="Julian")
213+
julian.add_committees({appkom})
214+
julian.add_interval(
215+
TimeInterval(datetime(2025, 10, 24, 8, 0), datetime(2025, 10, 24, 12, 0))
216+
)
217+
218+
match = match_meetings(applicants={simen, julian}, committees={appkom, prokom})
219+
220+
# Alle 3 ønskede intervjuer skal kunne matches selv om alt er på dag 1
221+
self.assertEqual(match["matched_meetings"], 3,
222+
"Alle intervjuer burde bli matchet selv om alt er på første dag")
223+
self.check_constraints(matchings=match["matchings"])
224+
225+
def test_matching_not_first_day(self):
226+
"""Tester at intervjuer ikke blir matchet første dag i perioden
227+
når det finnes like gode alternativer på dag 2."""
228+
229+
appkom = Committee(name="Appkom", interview_length=timedelta(minutes=20))
230+
231+
appkom.add_interview_slot(
232+
TimeInterval(datetime(2025, 10, 24, 11, 0), datetime(2025, 10, 24, 13, 0)), "AppkomRom"
233+
)
234+
appkom.add_interview_slot(
235+
TimeInterval(datetime(2025, 10, 25, 11, 0), datetime(2025, 10, 25, 13, 0)), "AppkomRom"
236+
)
237+
238+
simen: Applicant = Applicant(name="Simen")
239+
simen.add_committees({appkom})
240+
simen.add_interval(
241+
TimeInterval(datetime(2025, 10, 24, 11, 0), datetime(2025, 10, 24, 13, 0))
242+
)
243+
simen.add_interval(
244+
TimeInterval(datetime(2025, 10, 25, 11, 0), datetime(2025, 10, 25, 13, 0))
245+
)
246+
247+
match = match_meetings(applicants={simen}, committees={appkom})
248+
249+
self.assertEqual(match["matched_meetings"], 1,
250+
"Intervjuet burde bli matchet")
251+
self.check_constraints(matchings=match["matchings"])
252+
253+
# Sjekk at intervjuet ble satt på dag 2 (25. oktober), ikke dag 1 (24. oktober)
254+
matched_interval = match["matchings"][0][2]
255+
self.assertEqual(matched_interval.start.date(), date(2025, 10, 25),
256+
f"Intervjuet burde bli satt på dag 2 (25. oktober), "
257+
f"men ble satt på {matched_interval.start.date()}")
258+
191259

192260
def test_realistic(self):
193261
"""

0 commit comments

Comments
 (0)