Skip to content

Commit

Permalink
Merge branch 'main' into footer
Browse files Browse the repository at this point in the history
  • Loading branch information
julian-ao authored Sep 28, 2024
2 parents 854f555 + e5033a7 commit a737dd1
Show file tree
Hide file tree
Showing 15 changed files with 236 additions and 227 deletions.
15 changes: 4 additions & 11 deletions algorithm/README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,16 @@
# Algoritme

Algoritmen baserer seg på MIP-programmering (Mixed Integer Linear Programming).
**mip_matching** er en pakke for å tildele intervjutider til søkere basert på ledige tider for søkere og komitéer.

Algoritmen baserer seg på MIP-programmering (Mixed Integer Linear Programming). Se [Modellering.md](./src/Modellering.md) for detaljer.

## Setup Python Venv

```bash
cd algorithm
python -m venv ".venv"
```

```
.\.venv\Scripts\activate
pip install -e .
pip install -r requirements.txt
pip install pymongo[srv]
```

## TODOs

- [x] Lage funksjon som deler opp fra en komités slot
- [x] Sette opp begrensningene fra modelleringen
- [ ] Flikke litt på modelleringen.
- [ ] Finn ut hvordan man kan preprosessere dataen for å få ned kjøretiden (f. eks ved å lage lister av personer for hver komité.)
9 changes: 5 additions & 4 deletions algorithm/bridge/fetch_applicants_and_committees.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ def main():
application_end = datetime.fromisoformat(period["applicationPeriod"]["end"].replace("Z", "+00:00"))

now = datetime.now(timezone.utc)


#or period["name"] == "Juli Opptak"
if (application_end < now and period["hasSentInterviewTimes"] == False):
Expand Down Expand Up @@ -115,11 +114,13 @@ def format_match_results(match_results: MeetingMatch, applicants: List[dict], pe
time_interval = result[2]
start = time_interval.start.isoformat()
end = time_interval.end.isoformat()
room = result[3]

transformed_results[applicant_id]["interviews"].append({
"start": start,
"end": end,
"committeeName": committee.name
"committeeName": committee.name,
"room": room
})

return list(transformed_results.values())
Expand Down Expand Up @@ -156,8 +157,8 @@ def create_committee_objects(committee_data: List[dict]) -> set[Committee]:
start=datetime.fromisoformat(interval_data['start'].replace("Z", "+00:00")),
end=datetime.fromisoformat(interval_data['end'].replace("Z", "+00:00"))
)
capacity = interval_data.get('capacity', 1)
committee.add_interval(interval, capacity)
room = interval_data["room"]
committee.add_interview_slot(interval, room)
committees.add(committee)
return committees

Expand Down
22 changes: 11 additions & 11 deletions algorithm/src/Modellering.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
# Modellering av problem gjennom Mixed Integer Linear Programming

## Nyttige ressurser

- https://python-mip.readthedocs.io/en/latest/quickstart.html
- https://towardsdatascience.com/mixed-integer-linear-programming-1-bc0ef201ee87
- https://towardsdatascience.com/mixed-integer-linear-programming-formal-definition-and-solution-space-6b3286d54892
- https://www.gurobi.com/resources/mixed-integer-programming-mip-a-primer-on-the-basics/
# Modellering av møtetildelingsproblem gjennom Mixed Integer Linear Programming

## Variabler

Expand All @@ -19,7 +12,7 @@

`t`

- Timeslot (Må gjøres til intervaller etter hvert)
- Timeslot

`m(p, k, t)`

Expand Down Expand Up @@ -55,8 +48,15 @@ For alle `k`:

## Mål

Maksimere `sum(m(p, k, t))` for alle `p`, `k` og `t`
Maksimere `sum(m(p, k, t))` for alle `p`, `k` og `t`. Altså: Maksimere antall intervjuer som tildeles.

### Sekundærmål

- [Ikke enda implementert] La det være færrest mulig og minst mulig mellomrom mellom intervjuene for komitéene.
- La intervjuene klumpe seg rundt klokken 12 og dermed også minske hvor mange hull komitéene får i sin intervjuplan.

## Nyttige ressurser

- https://python-mip.readthedocs.io/en/latest/quickstart.html
- https://towardsdatascience.com/mixed-integer-linear-programming-1-bc0ef201ee87
- https://towardsdatascience.com/mixed-integer-linear-programming-formal-definition-and-solution-space-6b3286d54892
- https://www.gurobi.com/resources/mixed-integer-programming-mip-a-primer-on-the-basics/
41 changes: 20 additions & 21 deletions algorithm/src/mip_matching/Committee.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from typing import Iterator

from mip_matching.types import Room


class Committee:
"""
Expand All @@ -21,39 +23,40 @@ class Committee:
"""

