182 lines
8.4 KiB
Python
182 lines
8.4 KiB
Python
"""
|
|
PlacementBoard: shared clash-tracking state and low-level placement
|
|
primitives. Owns the room/instructor/batch busy-maps and the accumulated
|
|
session list. Has zero scheduling policy of its own — every phase module
|
|
(phase1_general, phase2_labs, phase3_remaining) reads and writes the same
|
|
board, which is what makes each later phase automatically see earlier
|
|
phases' placements as fixed/locked.
|
|
"""
|
|
|
|
from . import grid
|
|
|
|
|
|
def _is_lab_room_type(room_type):
|
|
"""
|
|
Real room data uses free-text room_type labels (e.g. "Computing
|
|
Laboratories", "electrical", "Classrooms", "Civil", "physics") rather
|
|
than a fixed classroom/lab enum. Any label mentioning "lab" is treated
|
|
as a lab room; everything else is a classroom.
|
|
"""
|
|
return "lab" in (room_type or "").lower()
|
|
|
|
|
|
class PlacementBoard:
|
|
def __init__(self, data):
|
|
self.data = data
|
|
self.classrooms = [r for r in data["rooms"] if not _is_lab_room_type(r["room_type"])]
|
|
self.labs = [r for r in data["rooms"] if _is_lab_room_type(r["room_type"])]
|
|
|
|
# busy[(day, slot_index)] -> set of keys already occupied
|
|
self.room_busy = {}
|
|
self.instructor_busy = {}
|
|
self.batch_busy = {}
|
|
|
|
self.sessions = []
|
|
|
|
# -- serialization -----------------------------------------------------
|
|
|
|
def to_state(self):
|
|
"""Serializes accumulated sessions + busy-maps to a JSON-able dict."""
|
|
def _dump_busy(busy_map):
|
|
return {f"{day}|{slot_index}": sorted(values) for (day, slot_index), values in busy_map.items()}
|
|
|
|
return {
|
|
"sessions": self.sessions,
|
|
"room_busy": _dump_busy(self.room_busy),
|
|
"instructor_busy": _dump_busy(self.instructor_busy),
|
|
"batch_busy": _dump_busy(self.batch_busy),
|
|
}
|
|
|
|
@classmethod
|
|
def from_state(cls, data, state):
|
|
"""Rebuilds a PlacementBoard from a dict previously produced by to_state()."""
|
|
def _load_busy(dumped):
|
|
busy = {}
|
|
for key, values in dumped.items():
|
|
day, slot_index = key.rsplit("|", 1)
|
|
busy[(day, int(slot_index))] = set(values)
|
|
return busy
|
|
|
|
board = cls(data)
|
|
board.sessions = list(state["sessions"])
|
|
board.room_busy = _load_busy(state["room_busy"])
|
|
board.instructor_busy = _load_busy(state["instructor_busy"])
|
|
board.batch_busy = _load_busy(state["batch_busy"])
|
|
return board
|
|
|
|
# -- clash-tracking primitives ------------------------------------------
|
|
|
|
def is_free(self, day, slot_index, room_id, instructor_code, batch_codes):
|
|
key = (day, slot_index)
|
|
if room_id in self.room_busy.get(key, set()):
|
|
return False
|
|
if instructor_code in self.instructor_busy.get(key, set()):
|
|
return False
|
|
if any(b in self.batch_busy.get(key, set()) for b in batch_codes):
|
|
return False
|
|
return True
|
|
|
|
def mark_busy(self, day, slot_index, room_id, instructor_code, batch_codes):
|
|
key = (day, slot_index)
|
|
self.room_busy.setdefault(key, set()).add(room_id)
|
|
self.instructor_busy.setdefault(key, set()).add(instructor_code)
|
|
self.batch_busy.setdefault(key, set()).update(batch_codes)
|
|
|
|
def unmark_busy(self, day, slot_index, room_id, instructor_code, batch_codes):
|
|
key = (day, slot_index)
|
|
self.room_busy.get(key, set()).discard(room_id)
|
|
self.instructor_busy.get(key, set()).discard(instructor_code)
|
|
for b in batch_codes:
|
|
self.batch_busy.get(key, set()).discard(b)
|
|
|
|
def room_candidates(self, preferred_room_id, room_pool):
|
|
"""Preferred room first, then the rest of the same-type pool."""
|
|
preferred = self.data["rooms_by_id"].get(preferred_room_id)
|
|
rest = [r for r in room_pool if r["room_id"] != preferred_room_id]
|
|
return ([preferred] if preferred else []) + rest
|
|
|
|
# -- session creation ----------------------------------------------------
|
|
|
|
def add_session(self, offering, day, slot_indices, room, is_long_lecture=False):
|
|
course = offering["course"]
|
|
if is_long_lecture:
|
|
start, end = grid.LONG_LECTURE_START, grid.LONG_LECTURE_END
|
|
duration_minutes = 120
|
|
session_type = "theory"
|
|
else:
|
|
start = grid.TIME_SLOTS[slot_indices[0]]["start"]
|
|
end = grid.TIME_SLOTS[slot_indices[-1]]["end"]
|
|
duration_minutes = 90 * len(slot_indices)
|
|
session_type = "lab" if len(slot_indices) > 1 else "theory"
|
|
session = {
|
|
"day": day,
|
|
"slot_indices": slot_indices,
|
|
"start": start,
|
|
"end": end,
|
|
"duration_minutes": duration_minutes,
|
|
"course_id": offering["course_id"],
|
|
"course_name": course["course_name"],
|
|
"course_type": session_type,
|
|
"is_general": course["is_general"],
|
|
"section_id": offering["section_id"],
|
|
"instructor_code": offering["instructor_code"],
|
|
"instructor_name": offering["instructor_name"],
|
|
"room_id": room["room_id"],
|
|
"room_name": room["room_name"],
|
|
"batch_codes": offering["batch_codes"],
|
|
}
|
|
self.sessions.append(session)
|
|
return session
|
|
|
|
def remove_session(self, session):
|
|
"""Unmarks a previously placed session's slots and removes it from self.sessions."""
|
|
for slot_index in session["slot_indices"]:
|
|
self.unmark_busy(
|
|
session["day"], slot_index,
|
|
session["room_id"], session["instructor_code"], session["batch_codes"],
|
|
)
|
|
self.sessions.remove(session)
|
|
|
|
# -- placement helpers shared by phase modules --------------------------
|
|
|
|
def place_theory_session(self, offering, room_candidates, day_order):
|
|
"""Finds and locks a free normal 90-minute single-slot session; returns the session dict or None."""
|
|
for day in day_order:
|
|
for slot_index in grid.TEACHING_SLOT_INDICES:
|
|
for room in room_candidates:
|
|
if self.is_free(day, slot_index, room["room_id"], offering["instructor_code"], offering["batch_codes"]):
|
|
self.mark_busy(day, slot_index, room["room_id"], offering["instructor_code"], offering["batch_codes"])
|
|
return self.add_session(offering, day, [slot_index], room)
|
|
return None
|
|
|
|
def place_long_lecture(self, offering, room_candidates, day_order):
|
|
"""
|
|
Finds and locks a free 08:30-10:30 long-lecture session. Occupies
|
|
the same clash-tracking slot index as normal slot 0 since they
|
|
overlap in wall-clock time. Returns the session dict or None.
|
|
"""
|
|
for day in day_order:
|
|
for room in room_candidates:
|
|
if self.is_free(day, grid.LONG_LECTURE_SLOT_INDEX, room["room_id"], offering["instructor_code"], offering["batch_codes"]):
|
|
self.mark_busy(day, grid.LONG_LECTURE_SLOT_INDEX, room["room_id"], offering["instructor_code"], offering["batch_codes"])
|
|
return self.add_session(offering, day, [grid.LONG_LECTURE_SLOT_INDEX], room, is_long_lecture=True)
|
|
return None
|
|
|
|
def place_lecture(self, offering, room_candidates, day_order):
|
|
"""Dispatches to the long (120-min) or normal (90-min) lecture placement based on the course's lecture_duration_minutes."""
|
|
if offering["course"]["lecture_duration_minutes"] == 120:
|
|
return self.place_long_lecture(offering, room_candidates, day_order)
|
|
return self.place_theory_session(offering, room_candidates, day_order)
|
|
|
|
def place_lab(self, offering, room_candidates, day_order):
|
|
"""Finds and locks a free 180-minute lab double-slot session; returns the session dict or None."""
|
|
for day in day_order:
|
|
for slot_a, slot_b in grid.LAB_SLOT_PAIRS:
|
|
for room in room_candidates:
|
|
if self.is_free(day, slot_a, room["room_id"], offering["instructor_code"], offering["batch_codes"]) and \
|
|
self.is_free(day, slot_b, room["room_id"], offering["instructor_code"], offering["batch_codes"]):
|
|
self.mark_busy(day, slot_a, room["room_id"], offering["instructor_code"], offering["batch_codes"])
|
|
self.mark_busy(day, slot_b, room["room_id"], offering["instructor_code"], offering["batch_codes"])
|
|
return self.add_session(offering, day, [slot_a, slot_b], room)
|
|
return None
|