Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .vscode/settings.json
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trenger vi å ha med denne endringen? Hva er greien?

Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
74 changes: 59 additions & 15 deletions src/mip_matching/match_meetings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dette vil vel fort føre til at det prioriteres over antall intervjuer.
Du mener kanskje 1/( num_interviews ** 2)?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Foreslår noe sånt som dette for å generalisere det:

secondary_penalties = [
    [...], # listen av variabler for én type
    [...] # listen av variabler for neste type
]

mip.xsum(mip.xsum(1/(num_interviews ** i)) * penalities for i, penalties in enumerate(secondary_penalties))


# 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:
Expand All @@ -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():
Expand All @@ -52,35 +67,61 @@ 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
else:
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()
Expand All @@ -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,
Expand Down
68 changes: 68 additions & 0 deletions tests/mip_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
Loading