192 lines
7.8 KiB
Python
192 lines
7.8 KiB
Python
"""
|
|
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
|