phases initial
This commit is contained in:
@@ -22,11 +22,11 @@ sys.path.insert(0, os.path.join(ROOT_DIR, "templates", "room-wise"))
|
||||
sys.path.insert(0, os.path.join(ROOT_DIR, "templates", "student-wise"))
|
||||
sys.path.insert(0, os.path.join(ROOT_DIR, "templates", "teacher-wise"))
|
||||
|
||||
from template_controller import build_schedule # noqa: E402
|
||||
from template_controller import load_data, Scheduler # noqa: E402
|
||||
from conflict_checker import check_all_conflicts # noqa: E402
|
||||
from conflict_checker.student_conflict_checker import format_student_conflicts # noqa: E402
|
||||
from conflict_checker.teacher_conflict_checker import format_teacher_conflicts # noqa: E402
|
||||
from template_logger import reset_log, log_error, log_info # noqa: E402
|
||||
from template_logger import reset_log, log_error, log_info, log_warning # noqa: E402
|
||||
|
||||
from batch_wise_template import generate_batch_wise_templates # noqa: E402
|
||||
from department_wise_template import generate_department_wise_templates # noqa: E402
|
||||
@@ -38,6 +38,23 @@ RESULT_DIR = os.path.join(ROOT_DIR, "result", "templates")
|
||||
VIEW_NAMES = ["batch-wise", "department-wise", "room-wise", "student-wise", "teacher-wise"]
|
||||
|
||||
|
||||
def _check_and_log(sessions, label):
|
||||
"""Runs student + teacher conflict checks over `sessions` and logs the result."""
|
||||
report = check_all_conflicts(sessions)
|
||||
if report["has_conflicts"]:
|
||||
for line in format_student_conflicts(report["student_conflicts"]):
|
||||
log_error(f"[{label}] STUDENT CONFLICT: {line}")
|
||||
for line in format_teacher_conflicts(report["teacher_conflicts"]):
|
||||
log_error(f"[{label}] TEACHER CONFLICT: {line}")
|
||||
log_warning(
|
||||
f"[{label}] Found {len(report['student_conflicts'])} student conflict(s) and "
|
||||
f"{len(report['teacher_conflicts'])} teacher conflict(s)."
|
||||
)
|
||||
else:
|
||||
log_info(f"[{label}] No conflicts found — clash-free.")
|
||||
return report
|
||||
|
||||
|
||||
def clear_result_dir():
|
||||
"""Removes every previous run's output so stale files never linger."""
|
||||
for view_name in VIEW_NAMES:
|
||||
@@ -59,23 +76,32 @@ def main():
|
||||
generate_dummy_data()
|
||||
print()
|
||||
|
||||
print("Loading data and building clash-free schedule...")
|
||||
data, sessions = build_schedule()
|
||||
print(f"Scheduled {len(sessions)} sessions.\n")
|
||||
log_info("Loading data...")
|
||||
data = load_data()
|
||||
scheduler = Scheduler(data)
|
||||
|
||||
log_info("Running conflict checks (student/batch and teacher)...")
|
||||
report = check_all_conflicts(sessions)
|
||||
if report["has_conflicts"]:
|
||||
for line in format_student_conflicts(report["student_conflicts"]):
|
||||
log_error(f"STUDENT CONFLICT: {line}")
|
||||
for line in format_teacher_conflicts(report["teacher_conflicts"]):
|
||||
log_error(f"TEACHER CONFLICT: {line}")
|
||||
log_error(
|
||||
f"Found {len(report['student_conflicts'])} student conflict(s) and "
|
||||
f"{len(report['teacher_conflicts'])} teacher conflict(s)."
|
||||
)
|
||||
else:
|
||||
log_info("No conflicts found — schedule is clash-free for students and teachers.")
|
||||
# --- Phase 1: general courses (Tue/Thu preferred), self-repaired until clash-free ---
|
||||
log_info("Phase 1/3: scheduling general courses...")
|
||||
phase1_sessions = scheduler.phase_1_general_courses()
|
||||
_check_and_log(phase1_sessions, "Phase 1 (general courses)")
|
||||
|
||||
# --- Phase 2: labs, scheduled against Phase 1's fixed placements ---
|
||||
log_info("Phase 2/3: scheduling labs...")
|
||||
phase2_sessions = scheduler.phase_2_labs()
|
||||
_check_and_log(phase1_sessions + phase2_sessions, "Phase 2 (general courses + labs)")
|
||||
|
||||
# --- Phase 3: remaining theory courses, batch by batch ---
|
||||
log_info("Phase 3/3: scheduling remaining courses (batch by batch)...")
|
||||
phase3_sessions = scheduler.phase_3_remaining_courses()
|
||||
|
||||
sessions = phase1_sessions + phase2_sessions + phase3_sessions
|
||||
log_info(
|
||||
f"Scheduling complete: {len(phase1_sessions)} general, {len(phase2_sessions)} lab, "
|
||||
f"{len(phase3_sessions)} remaining = {len(sessions)} total session(s)."
|
||||
)
|
||||
|
||||
log_info("Running final conflict check (student/batch and teacher) over the full schedule...")
|
||||
_check_and_log(sessions, "Final (full schedule)")
|
||||
|
||||
generators = [
|
||||
("batch-wise", generate_batch_wise_templates),
|
||||
|
||||
@@ -2,17 +2,23 @@
|
||||
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. Each general
|
||||
offering gets `lectures_per_week` sessions.
|
||||
2. Lab courses are placed next: each offering gets `labs_per_week`
|
||||
180-minute / double-slot sessions, in lab rooms only, on whichever
|
||||
day/slot-pair is free for the batch, instructor, and room.
|
||||
3. Remaining theory sessions (`lectures_per_week` per non-general
|
||||
offering) fill the rest of the week (Mon/Tue/Wed/Thu/Fri), one
|
||||
90-minute slot at a time, avoiding room / instructor / batch clashes.
|
||||
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
|
||||
@@ -131,6 +137,13 @@ class Scheduler:
|
||||
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)
|
||||
@@ -142,7 +155,7 @@ class Scheduler:
|
||||
start = TIME_SLOTS[slot_indices[0]]["start"]
|
||||
end = TIME_SLOTS[slot_indices[-1]]["end"]
|
||||
session_type = "lab" if len(slot_indices) > 1 else "theory"
|
||||
self.sessions.append({
|
||||
session = {
|
||||
"day": day,
|
||||
"slot_indices": slot_indices,
|
||||
"start": start,
|
||||
@@ -157,34 +170,84 @@ class Scheduler:
|
||||
"room_id": room["room_id"],
|
||||
"room_name": room["room_name"],
|
||||
"batch_codes": offering["batch_codes"],
|
||||
})
|
||||
}
|
||||
self.sessions.append(session)
|
||||
return session
|
||||
|
||||
def schedule_general_courses(self):
|
||||
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"]):
|
||||
placed = False
|
||||
# Tuesday first, then spill to the rest of the week if a
|
||||
# session can't fit there.
|
||||
for day in ["Tuesday"] + [d for d in DAYS if d != "Tuesday"]:
|
||||
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"])
|
||||
self._add_session(offering, day, [slot_index], room)
|
||||
placed = True
|
||||
break
|
||||
if placed:
|
||||
break
|
||||
if placed:
|
||||
break
|
||||
if not placed:
|
||||
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']}")
|
||||
|
||||
def schedule_lab_courses(self):
|
||||
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"]
|
||||
@@ -209,33 +272,37 @@ class Scheduler:
|
||||
if not placed:
|
||||
print(f"WARNING: could not place a lab for course {offering['course_id']} section {offering['section_id']} ({offering['batch_codes']})")
|
||||
|
||||
def schedule_theory_courses(self):
|
||||
offerings = [o for o in self.data["offerings"] if not o["course"]["is_general"]]
|
||||
for offering in offerings:
|
||||
course = offering["course"]
|
||||
room_candidates = self._room_candidates(course["lecture_room_id"], self.classrooms)
|
||||
return self.sessions[phase_start:]
|
||||
|
||||
for _ in range(course["lectures_per_week"]):
|
||||
placed = False
|
||||
for day in DAYS:
|
||||
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"])
|
||||
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 a lecture for course {offering['course_id']} section {offering['section_id']} ({offering['batch_codes']})")
|
||||
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.schedule_general_courses()
|
||||
self.schedule_lab_courses()
|
||||
self.schedule_theory_courses()
|
||||
self.phase_1_general_courses()
|
||||
self.phase_2_labs()
|
||||
self.phase_3_remaining_courses()
|
||||
return self.sessions
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
Timetable generation log - started 2026-09-09T17:48:16.013663
|
||||
Timetable generation log - started 2026-09-09T17:59:37.068314
|
||||
================================================================================
|
||||
[17:48:16] INFO: Running conflict checks (student/batch and teacher)...
|
||||
[17:48:16] INFO: No conflicts found — schedule is clash-free for students and teachers.
|
||||
[17:59:37] INFO: Loading data...
|
||||
[17:59:37] INFO: Phase 1/3: scheduling general courses...
|
||||
[17:59:37] INFO: [Phase 1 (general courses)] No conflicts found — clash-free.
|
||||
[17:59:37] INFO: Phase 2/3: scheduling labs...
|
||||
[17:59:37] INFO: [Phase 2 (general courses + labs)] No conflicts found — clash-free.
|
||||
[17:59:37] INFO: Phase 3/3: scheduling remaining courses (batch by batch)...
|
||||
[17:59:37] INFO: Scheduling complete: 6 general, 10 lab, 20 remaining = 36 total session(s).
|
||||
[17:59:37] INFO: Running final conflict check (student/batch and teacher) over the full schedule...
|
||||
[17:59:37] INFO: [Final (full schedule)] No conflicts found — clash-free.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user