""" Loads dummy CSV data and builds a clash-free schedule of sessions that all five templates/*-wise template modules render from. Scheduling is done in three sequential, independently-verified phases (see app/template_generator.py for the orchestration + per-phase conflict checks). Each phase's placements are locked before the next phase starts: once a (day, slot, room/instructor/batch) combination is marked busy by an earlier phase, later phases can never reuse it. 1. General courses (shared across every batch of a session), preferring Tuesday/Thursday first. Each offering gets `lectures_per_week` sessions. If two general courses still land in the same slot for a batch, the losing session is moved to another day/slot until this phase is clash-free (see `phase_1_general_courses`). 2. Labs: each offering gets `labs_per_week` 180-minute / double-slot sessions, in lab rooms only, scheduled against Phase 1's fixed placements plus labs placed so far this phase. 3. Remaining (non-general) theory sessions, scheduled batch by batch in sorted batch_code order: for each batch, every one of its remaining offerings is placed and locked before the next batch is considered. Room selection always tries a course's preferred lecture_room_id / lab_room_id first, falling back to another same-type room only if the preferred one is busy for that day/slot. """ import csv import os DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "dummy") DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] # Theory grid: 5 x 90-minute slots with a lunch break, 08:30-16:30 TIME_SLOTS = [ {"label": "S1", "start": "08:30", "end": "10:00", "is_lunch": False}, {"label": "S2", "start": "10:00", "end": "11:30", "is_lunch": False}, {"label": "S3", "start": "11:30", "end": "13:00", "is_lunch": False}, {"label": "LUNCH", "start": "13:00", "end": "13:30", "is_lunch": True}, {"label": "S4", "start": "13:30", "end": "15:00", "is_lunch": False}, {"label": "S5", "start": "15:00", "end": "16:30", "is_lunch": False}, ] # Indices into TIME_SLOTS that are real teaching slots (skip lunch) TEACHING_SLOT_INDICES = [i for i, s in enumerate(TIME_SLOTS) if not s["is_lunch"]] # Consecutive pairs of teaching slots usable as a 180-minute lab double-slot LAB_SLOT_PAIRS = [ (a, b) for a, b in zip(TEACHING_SLOT_INDICES, TEACHING_SLOT_INDICES[1:]) if TIME_SLOTS[b]["start"] == TIME_SLOTS[a]["end"] ] def _read_csv(relative_path): path = os.path.join(DATA_DIR, relative_path) with open(path, newline="", encoding="utf-8") as f: return list(csv.DictReader(f)) def load_data(): """Loads all dummy CSVs into plain dict/list structures.""" rooms = _read_csv(os.path.join("blocks", "rooms.csv")) blocks = _read_csv(os.path.join("blocks", "blocks.csv")) instructors = _read_csv(os.path.join("instructor", "instructors.csv")) courses = _read_csv(os.path.join("courses", "courses.csv")) offerings = _read_csv(os.path.join("courses", "course_offering.csv")) students = _read_csv(os.path.join("roll_no", "students.csv")) batches_25 = _read_csv(os.path.join("batch_codes", "25", "batches.csv")) batches_26 = _read_csv(os.path.join("batch_codes", "26", "batches.csv")) batches = batches_25 + batches_26 rooms_by_id = {r["room_id"]: r for r in rooms} instructor_names_by_code = {i["instructor_code"]: i["instructor_name"] for i in instructors} batches_by_code = {b["batch_code"]: b for b in batches} # normalize courses: numeric fields + a lookup by id courses_by_id = {} for c in courses: c["credit_hours"] = int(c["credit_hours"]) c["lectures_per_week"] = int(c["lectures_per_week"]) c["labs_per_week"] = int(c["labs_per_week"]) c["is_general"] = c["is_general"].lower() == "yes" courses_by_id[c["course_id"]] = c # normalize offerings: expand "batch1|batch2" section batch groups into # a list of batch codes, and attach the course record for convenience for off in offerings: off["batch_codes"] = off["batch_codes"].split("|") off["course"] = courses_by_id[off["course_id"]] off["instructor_name"] = instructor_names_by_code.get(off["instructor_code"], off["instructor_code"]) return { "rooms": rooms, "rooms_by_id": rooms_by_id, "blocks": blocks, "instructors": instructors, "instructor_names_by_code": instructor_names_by_code, "courses": courses, "courses_by_id": courses_by_id, "offerings": offerings, "students": students, "batches": batches, "batches_by_code": batches_by_code, } class Scheduler: """Greedy clash-free scheduler over rooms / instructors / batches.""" 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 = [] def to_state(self): """ Serializes this scheduler's accumulated sessions and busy-maps to a JSON-able dict, so a later process can resume exactly where this one left off (used to run each phase as a separate CLI command). """ 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 Scheduler 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 scheduler = cls(data) scheduler.sessions = list(state["sessions"]) scheduler.room_busy = _load_busy(state["room_busy"]) scheduler.instructor_busy = _load_busy(state["instructor_busy"]) scheduler.batch_busy = _load_busy(state["batch_busy"]) return scheduler 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 def _add_session(self, offering, day, slot_indices, room): course = offering["course"] start = TIME_SLOTS[slot_indices[0]]["start"] end = TIME_SLOTS[slot_indices[-1]]["end"] session_type = "lab" if len(slot_indices) > 1 else "theory" session = { "day": day, "slot_indices": slot_indices, "start": start, "end": end, "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 _place_theory_session(self, offering, room_candidates, day_order): """Finds and locks a free single-slot session; returns the session dict or None.""" for day in day_order: for slot_index in 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 phase_1_general_courses(self, preferred_days=("Tuesday", "Thursday"), max_repair_attempts=20): """ Places every general-course offering's weekly lectures, preferring `preferred_days` first. Afterwards, self-repairs: any batch left with two general-course sessions in the same slot has the losing session unmarked and re-placed (scanning every day) until this phase's own sessions are clash-free. Returns this phase's sessions. """ general = [o for o in self.data["offerings"] if o["course"]["is_general"]] preferred_order = list(preferred_days) + [d for d in DAYS if d not in preferred_days] phase_start = len(self.sessions) for offering in general: course = offering["course"] room_candidates = self._room_candidates(course["lecture_room_id"], self.classrooms) for _ in range(course["lectures_per_week"]): session = self._place_theory_session(offering, room_candidates, preferred_order) if session is None: print(f"WARNING: could not place a lecture for general course {offering['course_id']} section {offering['section_id']}") phase_sessions = self.sessions[phase_start:] self._repair_general_conflicts(phase_sessions, preferred_order, max_repair_attempts) return phase_sessions def _repair_general_conflicts(self, phase_sessions, day_order, max_attempts): """Moves general-course sessions that clash for a batch to a free slot on any day.""" from conflict_checker.student_conflict_checker import check_student_conflicts any_day_order = list(DAYS) for _ in range(max_attempts): conflicts = check_student_conflicts(phase_sessions) if not conflicts: return # Re-place the last session involved in the first conflict; the # other session(s) in that cell stay put. losing_session = conflicts[0]["sessions"][-1] offering = next( o for o in self.data["offerings"] if o["course_id"] == losing_session["course_id"] and o["section_id"] == losing_session["section_id"] ) self._unmark_busy( losing_session["day"], losing_session["slot_indices"][0], losing_session["room_id"], losing_session["instructor_code"], losing_session["batch_codes"], ) self.sessions.remove(losing_session) phase_sessions.remove(losing_session) room_candidates = self._room_candidates(offering["course"]["lecture_room_id"], self.classrooms) new_session = self._place_theory_session(offering, room_candidates, any_day_order) if new_session is None: print(f"WARNING: could not repair general-course conflict for {offering['course_id']} section {offering['section_id']}") return phase_sessions.append(new_session) print("WARNING: general-course conflicts remained unresolved after max repair attempts") def phase_2_labs(self): """ Places every lab offering's weekly labs into lab rooms, treating Phase 1's placements (already marked busy) as fixed. Returns this phase's sessions. """ phase_start = len(self.sessions) offerings = [o for o in self.data["offerings"] if not o["course"]["is_general"] and o["course"]["labs_per_week"] > 0] for offering in offerings: course = offering["course"] room_candidates = self._room_candidates(course["lab_room_id"], self.labs) for _ in range(course["labs_per_week"]): placed = False for day in DAYS: for slot_a, slot_b in 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"]) self._add_session(offering, day, [slot_a, slot_b], room) placed = True break if placed: break if placed: break if not placed: print(f"WARNING: could not place a lab for course {offering['course_id']} section {offering['section_id']} ({offering['batch_codes']})") return self.sessions[phase_start:] def phase_3_remaining_courses(self): """ Places every remaining (non-general) theory offering's weekly lectures, batch by batch in sorted batch_code order: all of one batch's offerings are placed and locked before the next batch is considered. Treats Phases 1+2 (already marked busy) as fixed. Returns this phase's sessions. """ phase_start = len(self.sessions) offerings = [o for o in self.data["offerings"] if not o["course"]["is_general"]] for batch in sorted(self.data["batches"], key=lambda b: b["batch_code"]): batch_code = batch["batch_code"] batch_offerings = [o for o in offerings if batch_code in o["batch_codes"]] for offering in batch_offerings: course = offering["course"] room_candidates = self._room_candidates(course["lecture_room_id"], self.classrooms) for _ in range(course["lectures_per_week"]): session = self._place_theory_session(offering, room_candidates, DAYS) if session is None: print(f"WARNING: could not place a lecture for course {offering['course_id']} section {offering['section_id']} ({offering['batch_codes']})") return self.sessions[phase_start:] def run(self): self.phase_1_general_courses() self.phase_2_labs() self.phase_3_remaining_courses() return self.sessions def build_schedule(): data = load_data() scheduler = Scheduler(data) sessions = scheduler.run() return data, sessions