Files
timetable/controller/solver/placement_board.py
Talhadeveloperr 9079ba2739 succesfull testing
2026-09-09 18:55:53 +05:00

172 lines
8.0 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
class PlacementBoard:
def __init__(self, data):
self.data = data
self.classrooms = [r for r in data["rooms"] if r["room_type"] == "classroom"]
self.labs = [r for r in data["rooms"] if r["room_type"] == "lab"]
# 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