def __init__(self, name: str, interview_length: timedelta = timedelta(minutes=15)):
self.capacities: dict[TimeInterval, int] = dict()
self.interview_slots: dict[TimeInterval, set[Room]] = dict()
self.interview_length: timedelta = interview_length
self.applicants: set[Applicant] = set()
self.name = name

def add_interval(self, interval: TimeInterval, capacity: int = 1) -> None:
"""Legger til et nytt intervall med gitt kapasitet hvis intervallet
ikke allerede har en kapasitet for denne komitéen.
def add_interview_slot(self, interval: TimeInterval, room: Room) -> None:
"""Legger til et nytt intervall med gitt rom.
Når intervaller legges til deles det automatisk opp i
intervaller med lik lengde som intervjulengder."""
minimal_intervals = TimeInterval.divide_interval(
interval=interval, length=self.interview_length)
for interval in minimal_intervals:
if interval not in self.capacities:
self.capacities[interval] = capacity
else:
self.capacities[interval] += capacity

def add_intervals_with_capacities(self, intervals_with_capacities: dict[TimeInterval, int]):
"""Legger til flere tidsintervaller samtidig."""
for interval, capacity in intervals_with_capacities.items():
self.add_interval(interval, capacity)
if interval not in self.interview_slots:
self.interview_slots[interval] = set()
self.interview_slots[interval].add(room)

def get_intervals_and_capacities(self) -> Iterator[tuple[TimeInterval, int]]:
"""Generator som returnerer interval-kapasitet-par."""
for interval, capacity in self.capacities.items():
yield interval, capacity
for interval, rooms in self.interview_slots.items():
yield interval, len(rooms)

def get_capacity(self, interval: TimeInterval) -> int:
"""Returnerer komitéens kapasitet ved et gitt interval (ferdiginndelt etter lengde)"""
return len(self.interview_slots[interval])

def get_intervals(self) -> Iterator[TimeInterval]:
"""Generator som returnerer kun intervallene"""
for interval in self.capacities.keys():
for interval in self.interview_slots.keys():
yield interval

def get_rooms(self, interval: TimeInterval) -> Iterator[Room]:
for room in self.interview_slots[interval]:
yield room

def _add_applicant(self, applicant: Applicant):
"""Metode brukt for å holde toveis-assosiasjonen."""
self.applicants.add(applicant)
Expand All @@ -62,10 +65,6 @@ def get_applicants(self) -> Iterator[Applicant]:
for applicant in self.applicants:
yield applicant

def get_capacity(self, interval: TimeInterval) -> int:
"""Returnerer komitéens kapasitet ved et gitt interval (ferdiginndelt etter lengde)"""
return self.capacities[interval]

def get_applicant_count(self) -> int:
return len(self.applicants)

Expand All @@ -77,4 +76,4 @@ def __repr__(self):


if __name__ == "__main__":
print("running")
print("running")
2 changes: 1 addition & 1 deletion algorithm/src/mip_matching/TimeInterval.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def divide(self, length: timedelta) -> list[TimeInterval]:
return TimeInterval.divide_interval(self, length)

def is_within_distance(self, other: TimeInterval, distance: timedelta) -> bool:
return (self.end <= other.start and self.end + distance > other.start) or (other.end <= self.start and other.end + distance > self.start)
return (self.end <= other.start < self.end + distance) or (other.end <= self.start < other.end + distance)

@staticmethod
def divide_interval(interval: TimeInterval, length: timedelta) -> list[TimeInterval]:
Expand Down
45 changes: 19 additions & 26 deletions algorithm/src/mip_matching/match_meetings.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from datetime import timedelta, time
from itertools import combinations

from mip_matching.types import Matching, MeetingMatch
from mip_matching.utils import subtract_time


Expand All @@ -22,60 +23,53 @@
MAX_SCALE_CLUSTERING_TIME = timedelta(seconds=43200) # TODO: Rename variable


class MeetingMatch(TypedDict):
"""Type definition of a meeting match object"""
solver_status: mip.OptimizationStatus
matched_meetings: int
total_wanted_meetings: int
matchings: list[tuple[Applicant, Committee, TimeInterval]]


def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> MeetingMatch:
"""Matches meetings and returns a MeetingMatch-object"""
model = mip.Model(sense=mip.MAXIMIZE)

m: dict[tuple[Applicant, Committee, TimeInterval], mip.Var] = {}
m: dict[Matching, mip.Var] = {}

# Lager alle maksimeringsvariabler
for applicant in applicants:
for committee in applicant.get_committees():
for interval in applicant.get_fitting_committee_slots(committee):
m[(applicant, committee, interval)] = model.add_var(
var_type=mip.BINARY, name=f"({applicant}, {committee}, {interval})")
for room in committee.get_rooms(interval):
m[(applicant, committee, interval, room)] = model.add_var(
var_type=mip.BINARY, name=f"({applicant}, {committee}, {interval}, {room})")

# Legger inn begrensninger for at en komité kun kan ha antall møter i et slot lik kapasiteten.
for committee in committees:
for interval, capacity in committee.get_intervals_and_capacities():
model += mip.xsum(m[(applicant, committee, interval)]
model += mip.xsum(m[(applicant, committee, interval, room)]
for applicant in committee.get_applicants()
for room in committee.get_rooms(interval)
if (applicant, committee, interval, room) in m
# type: ignore
if (applicant, committee, interval) in m) <= capacity
) <= capacity

# Legger inn begrensninger for at en person kun har ett intervju med hver komité
for applicant in applicants:
for committee in applicant.get_committees():
model += mip.xsum(m[(applicant, committee, interval)]
model += mip.xsum(m[(applicant, committee, interval, room)]
for interval in applicant.get_fitting_committee_slots(committee)
for room in committee.get_rooms(interval)
# type: ignore
for interval in applicant.get_fitting_committee_slots(committee)) <= 1
) <= 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[tuple[Committee, TimeInterval]] = set()
for applicant_candidate, committee, interval in m:
if applicant == applicant_candidate:
potential_interviews.add((committee, interval))
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[1].intersects(interview_b[1]) or interview_a[1].is_within_distance(interview_b[1], APPLICANT_BUFFER_LENGTH):
model += m[(applicant, *interview_a)] + \
m[(applicant, *interview_b)] <= 1 # type: ignore
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

# Legger til sekundærmål om at man ønsker å sentrere intervjuer rundt CLUSTERING_TIME_BASELINE
clustering_objectives = []

for name, variable in m.items():
applicant, committee, interval = name
applicant, committee, interval, room = name
if interval.start.time() < CLUSTERING_TIME_BASELINE:
relative_distance_from_baseline = subtract_time(CLUSTERING_TIME_BASELINE,
interval.end.time()) / MAX_SCALE_CLUSTERING_TIME
Expand All @@ -86,8 +80,8 @@ def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> Me
clustering_objectives.append(
CLUSTERING_WEIGHT * relative_distance_from_baseline * 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
# Setter mål til å være maksimering av antall møter
# med sekundærmål om å samle intervjuene rundt CLUSTERING_TIME_BASELINE
model.objective = mip.maximize(
mip.xsum(m.values()) + mip.xsum(clustering_objectives))

Expand All @@ -101,7 +95,6 @@ def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> Me
if variable.x:
antall_matchede_møter += 1
matchings.append(name)
print(f"{name}")

antall_ønskede_møter = sum(
len(applicant.get_committees()) for applicant in applicants)
Expand Down
22 changes: 22 additions & 0 deletions algorithm/src/mip_matching/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""
Typealiaser
"""

