From 9a3aadabacc3fc6404aa39b608fbcb91427e4af8 Mon Sep 17 00:00:00 2001 From: Simen Barnes Date: Mon, 23 Mar 2026 18:46:55 +0100 Subject: [PATCH] Enhance meeting matching logic to exclude the first day and add new tests for availability scenarios --- .vscode/settings.json | 4 +- src/mip_matching/match_meetings.py | 74 ++++++++++++++++++++++++------ tests/mip_test.py | 68 +++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 16 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9780500..5fff3ed 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,7 @@ "*test.py" ], "python.testing.pytestEnabled": false, - "python.testing.unittestEnabled": true + "python.testing.unittestEnabled": true, + "python-envs.defaultEnvManager": "ms-python.python:system", + "python-envs.pythonProjects": [] } \ No newline at end of file diff --git a/src/mip_matching/match_meetings.py b/src/mip_matching/match_meetings.py index cf92fc6..8704cd4 100644 --- a/src/mip_matching/match_meetings.py +++ b/src/mip_matching/match_meetings.py @@ -12,12 +12,26 @@ # Hvor stort buffer man ønsker å ha mellom intervjuene APPLICANT_BUFFER_LENGTH = timedelta(minutes=15) -# Et mål på hvor viktig det er at intervjuer er i nærheten av hverandre -CLUSTERING_WEIGHT = 0.001 - # Når på dagen man helst vil ha intervjuene rundt CLUSTERING_TIME_BASELINE = time(12, 00) -MAX_SCALE_CLUSTERING_TIME = timedelta(seconds=43200) # TODO: Rename variable +MAX_SCALE_CLUSTERING_TIME = timedelta(seconds=43200) + +# En liste med alle sekundærmål-vekter. +# Hver vekt bestemmer hvor mye det tilhørende sekundærmålet påvirker optimeringen. +# Høyere vekt = sterkere preferanse for det målet. + +# n^2*x + n*x + h, der n er antall intervjuer, x er en konstant. +def calculate_secondary_objective_weights(num_interviews: int) -> dict[str, float]: + # Vekten for clustering øker kvadratisk med antall intervjuer, for å prioritere det mer når det er mange intervjuer. + clustering_weight = 0.001 * (num_interviews ** 2) + + # Vekten for spredning over perioden øker lineært med antall intervjuer, for å sikre at det fortsatt har en betydelig effekt. + firstDay_weight = 0.001 * num_interviews + + return { + "clustering": clustering_weight, + "first_day": firstDay_weight + } def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> MeetingMatch: @@ -26,6 +40,7 @@ def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> Me m: dict[Matching, mip.Var] = {} + # Lager alle maksimeringsvariabler for applicant in applicants: for committee in applicant.get_committees(): @@ -52,21 +67,41 @@ def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> Me for room in committee.get_rooms(interval) # type: ignore ) <= 1 - # Legger inn begrensninger for at en søker ikke kan ha overlappende intervjutider # og minst har et buffer mellom hvert intervju som angitt for applicant in applicants: - potential_interviews = set(slot for slot in m.keys() if slot[0] == applicant) - - for interview_a, interview_b in combinations(potential_interviews, r=2): - if interview_a[2].intersects(interview_b[2]) or interview_a[2].is_within_distance(interview_b[2], APPLICANT_BUFFER_LENGTH): - model += m[interview_a] + m[interview_b] <= 1 # type: ignore + # Grupper variabler per unikt (komité, intervall) — rom er irrelevant for overlap + unique_slots: dict[tuple, list[mip.Var]] = {} + for slot in m.keys(): + if slot[0] == applicant: + key = (slot[1], slot[2]) # (committee, interval) + if key not in unique_slots: + unique_slots[key] = [] + unique_slots[key].append(m[slot]) + + slot_keys = list(unique_slots.keys()) + for i, key_a in enumerate(slot_keys): + for key_b in slot_keys[i + 1:]: + interval_a = key_a[1] + interval_b = key_b[1] + if interval_a.intersects(interval_b) or interval_a.is_within_distance(interval_b, APPLICANT_BUFFER_LENGTH): + # Sum av alle rom-variabler for begge slots <= 1 + model += mip.xsum(unique_slots[key_a]) + mip.xsum(unique_slots[key_b]) <= 1 # type: ignore + + SECONDARY_OBJECTIVE_WEIGHTS = calculate_secondary_objective_weights(model.num_cols); # Legger til sekundærmål om at man ønsker å sentrere intervjuer rundt CLUSTERING_TIME_BASELINE - clustering_penalties = [] + # og at man foretrekker intervjuer senere i søknadsperioden + secondary_penalties = [] + + # Finn den tidligste og seneste datoen blant alle intervjuer for å normalisere + all_dates = [interval.start for (_, _, interval, _) in m.keys()] + min_date = min(all_dates) for name, variable in m.items(): applicant, committee, interval, room = name + + # Sekundærmål 1: Clustering rundt CLUSTERING_TIME_BASELINE if interval.start.time() < CLUSTERING_TIME_BASELINE: relative_distance_from_baseline = subtract_time(CLUSTERING_TIME_BASELINE, interval.end.time()) / MAX_SCALE_CLUSTERING_TIME @@ -74,13 +109,19 @@ def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> Me relative_distance_from_baseline = subtract_time(interval.start.time(), CLUSTERING_TIME_BASELINE) / MAX_SCALE_CLUSTERING_TIME - clustering_penalties.append( - CLUSTERING_WEIGHT * relative_distance_from_baseline * variable) # type: ignore + secondary_penalties.append( + SECONDARY_OBJECTIVE_WEIGHTS["clustering"] * relative_distance_from_baseline * variable) # type: ignore + # Sekundærmål 2: Foretrekk intervjuer senere i perioden + # Gir lavere straff jo senere i perioden intervjuet er + if interval.start.date() == min_date.date(): + secondary_penalties.append( + SECONDARY_OBJECTIVE_WEIGHTS["first_day"] * variable) # type: ignore + # Setter mål til å være maksimering av antall møter - # med sekundærmål om å samle intervjuene rundt CLUSTERING_TIME_BASELINE + # med sekundærmål om å samle intervjuene og foretrekke senere datoer model.objective = mip.maximize( - mip.xsum(m.values()) - mip.xsum(clustering_penalties)) + mip.xsum(m.values()) - mip.xsum(secondary_penalties)) # Kjør optimeringen solver_status = model.optimize() @@ -95,6 +136,9 @@ def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> Me total_wanted_meetings = sum( len(applicant.get_committees()) for applicant in applicants) + + print(f"Matched {total_matched_meetings} out of {total_wanted_meetings} wanted meetings.") + print(model.num_cols) match_object: MeetingMatch = { "solver_status": solver_status, diff --git a/tests/mip_test.py b/tests/mip_test.py index ff22b7a..0be5061 100644 --- a/tests/mip_test.py +++ b/tests/mip_test.py @@ -187,7 +187,75 @@ def test_clustering_not_available_12(self): self.assertEqual(match["matched_meetings"], 1) self.assertIn(match["matchings"][0], possible_matchings, "Møte var ikke blant matchede intervjuer") + + def test_all_availability_first_day_still_matches(self): + """Tester at intervjuer fortsatt blir matchet selv om all ledig tid + og alle komité-slots kun er på første dag av perioden.""" + appkom = Committee(name="Appkom", interview_length=timedelta(minutes=20)) + prokom = Committee(name="Prokom", interview_length=timedelta(minutes=20)) + + # Begge komiteer har kun slots på første (og eneste) dag + appkom.add_interview_slot( + TimeInterval(datetime(2025, 10, 24, 8, 0), datetime(2025, 10, 24, 12, 0)), "AppkomRom" + ) + prokom.add_interview_slot( + TimeInterval(datetime(2025, 10, 24, 8, 0), datetime(2025, 10, 24, 12, 0)), "ProkomRom" + ) + + simen: Applicant = Applicant(name="Simen") + simen.add_committees({appkom, prokom}) + simen.add_interval( + TimeInterval(datetime(2025, 10, 24, 8, 0), datetime(2025, 10, 24, 12, 0)) + ) + + julian: Applicant = Applicant(name="Julian") + julian.add_committees({appkom}) + julian.add_interval( + TimeInterval(datetime(2025, 10, 24, 8, 0), datetime(2025, 10, 24, 12, 0)) + ) + + match = match_meetings(applicants={simen, julian}, committees={appkom, prokom}) + + # Alle 3 ønskede intervjuer skal kunne matches selv om alt er på dag 1 + self.assertEqual(match["matched_meetings"], 3, + "Alle intervjuer burde bli matchet selv om alt er på første dag") + self.check_constraints(matchings=match["matchings"]) + + def test_matching_not_first_day(self): + """Tester at intervjuer ikke blir matchet første dag i perioden + når det finnes like gode alternativer på dag 2.""" + + appkom = Committee(name="Appkom", interview_length=timedelta(minutes=20)) + + appkom.add_interview_slot( + TimeInterval(datetime(2025, 10, 24, 11, 0), datetime(2025, 10, 24, 13, 0)), "AppkomRom" + ) + appkom.add_interview_slot( + TimeInterval(datetime(2025, 10, 25, 11, 0), datetime(2025, 10, 25, 13, 0)), "AppkomRom" + ) + + simen: Applicant = Applicant(name="Simen") + simen.add_committees({appkom}) + simen.add_interval( + TimeInterval(datetime(2025, 10, 24, 11, 0), datetime(2025, 10, 24, 13, 0)) + ) + simen.add_interval( + TimeInterval(datetime(2025, 10, 25, 11, 0), datetime(2025, 10, 25, 13, 0)) + ) + + match = match_meetings(applicants={simen}, committees={appkom}) + + self.assertEqual(match["matched_meetings"], 1, + "Intervjuet burde bli matchet") + self.check_constraints(matchings=match["matchings"]) + + # Sjekk at intervjuet ble satt på dag 2 (25. oktober), ikke dag 1 (24. oktober) + matched_interval = match["matchings"][0][2] + self.assertEqual(matched_interval.start.date(), date(2025, 10, 25), + f"Intervjuet burde bli satt på dag 2 (25. oktober), " + f"men ble satt på {matched_interval.start.date()}") + def test_realistic(self): """