phases initial

This commit is contained in:
Talhadeveloperr
2026-09-09 18:01:05 +05:00
parent aa3faf674f
commit 1e300d1700
51 changed files with 177 additions and 77 deletions

View File

@@ -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