""" Loads dummy CSV data and exposes a thin Scheduler facade over the solver engine in controller/solver/ (grid constants, PlacementBoard, and one module per phase — phase1_general, phase2_labs, phase3_remaining). Scheduling is done in three sequential, independently-verified phases, each with its own tailored strategy (see controller/solver/phase*_*.py for the per-phase logic). 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 — except Phase 3, which is allowed to backtrack and reopen *its own* earlier batches (never Phase 1 or Phase 2) when a later batch can't otherwise be placed. 1. General courses (shared across every batch of a session), preferring Tuesday/Thursday first, self-repaired until clash-free. 2. Labs, scheduled against Phase 1's fixed placements, then self-repaired for lab-vs-lab clashes within Phase 2 only. 3. Remaining (non-general) theory sessions, scheduled batch by batch in sorted batch_code order, with backtracking into earlier Phase 3 batches when a later batch can't otherwise fit. Time grid: 5 normal 90-minute slots, 09:00-16:30, no lunch break. Labs use 180-minute double-slots (09:00-12:00 and 12:00-15:00). A course whose lecture_duration_minutes is 120 ("2 hours once a week") is instead placed in a special fixed 08:30-10:30 slot, which overlaps normal slot 0 (09:00-10:30) in wall-clock time and is tracked as clashing with it. 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 from solver.grid import ( # noqa: F401 (re-exported for templates/backward compatibility) DAYS, TIME_SLOTS, TEACHING_SLOT_INDICES, LAB_SLOT_PAIRS, LONG_LECTURE_SLOT_INDEX, LONG_LECTURE_START, LONG_LECTURE_END, DISPLAY_ROWS, ) from solver.placement_board import PlacementBoard from solver import phase1_general, phase2_labs, phase3_remaining DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "dummy") 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 _to_int(value, default=0): try: return int(float(value)) except (TypeError, ValueError): return default def load_data(): """ Loads the real UORM CSV data into plain dict/list structures. courses/courses.csv is the full course catalog in the university's wide export format (Department_Name, Program_Name, Course_Code, T_Contact_Hours, P_Contact_Hours, ...). courses/course_offering.csv is a curated subset of that catalog — only rows with an instructor assigned there are schedulable. Course rows are looked up by Course_Code (e.g. "BBA-300"), which is what course_offering.csv's course_id column refers to. """ 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: derive scheduler fields from the real catalog's # contact-hour columns, and key by Course_Code (what offerings reference). courses_by_id = {} for c in courses: course_code = c["Course_Code"].strip() t_contact_hours = _to_int(c.get("T_Contact_Hours")) p_contact_hours = _to_int(c.get("P_Contact_Hours")) courses_by_id[course_code] = { "course_id": course_code, "course_name": c["Course_Title"].strip(), "department": c["Department_Name"], "program": c["Program_Name"], "credit_hours": _to_int(c.get("Total_Credit_Hours")), # One 90-minute lecture slot per weekly theory contact hour; # no reliable "general" flag exists in this data, so every # course is treated as non-general (see solver/phase1_general.py). "lectures_per_week": t_contact_hours, # A course with any practical/lab contact hours gets exactly # one 180-minute lab session/week (never more), per spec. "labs_per_week": 1 if p_contact_hours > 0 else 0, "is_general": False, "lecture_duration_minutes": 90, # No per-course room assignment in this data yet; leaving # these unset makes room_candidates() fall back to the full # room pool with no preference (handled by PlacementBoard). "lecture_room_id": None, "lab_room_id": None, } # normalize offerings: expand "batch1|batch2" section batch groups into # a list of batch codes (kept for forward compatibility even though # this real data has one batch per offering), and attach the course # record for convenience. Offerings whose course_id has no matching # catalog row are skipped (curated subset referencing a typo/removed # course) rather than crashing the whole load. normalized_offerings = [] for off in offerings: course = courses_by_id.get(off["course_id"]) if course is None: print(f"WARNING: course_offering.csv references unknown course_id '{off['course_id']}' — skipping") continue off["batch_codes"] = off["batch_codes"].split("|") off["course"] = course off["instructor_name"] = instructor_names_by_code.get(off["instructor_code"], off["instructor_code"]) normalized_offerings.append(off) 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": normalized_offerings, "students": students, "batches": batches, "batches_by_code": batches_by_code, } class Scheduler: """ Thin facade over the solver engine: owns a PlacementBoard and delegates each phase to its dedicated solver/phase*_*.py module. Keeps the same public surface the rest of the app already relies on (app/template_generator.py, controller/conflict_checker/*). """ def __init__(self, data): self.data = data self.board = PlacementBoard(data) @property def sessions(self): return self.board.sessions def to_state(self): return self.board.to_state() @classmethod def from_state(cls, data, state): scheduler = cls(data) scheduler.board = PlacementBoard.from_state(data, state) return scheduler def phase_1_general_courses(self): return phase1_general.run(self.board, self.data) def phase_2_labs(self): return phase2_labs.run(self.board, self.data) def phase_3_remaining_courses(self): return phase3_remaining.run(self.board, self.data) def run(self): self.phase_1_general_courses() self.phase_2_labs() self.phase_3_remaining_courses() return self.board.sessions def build_schedule(): data = load_data() scheduler = Scheduler(data) sessions = scheduler.run() return data, sessions