initial project
This commit is contained in:
19
controller/conflict_checker/__init__.py
Normal file
19
controller/conflict_checker/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from .student_conflict_checker import check_student_conflicts
|
||||
from .teacher_conflict_checker import check_teacher_conflicts
|
||||
|
||||
__all__ = ["check_student_conflicts", "check_teacher_conflicts", "check_all_conflicts"]
|
||||
|
||||
|
||||
def check_all_conflicts(sessions):
|
||||
"""
|
||||
Runs every conflict check against the given list of scheduled sessions
|
||||
and returns a combined report:
|
||||
{"student_conflicts": [...], "teacher_conflicts": [...], "has_conflicts": bool}
|
||||
"""
|
||||
student_conflicts = check_student_conflicts(sessions)
|
||||
teacher_conflicts = check_teacher_conflicts(sessions)
|
||||
return {
|
||||
"student_conflicts": student_conflicts,
|
||||
"teacher_conflicts": teacher_conflicts,
|
||||
"has_conflicts": bool(student_conflicts or teacher_conflicts),
|
||||
}
|
||||
63
controller/conflict_checker/student_conflict_checker.py
Normal file
63
controller/conflict_checker/student_conflict_checker.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Verifies that no batch (and therefore no student in that batch) is booked
|
||||
into two courses — lecture or lab — at the same day+slot.
|
||||
|
||||
This is an independent verification pass over the scheduler's output: it
|
||||
does not trust the scheduler's own busy-maps, it re-derives every batch's
|
||||
occupied (day, slot_index) cells directly from the session list and flags
|
||||
any cell used by more than one session.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def check_student_conflicts(sessions):
|
||||
"""
|
||||
Returns a list of conflict dicts, one per (batch, day, slot) where two
|
||||
or more sessions overlap for that batch:
|
||||
{
|
||||
"batch_code": str,
|
||||
"day": str,
|
||||
"slot_index": int,
|
||||
"sessions": [session, session, ...],
|
||||
}
|
||||
Sessions that span multiple slot_indices (labs) occupy every one of
|
||||
their slot_indices, so a lab overlapping a lecture in either of its two
|
||||
slots is caught too.
|
||||
"""
|
||||
# (batch_code, day, slot_index) -> list of sessions occupying that cell
|
||||
occupied = defaultdict(list)
|
||||
|
||||
for session in sessions:
|
||||
day = session["day"]
|
||||
for slot_index in session["slot_indices"]:
|
||||
for batch_code in session["batch_codes"]:
|
||||
occupied[(batch_code, day, slot_index)].append(session)
|
||||
|
||||
conflicts = []
|
||||
for (batch_code, day, slot_index), cell_sessions in occupied.items():
|
||||
if len(cell_sessions) > 1:
|
||||
conflicts.append({
|
||||
"batch_code": batch_code,
|
||||
"day": day,
|
||||
"slot_index": slot_index,
|
||||
"sessions": cell_sessions,
|
||||
})
|
||||
|
||||
conflicts.sort(key=lambda c: (c["batch_code"], c["day"], c["slot_index"]))
|
||||
return conflicts
|
||||
|
||||
|
||||
def format_student_conflicts(conflicts):
|
||||
"""Human-readable lines describing each student/batch conflict."""
|
||||
lines = []
|
||||
for c in conflicts:
|
||||
course_list = ", ".join(
|
||||
f'{s["course_id"]} ({s["course_type"]}) with {s["instructor_name"]} in {s["room_id"]}'
|
||||
for s in c["sessions"]
|
||||
)
|
||||
lines.append(
|
||||
f'Batch {c["batch_code"]} has {len(c["sessions"])} overlapping courses on '
|
||||
f'{c["day"]} slot {c["slot_index"]}: {course_list}'
|
||||
)
|
||||
return lines
|
||||
59
controller/conflict_checker/teacher_conflict_checker.py
Normal file
59
controller/conflict_checker/teacher_conflict_checker.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Verifies that no instructor is booked into two sessions at the same
|
||||
day+slot (whether both are lectures, both labs, or a mix of either).
|
||||
|
||||
Like student_conflict_checker, this re-derives occupancy directly from the
|
||||
session list rather than trusting the scheduler's internal busy-maps.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def check_teacher_conflicts(sessions):
|
||||
"""
|
||||
Returns a list of conflict dicts, one per (instructor_code, day, slot)
|
||||
where two or more sessions overlap for that instructor:
|
||||
{
|
||||
"instructor_code": str,
|
||||
"day": str,
|
||||
"slot_index": int,
|
||||
"sessions": [session, session, ...],
|
||||
}
|
||||
"""
|
||||
# (instructor_code, day, slot_index) -> list of sessions occupying that cell
|
||||
occupied = defaultdict(list)
|
||||
|
||||
for session in sessions:
|
||||
day = session["day"]
|
||||
instructor_code = session["instructor_code"]
|
||||
for slot_index in session["slot_indices"]:
|
||||
occupied[(instructor_code, day, slot_index)].append(session)
|
||||
|
||||
conflicts = []
|
||||
for (instructor_code, day, slot_index), cell_sessions in occupied.items():
|
||||
if len(cell_sessions) > 1:
|
||||
conflicts.append({
|
||||
"instructor_code": instructor_code,
|
||||
"day": day,
|
||||
"slot_index": slot_index,
|
||||
"sessions": cell_sessions,
|
||||
})
|
||||
|
||||
conflicts.sort(key=lambda c: (c["instructor_code"], c["day"], c["slot_index"]))
|
||||
return conflicts
|
||||
|
||||
|
||||
def format_teacher_conflicts(conflicts):
|
||||
"""Human-readable lines describing each teacher conflict."""
|
||||
lines = []
|
||||
for c in conflicts:
|
||||
course_list = ", ".join(
|
||||
f'{s["course_id"]} ({s["course_type"]}) for batch(es) {"+".join(s["batch_codes"])} in {s["room_id"]}'
|
||||
for s in c["sessions"]
|
||||
)
|
||||
instructor_name = c["sessions"][0]["instructor_name"]
|
||||
lines.append(
|
||||
f'Instructor {instructor_name} ({c["instructor_code"]}) is double-booked on '
|
||||
f'{c["day"]} slot {c["slot_index"]}: {course_list}'
|
||||
)
|
||||
return lines
|
||||
206
controller/template_controller.py
Normal file
206
controller/template_controller.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Loads dummy CSV data and builds a clash-free schedule of sessions that all
|
||||
five templates/*-wise template modules render from.
|
||||
|
||||
Scheduling rules (dummy/demo scheduler, not a full CSP solver):
|
||||
1. General courses (shared across every batch of a session) are placed
|
||||
first, only on Tuesday, so those slots are locked in before anything
|
||||
else and never clash across the batches sharing them.
|
||||
2. Lab courses (180-minute / double-slot) are placed next, in lab rooms
|
||||
only, on whichever day/slot-pair is free for the batch, instructor,
|
||||
and room.
|
||||
3. Remaining theory courses fill the rest of the week (Mon/Tue/Wed/Thu/Fri),
|
||||
one 90-minute slot at a time, avoiding room / instructor / batch clashes.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
courses_by_id = {c["course_id"]: c for c in courses}
|
||||
instructors_by_code = {i["instructor_code"]: i for i in instructors}
|
||||
batches_by_code = {b["batch_code"]: b for b in batches}
|
||||
|
||||
# normalize offerings: expand "batch1|batch2" general offerings into a
|
||||
# list of batch codes on each offering record
|
||||
for off in offerings:
|
||||
off["batch_codes"] = off["batch_code"].split("|")
|
||||
off["is_general"] = courses_by_id[off["course_id"]]["is_general"].lower() == "yes"
|
||||
off["course_type"] = courses_by_id[off["course_id"]]["course_type"]
|
||||
|
||||
return {
|
||||
"rooms": rooms,
|
||||
"blocks": blocks,
|
||||
"instructors": instructors,
|
||||
"instructors_by_code": instructors_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 _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 _add_session(self, offering, day, slot_indices, room):
|
||||
course = self.data["courses_by_id"][offering["course_id"]]
|
||||
start = TIME_SLOTS[slot_indices[0]]["start"]
|
||||
end = TIME_SLOTS[slot_indices[-1]]["end"]
|
||||
self.sessions.append({
|
||||
"day": day,
|
||||
"slot_indices": slot_indices,
|
||||
"start": start,
|
||||
"end": end,
|
||||
"course_id": offering["course_id"],
|
||||
"course_name": course["course_name"],
|
||||
"course_type": course["course_type"],
|
||||
"is_general": offering["is_general"],
|
||||
"instructor_code": offering["instructor_code"],
|
||||
"instructor_name": self.data["instructors_by_code"][offering["instructor_code"]]["instructor_name"],
|
||||
"room_id": room["room_id"],
|
||||
"room_name": room["room_name"],
|
||||
"batch_codes": offering["batch_codes"],
|
||||
})
|
||||
|
||||
def schedule_general_courses(self):
|
||||
day = "Tuesday"
|
||||
general = [o for o in self.data["offerings"] if o["is_general"]]
|
||||
for offering in general:
|
||||
placed = False
|
||||
for slot_index in TEACHING_SLOT_INDICES:
|
||||
for room in self.classrooms:
|
||||
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"])
|
||||
self._add_session(offering, day, [slot_index], room)
|
||||
placed = True
|
||||
break
|
||||
if placed:
|
||||
break
|
||||
if not placed:
|
||||
print(f"WARNING: could not place general course {offering['course_id']}")
|
||||
|
||||
def schedule_lab_courses(self):
|
||||
labs_offerings = [o for o in self.data["offerings"] if o["course_type"] == "lab" and not o["is_general"]]
|
||||
for offering in labs_offerings:
|
||||
placed = False
|
||||
for day in DAYS:
|
||||
for slot_a, slot_b in LAB_SLOT_PAIRS:
|
||||
for room in self.labs:
|
||||
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 lab course {offering['course_id']} for {offering['batch_codes']}")
|
||||
|
||||
def schedule_theory_courses(self):
|
||||
theory_offerings = [o for o in self.data["offerings"] if o["course_type"] == "theory" and not o["is_general"]]
|
||||
for offering in theory_offerings:
|
||||
placed = False
|
||||
for day in DAYS:
|
||||
for slot_index in TEACHING_SLOT_INDICES:
|
||||
for room in self.classrooms:
|
||||
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"])
|
||||
self._add_session(offering, day, [slot_index], room)
|
||||
placed = True
|
||||
break
|
||||
if placed:
|
||||
break
|
||||
if placed:
|
||||
break
|
||||
if not placed:
|
||||
print(f"WARNING: could not place theory course {offering['course_id']} for {offering['batch_codes']}")
|
||||
|
||||
def run(self):
|
||||
self.schedule_general_courses()
|
||||
self.schedule_lab_courses()
|
||||
self.schedule_theory_courses()
|
||||
return self.sessions
|
||||
|
||||
|
||||
def build_schedule():
|
||||
data = load_data()
|
||||
scheduler = Scheduler(data)
|
||||
sessions = scheduler.run()
|
||||
return data, sessions
|
||||
Reference in New Issue
Block a user