""" 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