from typing import TypedDict, TYPE_CHECKING
import mip
if TYPE_CHECKING:
# Unngår cyclic import
from mip_matching.Applicant import Applicant
from mip_matching.Committee import Committee
from mip_matching.TimeInterval import TimeInterval


type Room = str
type Matching = tuple[Applicant, Committee, TimeInterval, Room]

class MeetingMatch(TypedDict):
"""Type definition of a meeting match object"""
solver_status: mip.OptimizationStatus
matched_meetings: int
total_wanted_meetings: int
matchings: list[Matching]
29 changes: 11 additions & 18 deletions algorithm/tests/CommitteeTest.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
from __future__ import annotations
from datetime import datetime, timedelta
import unittest
from mip_matching.TimeInterval import TimeInterval
from mip_matching.Committee import Committee
# from __future__ import annotations
# from datetime import datetime, timedelta
# import unittest
# from mip_matching.TimeInterval import TimeInterval
# from mip_matching.Committee import Committee


class ApplicantTest(unittest.TestCase):
def setUp(self) -> None:
self.committee = Committee(
"TestKom", interview_length=timedelta(minutes=30))
self.committee.add_intervals_with_capacities({
TimeInterval(datetime(2024, 8, 24, 8, 0), datetime(2024, 8, 24, 9, 30)): 1,
TimeInterval(datetime(2024, 8, 24, 8, 30), datetime(2024, 8, 24, 9, 30)): 1
})
# class ApplicantTest(unittest.TestCase):
# def setUp(self) -> None:
# self.committee = Committee(
# "TestKom", interview_length=timedelta(minutes=30))


def test_capacity_stacking(self) -> None:
self.assertEqual(1, self.committee.get_capacity(
TimeInterval(datetime(2024, 8, 24, 8, 0), datetime(2024, 8, 24, 8, 30))))
self.assertEqual(2, self.committee.get_capacity(
TimeInterval(datetime(2024, 8, 24, 8, 30), datetime(2024, 8, 24, 9, 0))))

Loading

0 comments on commit a737dd1

Please sign in to comment.