succesfull testing
This commit is contained in:
0
controller/solver/__init__.py
Normal file
0
controller/solver/__init__.py
Normal file
54
controller/solver/grid.py
Normal file
54
controller/solver/grid.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Time-grid constants: days, slot definitions, lab double-slot pairs, and
|
||||||
|
the special long-lecture slot. Pure data — no scheduling logic lives here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
|
||||||
|
|
||||||
|
# Normal theory grid: 5 x 90-minute slots, 09:00-16:30, no lunch break.
|
||||||
|
# Every placeable session (normal lecture, lab, or the special long
|
||||||
|
# lecture) is expressed as one or more of these slot indices (0-4) for
|
||||||
|
# clash-tracking purposes — see LONG_LECTURE_SLOT_INDEX below for why the
|
||||||
|
# long lecture also uses index 0 despite rendering at a different clock time.
|
||||||
|
TIME_SLOTS = [
|
||||||
|
{"label": "S1", "start": "09:00", "end": "10:30", "is_lunch": False},
|
||||||
|
{"label": "S2", "start": "10:30", "end": "12:00", "is_lunch": False},
|
||||||
|
{"label": "S3", "start": "12:00", "end": "13:30", "is_lunch": False},
|
||||||
|
{"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 (no lunch to skip
|
||||||
|
# anymore, but kept for readability at call sites)
|
||||||
|
TEACHING_SLOT_INDICES = [i for i, s in enumerate(TIME_SLOTS) if not s["is_lunch"]]
|
||||||
|
|
||||||
|
# Consecutive pairs of normal slots usable as a 180-minute lab double-slot:
|
||||||
|
# 10:30-13:30 (S2+S3) and 13:30-16:30 (S4+S5). Deliberately starts at slot
|
||||||
|
# 1, not slot 0 — slot 0 (09:00-10:30) is left free for normal lectures and
|
||||||
|
# the special long-lecture slot (see LONG_LECTURE_SLOT_INDEX below), so
|
||||||
|
# labs never crowd out a batch's only long-lecture opportunity for the day.
|
||||||
|
LAB_SLOT_PAIRS = [(1, 2), (3, 4)]
|
||||||
|
|
||||||
|
# The special fixed slot for courses whose lecture is "2 hours once a
|
||||||
|
# week": 08:30-10:30. This overlaps 09:00-10:30 (normal slot index 0) in
|
||||||
|
# wall-clock time, so a long-lecture session is tracked as occupying slot
|
||||||
|
# index 0 too (same key _is_free/_mark_busy use) — that's what makes it
|
||||||
|
# correctly clash with anything else placed in normal slot 0 that day for
|
||||||
|
# the same room/instructor/batch. duration_minutes on the session dict is
|
||||||
|
# what distinguishes it from a normal 90-minute slot-0 session at render time.
|
||||||
|
LONG_LECTURE_SLOT_INDEX = 0
|
||||||
|
LONG_LECTURE_START = "08:30"
|
||||||
|
LONG_LECTURE_END = "10:30"
|
||||||
|
|
||||||
|
# Rows to render on every generated timetable grid: the special long-lecture
|
||||||
|
# row first (it starts earliest, 08:30), then the 5 normal 90-minute rows.
|
||||||
|
# Templates/xlsx_grid_helper key session lookups on (day, slot_index,
|
||||||
|
# duration_minutes) using these rows' slot_index/duration_minutes pairs.
|
||||||
|
DISPLAY_ROWS = [
|
||||||
|
{"label": "Long", "start": LONG_LECTURE_START, "end": LONG_LECTURE_END,
|
||||||
|
"slot_index": LONG_LECTURE_SLOT_INDEX, "duration_minutes": 120},
|
||||||
|
] + [
|
||||||
|
{"label": s["label"], "start": s["start"], "end": s["end"],
|
||||||
|
"slot_index": i, "duration_minutes": 90}
|
||||||
|
for i, s in enumerate(TIME_SLOTS)
|
||||||
|
]
|
||||||
59
controller/solver/phase1_general.py
Normal file
59
controller/solver/phase1_general.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Phase 1: general courses (shared across every batch of a session).
|
||||||
|
|
||||||
|
Simple strategy — small offering count, Tue/Thu preferred first. After
|
||||||
|
placing everything, self-repair: 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import grid
|
||||||
|
|
||||||
|
|
||||||
|
def run(board, data, preferred_days=("Tuesday", "Thursday"), max_repair_attempts=20):
|
||||||
|
"""Places every general-course offering's weekly lectures. Returns this phase's sessions."""
|
||||||
|
general = [o for o in data["offerings"] if o["course"]["is_general"]]
|
||||||
|
preferred_order = list(preferred_days) + [d for d in grid.DAYS if d not in preferred_days]
|
||||||
|
phase_start = len(board.sessions)
|
||||||
|
|
||||||
|
for offering in general:
|
||||||
|
course = offering["course"]
|
||||||
|
room_candidates = board.room_candidates(course["lecture_room_id"], board.classrooms)
|
||||||
|
for _ in range(course["lectures_per_week"]):
|
||||||
|
session = board.place_lecture(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']}")
|
||||||
|
|
||||||
|
phase_sessions = board.sessions[phase_start:]
|
||||||
|
_repair_general_conflicts(board, data, phase_sessions, max_repair_attempts)
|
||||||
|
return phase_sessions
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_general_conflicts(board, data, phase_sessions, 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(grid.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 data["offerings"]
|
||||||
|
if o["course_id"] == losing_session["course_id"] and o["section_id"] == losing_session["section_id"]
|
||||||
|
)
|
||||||
|
board.remove_session(losing_session)
|
||||||
|
phase_sessions.remove(losing_session)
|
||||||
|
|
||||||
|
room_candidates = board.room_candidates(offering["course"]["lecture_room_id"], board.classrooms)
|
||||||
|
new_session = board.place_lecture(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")
|
||||||
116
controller/solver/phase2_labs.py
Normal file
116
controller/solver/phase2_labs.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""
|
||||||
|
Phase 2: labs.
|
||||||
|
|
||||||
|
Slightly more advanced than Phase 1 — places every lab offering's weekly
|
||||||
|
labs greedily first, against Phase 1's already-fixed placements. When a
|
||||||
|
lab can't be placed, this backtracks *recursively*: it bumps an
|
||||||
|
already-placed lab from earlier in this phase that shares the contested
|
||||||
|
resource (same instructor or same lab room), and tries to re-place the
|
||||||
|
stuck lab; if the bumped lab then can't find a new slot either, that
|
||||||
|
bumped lab is itself backtracked the same way (bump one of *its*
|
||||||
|
resource-sharing predecessors), and so on, until either everything
|
||||||
|
settles or the attempt budget is exhausted. Phase 1's sessions are never
|
||||||
|
touched — only Phase 2's own placements are ever moved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import grid
|
||||||
|
|
||||||
|
MAX_BACKTRACK_ATTEMPTS = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def run(board, data):
|
||||||
|
"""Places every lab offering's weekly labs. Returns this phase's sessions."""
|
||||||
|
phase_start = len(board.sessions)
|
||||||
|
offerings = [o for o in data["offerings"] if not o["course"]["is_general"] and o["course"]["labs_per_week"] > 0]
|
||||||
|
|
||||||
|
placed_so_far = [] # [(offering, session), ...] placed so far in this phase
|
||||||
|
attempts = [0]
|
||||||
|
|
||||||
|
for offering in offerings:
|
||||||
|
course = offering["course"]
|
||||||
|
room_candidates = board.room_candidates(course["lab_room_id"], board.labs)
|
||||||
|
for _ in range(course["labs_per_week"]):
|
||||||
|
session = _place_lab_with_backtracking(board, offering, room_candidates, placed_so_far, attempts)
|
||||||
|
if session is None:
|
||||||
|
print(f"WARNING: could not place a lab for course {offering['course_id']} section {offering['section_id']} ({offering['batch_codes']}) even after backtracking")
|
||||||
|
else:
|
||||||
|
placed_so_far.append((offering, session))
|
||||||
|
|
||||||
|
return board.sessions[phase_start:]
|
||||||
|
|
||||||
|
|
||||||
|
def _place_lab_with_backtracking(board, offering, room_candidates, placed_so_far, attempts):
|
||||||
|
"""
|
||||||
|
Tries the plain first-fit placement first; if that fails, tries
|
||||||
|
bumping each resource-sharing predecessor (most recent first) out of
|
||||||
|
the way and recursively re-seating it, backing out cleanly if a given
|
||||||
|
bump doesn't lead anywhere. Returns the newly placed session dict, or
|
||||||
|
None if no arrangement works within the attempt budget.
|
||||||
|
"""
|
||||||
|
session = board.place_lab(offering, room_candidates, grid.DAYS)
|
||||||
|
if session is not None:
|
||||||
|
return session
|
||||||
|
|
||||||
|
# Snapshot the current candidates once — recursive calls will extend
|
||||||
|
# placed_so_far with their own successful re-seatings, but we only
|
||||||
|
# want to consider *this call's* view of what existed when we started.
|
||||||
|
candidate_room_ids = {r["room_id"] for r in room_candidates}
|
||||||
|
candidates = [
|
||||||
|
(other_offering, other_session)
|
||||||
|
for other_offering, other_session in placed_so_far
|
||||||
|
if _shares_contested_resource(offering, other_session, candidate_room_ids)
|
||||||
|
]
|
||||||
|
|
||||||
|
for other_offering, other_session in reversed(candidates):
|
||||||
|
if attempts[0] >= MAX_BACKTRACK_ATTEMPTS:
|
||||||
|
return None
|
||||||
|
attempts[0] += 1
|
||||||
|
|
||||||
|
if (other_offering, other_session) not in placed_so_far:
|
||||||
|
# Already moved by an earlier (failed) branch of this search.
|
||||||
|
continue
|
||||||
|
|
||||||
|
placed_so_far.remove((other_offering, other_session))
|
||||||
|
board.remove_session(other_session)
|
||||||
|
|
||||||
|
session = board.place_lab(offering, room_candidates, grid.DAYS)
|
||||||
|
if session is not None:
|
||||||
|
other_room_candidates = board.room_candidates(other_offering["course"]["lab_room_id"], board.labs)
|
||||||
|
new_other_session = _place_lab_with_backtracking(board, other_offering, other_room_candidates, placed_so_far, attempts)
|
||||||
|
if new_other_session is not None:
|
||||||
|
placed_so_far.append((other_offering, new_other_session))
|
||||||
|
return session
|
||||||
|
# Could not reseat the bumped lab even with recursion — undo
|
||||||
|
# our placement and put the bump back exactly as it was.
|
||||||
|
board.remove_session(session)
|
||||||
|
placed_so_far.append((other_offering, _restore_lab(board, other_offering, other_session)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Bumping didn't free a usable slot for `offering` — restore and
|
||||||
|
# try the next candidate.
|
||||||
|
placed_so_far.append((other_offering, _restore_lab(board, other_offering, other_session)))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_lab(board, offering, session):
|
||||||
|
"""Re-marks and re-adds a lab session dict exactly as it was."""
|
||||||
|
room = board.data["rooms_by_id"][session["room_id"]]
|
||||||
|
for slot_index in session["slot_indices"]:
|
||||||
|
board.mark_busy(session["day"], slot_index, session["room_id"], session["instructor_code"], session["batch_codes"])
|
||||||
|
return board.add_session(offering, session["day"], session["slot_indices"], room)
|
||||||
|
|
||||||
|
|
||||||
|
def _shares_contested_resource(offering, other_session, candidate_room_ids):
|
||||||
|
"""
|
||||||
|
True if bumping other_session could plausibly free a slot offering
|
||||||
|
needs: shares the instructor, a usable lab room, or one of offering's
|
||||||
|
own batches (a batch can only be in one place at a time, so another
|
||||||
|
session competing for that batch's slot is a real contested resource
|
||||||
|
too, not just instructor/room).
|
||||||
|
"""
|
||||||
|
if other_session["instructor_code"] == offering["instructor_code"]:
|
||||||
|
return True
|
||||||
|
if other_session["room_id"] in candidate_room_ids:
|
||||||
|
return True
|
||||||
|
return bool(set(other_session["batch_codes"]) & set(offering["batch_codes"]))
|
||||||
193
controller/solver/phase3_remaining.py
Normal file
193
controller/solver/phase3_remaining.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""
|
||||||
|
Phase 3: remaining (non-general) theory courses — batch by batch, with
|
||||||
|
recursive multi-bump backtracking.
|
||||||
|
|
||||||
|
Processes batches in sorted batch_code order. Each batch must be fully
|
||||||
|
placed with zero conflicts before moving to the next batch. If a batch's
|
||||||
|
courses can't all be placed, this backtracks into *earlier, already-
|
||||||
|
completed Phase 3 batches*: it bumps sessions that share a contested
|
||||||
|
resource (same instructor, same room, or same batch) with the stuck
|
||||||
|
offering out of the way — trying combinations of more than one bumped
|
||||||
|
session where a single bump isn't enough — then reseats every bumped
|
||||||
|
session afterward (recursively backtracking again if a reseat itself gets
|
||||||
|
stuck), until either everything settles or the attempt budget is
|
||||||
|
exhausted.
|
||||||
|
|
||||||
|
Phase 1 and Phase 2 sessions are never touched — only sessions this phase
|
||||||
|
itself placed for earlier batches are ever reopened.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import grid
|
||||||
|
|
||||||
|
MAX_BACKTRACK_ATTEMPTS = 5000
|
||||||
|
MAX_SIMULTANEOUS_BUMPS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def run(board, data):
|
||||||
|
"""
|
||||||
|
Places every remaining (non-general) theory offering's weekly
|
||||||
|
lectures, batch by batch. Returns this phase's sessions.
|
||||||
|
"""
|
||||||
|
phase_start = len(board.sessions)
|
||||||
|
offerings = [o for o in data["offerings"] if not o["course"]["is_general"]]
|
||||||
|
|
||||||
|
# placed_so_far: flat list of (offering, session) for every session
|
||||||
|
# this phase has placed so far (across all completed batches) — the
|
||||||
|
# pool backtracking is allowed to bump from.
|
||||||
|
placed_so_far = []
|
||||||
|
attempts = [0]
|
||||||
|
|
||||||
|
for batch in sorted(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"]]
|
||||||
|
# Place the most slot-constrained offerings first (long-lecture
|
||||||
|
# courses can only ever use slot 0, so they have the fewest
|
||||||
|
# options) — this way flexible 90-min courses fill in around them
|
||||||
|
# instead of greedily grabbing slot 0 first and starving a
|
||||||
|
# long-lecture course that has nowhere else to go.
|
||||||
|
batch_offerings.sort(key=lambda o: 0 if o["course"]["lecture_duration_minutes"] == 120 else 1)
|
||||||
|
|
||||||
|
for offering in batch_offerings:
|
||||||
|
course = offering["course"]
|
||||||
|
room_candidates = board.room_candidates(course["lecture_room_id"], board.classrooms)
|
||||||
|
for _ in range(course["lectures_per_week"]):
|
||||||
|
session = _place_lecture_with_backtracking(board, offering, room_candidates, placed_so_far, attempts)
|
||||||
|
if session is None:
|
||||||
|
print(
|
||||||
|
f"WARNING: could not place a lecture for course {offering['course_id']} "
|
||||||
|
f"section {offering['section_id']} (batch {batch_code}) even after backtracking"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
placed_so_far.append((offering, session))
|
||||||
|
|
||||||
|
return board.sessions[phase_start:]
|
||||||
|
|
||||||
|
|
||||||
|
def _place_lecture_with_backtracking(board, offering, room_candidates, placed_so_far, attempts):
|
||||||
|
"""
|
||||||
|
Tries the plain first-fit placement first; if that fails, tries
|
||||||
|
bumping *combinations* of resource-sharing predecessors out of the way
|
||||||
|
(starting with one at a time, then pairs, up to MAX_SIMULTANEOUS_BUMPS
|
||||||
|
together) until `offering` fits, then reseats every bumped session
|
||||||
|
(recursively backtracking again if a reseat itself gets stuck).
|
||||||
|
Returns the newly placed session dict, or None if no arrangement works
|
||||||
|
within the attempt budget.
|
||||||
|
"""
|
||||||
|
session = board.place_lecture(offering, room_candidates, grid.DAYS)
|
||||||
|
if session is not None:
|
||||||
|
return session
|
||||||
|
|
||||||
|
candidate_room_ids = {r["room_id"] for r in room_candidates}
|
||||||
|
usable_slot_indices = {grid.LONG_LECTURE_SLOT_INDEX} if offering["course"]["lecture_duration_minutes"] == 120 else set(grid.TEACHING_SLOT_INDICES)
|
||||||
|
candidates = [
|
||||||
|
(other_offering, other_session)
|
||||||
|
for other_offering, other_session in placed_so_far
|
||||||
|
if _shares_contested_resource(offering, other_session, candidate_room_ids, usable_slot_indices)
|
||||||
|
]
|
||||||
|
# Most recently placed first — later placements are more likely to be
|
||||||
|
# "loose" (easier to reseat) than earlier, already-settled ones.
|
||||||
|
candidates.reverse()
|
||||||
|
|
||||||
|
for bump_count in range(1, min(MAX_SIMULTANEOUS_BUMPS, len(candidates)) + 1):
|
||||||
|
result = _try_bump_combinations(board, offering, room_candidates, placed_so_far, attempts, candidates, bump_count)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
if attempts[0] >= MAX_BACKTRACK_ATTEMPTS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _try_bump_combinations(board, offering, room_candidates, placed_so_far, attempts, candidates, bump_count):
|
||||||
|
"""
|
||||||
|
Tries every way of picking `bump_count` candidates (in order) to bump
|
||||||
|
simultaneously, retrying `offering`'s placement after each bump.
|
||||||
|
Returns the newly placed session on success (with all bumped sessions
|
||||||
|
reseated), or None if no combination of this size works.
|
||||||
|
"""
|
||||||
|
from itertools import combinations
|
||||||
|
|
||||||
|
for combo in combinations(range(len(candidates)), bump_count):
|
||||||
|
if attempts[0] >= MAX_BACKTRACK_ATTEMPTS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
chosen = [candidates[i] for i in combo]
|
||||||
|
if any((o, s) not in placed_so_far for o, s in chosen):
|
||||||
|
# One of these was already moved by an earlier failed branch.
|
||||||
|
continue
|
||||||
|
|
||||||
|
bumped = []
|
||||||
|
for other_offering, other_session in chosen:
|
||||||
|
attempts[0] += 1
|
||||||
|
placed_so_far.remove((other_offering, other_session))
|
||||||
|
board.remove_session(other_session)
|
||||||
|
bumped.append((other_offering, other_session))
|
||||||
|
|
||||||
|
session = board.place_lecture(offering, room_candidates, grid.DAYS)
|
||||||
|
if session is not None:
|
||||||
|
if _reseat_all(board, placed_so_far, attempts, bumped):
|
||||||
|
return session
|
||||||
|
# Reseating some bumped session failed even with recursion —
|
||||||
|
# undo our placement and restore everything bumped this round.
|
||||||
|
board.remove_session(session)
|
||||||
|
for other_offering, other_session in bumped:
|
||||||
|
placed_so_far.append((other_offering, _restore_session(board, other_offering, other_session)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# This combination didn't free a usable slot — restore everything
|
||||||
|
# bumped this round and try the next combination.
|
||||||
|
for other_offering, other_session in bumped:
|
||||||
|
placed_so_far.append((other_offering, _restore_session(board, other_offering, other_session)))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _reseat_all(board, placed_so_far, attempts, bumped):
|
||||||
|
"""
|
||||||
|
Reseats every (offering, session) in `bumped` (each currently removed
|
||||||
|
from the board), backtracking recursively if needed. All-or-nothing:
|
||||||
|
on any failure, every session reseated so far in this attempt is
|
||||||
|
removed again and `bumped` remains fully un-seated (the caller is
|
||||||
|
responsible for restoring them to their original slots).
|
||||||
|
"""
|
||||||
|
reseated = []
|
||||||
|
for other_offering, other_session in bumped:
|
||||||
|
other_room_candidates = board.room_candidates(other_offering["course"]["lecture_room_id"], board.classrooms)
|
||||||
|
new_session = _place_lecture_with_backtracking(board, other_offering, other_room_candidates, placed_so_far, attempts)
|
||||||
|
if new_session is None:
|
||||||
|
for reseated_offering, reseated_session in reseated:
|
||||||
|
placed_so_far.remove((reseated_offering, reseated_session))
|
||||||
|
board.remove_session(reseated_session)
|
||||||
|
return False
|
||||||
|
reseated.append((other_offering, new_session))
|
||||||
|
placed_so_far.append((other_offering, new_session))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_session(board, offering, session):
|
||||||
|
"""Re-marks and re-adds a session dict exactly as it was, at the same day/slots/room."""
|
||||||
|
room = board.data["rooms_by_id"][session["room_id"]]
|
||||||
|
for slot_index in session["slot_indices"]:
|
||||||
|
board.mark_busy(session["day"], slot_index, session["room_id"], session["instructor_code"], session["batch_codes"])
|
||||||
|
return board.add_session(
|
||||||
|
offering, session["day"], session["slot_indices"], room,
|
||||||
|
is_long_lecture=(session["duration_minutes"] == 120),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _shares_contested_resource(offering, other_session, candidate_room_ids, usable_slot_indices):
|
||||||
|
"""
|
||||||
|
True if bumping other_session could plausibly free a slot `offering`
|
||||||
|
can actually use: other_session must occupy at least one slot index
|
||||||
|
`offering` is eligible for, AND share the instructor, a usable room,
|
||||||
|
or (crucially) one of offering's own batches — a batch can only be in
|
||||||
|
one place at a time, so another course competing for the *same batch's*
|
||||||
|
slot-0 time is just as real a contested resource as instructor/room.
|
||||||
|
"""
|
||||||
|
if not any(slot_index in usable_slot_indices for slot_index in other_session["slot_indices"]):
|
||||||
|
return False
|
||||||
|
if other_session["instructor_code"] == offering["instructor_code"]:
|
||||||
|
return True
|
||||||
|
if other_session["room_id"] in candidate_room_ids:
|
||||||
|
return True
|
||||||
|
return bool(set(other_session["batch_codes"]) & set(offering["batch_codes"]))
|
||||||
171
controller/solver/placement_board.py
Normal file
171
controller/solver/placement_board.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""
|
||||||
|
PlacementBoard: shared clash-tracking state and low-level placement
|
||||||
|
primitives. Owns the room/instructor/batch busy-maps and the accumulated
|
||||||
|
session list. Has zero scheduling policy of its own — every phase module
|
||||||
|
(phase1_general, phase2_labs, phase3_remaining) reads and writes the same
|
||||||
|
board, which is what makes each later phase automatically see earlier
|
||||||
|
phases' placements as fixed/locked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import grid
|
||||||
|
|
||||||
|
|
||||||
|
class PlacementBoard:
|
||||||
|
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 = []
|
||||||
|
|
||||||
|
# -- serialization -----------------------------------------------------
|
||||||
|
|
||||||
|
def to_state(self):
|
||||||
|
"""Serializes accumulated sessions + busy-maps to a JSON-able dict."""
|
||||||
|
def _dump_busy(busy_map):
|
||||||
|
return {f"{day}|{slot_index}": sorted(values) for (day, slot_index), values in busy_map.items()}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sessions": self.sessions,
|
||||||
|
"room_busy": _dump_busy(self.room_busy),
|
||||||
|
"instructor_busy": _dump_busy(self.instructor_busy),
|
||||||
|
"batch_busy": _dump_busy(self.batch_busy),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_state(cls, data, state):
|
||||||
|
"""Rebuilds a PlacementBoard from a dict previously produced by to_state()."""
|
||||||
|
def _load_busy(dumped):
|
||||||
|
busy = {}
|
||||||
|
for key, values in dumped.items():
|
||||||
|
day, slot_index = key.rsplit("|", 1)
|
||||||
|
busy[(day, int(slot_index))] = set(values)
|
||||||
|
return busy
|
||||||
|
|
||||||
|
board = cls(data)
|
||||||
|
board.sessions = list(state["sessions"])
|
||||||
|
board.room_busy = _load_busy(state["room_busy"])
|
||||||
|
board.instructor_busy = _load_busy(state["instructor_busy"])
|
||||||
|
board.batch_busy = _load_busy(state["batch_busy"])
|
||||||
|
return board
|
||||||
|
|
||||||
|
# -- clash-tracking primitives ------------------------------------------
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
rest = [r for r in room_pool if r["room_id"] != preferred_room_id]
|
||||||
|
return ([preferred] if preferred else []) + rest
|
||||||
|
|
||||||
|
# -- session creation ----------------------------------------------------
|
||||||
|
|
||||||
|
def add_session(self, offering, day, slot_indices, room, is_long_lecture=False):
|
||||||
|
course = offering["course"]
|
||||||
|
if is_long_lecture:
|
||||||
|
start, end = grid.LONG_LECTURE_START, grid.LONG_LECTURE_END
|
||||||
|
duration_minutes = 120
|
||||||
|
session_type = "theory"
|
||||||
|
else:
|
||||||
|
start = grid.TIME_SLOTS[slot_indices[0]]["start"]
|
||||||
|
end = grid.TIME_SLOTS[slot_indices[-1]]["end"]
|
||||||
|
duration_minutes = 90 * len(slot_indices)
|
||||||
|
session_type = "lab" if len(slot_indices) > 1 else "theory"
|
||||||
|
session = {
|
||||||
|
"day": day,
|
||||||
|
"slot_indices": slot_indices,
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"duration_minutes": duration_minutes,
|
||||||
|
"course_id": offering["course_id"],
|
||||||
|
"course_name": course["course_name"],
|
||||||
|
"course_type": session_type,
|
||||||
|
"is_general": course["is_general"],
|
||||||
|
"section_id": offering["section_id"],
|
||||||
|
"instructor_code": offering["instructor_code"],
|
||||||
|
"instructor_name": offering["instructor_name"],
|
||||||
|
"room_id": room["room_id"],
|
||||||
|
"room_name": room["room_name"],
|
||||||
|
"batch_codes": offering["batch_codes"],
|
||||||
|
}
|
||||||
|
self.sessions.append(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def remove_session(self, session):
|
||||||
|
"""Unmarks a previously placed session's slots and removes it from self.sessions."""
|
||||||
|
for slot_index in session["slot_indices"]:
|
||||||
|
self.unmark_busy(
|
||||||
|
session["day"], slot_index,
|
||||||
|
session["room_id"], session["instructor_code"], session["batch_codes"],
|
||||||
|
)
|
||||||
|
self.sessions.remove(session)
|
||||||
|
|
||||||
|
# -- placement helpers shared by phase modules --------------------------
|
||||||
|
|
||||||
|
def place_theory_session(self, offering, room_candidates, day_order):
|
||||||
|
"""Finds and locks a free normal 90-minute single-slot session; returns the session dict or None."""
|
||||||
|
for day in day_order:
|
||||||
|
for slot_index in grid.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 place_long_lecture(self, offering, room_candidates, day_order):
|
||||||
|
"""
|
||||||
|
Finds and locks a free 08:30-10:30 long-lecture session. Occupies
|
||||||
|
the same clash-tracking slot index as normal slot 0 since they
|
||||||
|
overlap in wall-clock time. Returns the session dict or None.
|
||||||
|
"""
|
||||||
|
for day in day_order:
|
||||||
|
for room in room_candidates:
|
||||||
|
if self.is_free(day, grid.LONG_LECTURE_SLOT_INDEX, room["room_id"], offering["instructor_code"], offering["batch_codes"]):
|
||||||
|
self.mark_busy(day, grid.LONG_LECTURE_SLOT_INDEX, room["room_id"], offering["instructor_code"], offering["batch_codes"])
|
||||||
|
return self.add_session(offering, day, [grid.LONG_LECTURE_SLOT_INDEX], room, is_long_lecture=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def place_lecture(self, offering, room_candidates, day_order):
|
||||||
|
"""Dispatches to the long (120-min) or normal (90-min) lecture placement based on the course's lecture_duration_minutes."""
|
||||||
|
if offering["course"]["lecture_duration_minutes"] == 120:
|
||||||
|
return self.place_long_lecture(offering, room_candidates, day_order)
|
||||||
|
return self.place_theory_session(offering, room_candidates, day_order)
|
||||||
|
|
||||||
|
def place_lab(self, offering, room_candidates, day_order):
|
||||||
|
"""Finds and locks a free 180-minute lab double-slot session; returns the session dict or None."""
|
||||||
|
for day in day_order:
|
||||||
|
for slot_a, slot_b in grid.LAB_SLOT_PAIRS:
|
||||||
|
for room in room_candidates:
|
||||||
|
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"])
|
||||||
|
return self.add_session(offering, day, [slot_a, slot_b], room)
|
||||||
|
return None
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
"""
|
"""
|
||||||
Loads dummy CSV data and builds a clash-free schedule of sessions that all
|
Loads dummy CSV data and exposes a thin Scheduler facade over the solver
|
||||||
five templates/*-wise template modules render from.
|
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 (see
|
Scheduling is done in three sequential, independently-verified phases, each
|
||||||
app/template_generator.py for the orchestration + per-phase conflict
|
with its own tailored strategy (see controller/solver/phase*_*.py for the
|
||||||
checks). Each phase's placements are locked before the next phase starts:
|
per-phase logic). Each phase's placements are locked before the next phase
|
||||||
once a (day, slot, room/instructor/batch) combination is marked busy by an
|
starts: once a (day, slot, room/instructor/batch) combination is marked
|
||||||
earlier phase, later phases can never reuse it.
|
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
|
1. General courses (shared across every batch of a session), preferring
|
||||||
Tuesday/Thursday first. Each offering gets `lectures_per_week`
|
Tuesday/Thursday first, self-repaired until clash-free.
|
||||||
sessions. If two general courses still land in the same slot for a
|
2. Labs, scheduled against Phase 1's fixed placements, then self-repaired
|
||||||
batch, the losing session is moved to another day/slot until this
|
for lab-vs-lab clashes within Phase 2 only.
|
||||||
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
|
3. Remaining (non-general) theory sessions, scheduled batch by batch in
|
||||||
sorted batch_code order: for each batch, every one of its remaining
|
sorted batch_code order, with backtracking into earlier Phase 3
|
||||||
offerings is placed and locked before the next batch is considered.
|
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
|
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
|
180-minute double-slots (09:00-12:00 and 12:00-15:00). A course whose
|
||||||
@@ -34,55 +33,15 @@ preferred one is busy for that day/slot.
|
|||||||
import csv
|
import csv
|
||||||
import os
|
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")
|
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "dummy")
|
||||||
|
|
||||||
DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
|
|
||||||
|
|
||||||
# Normal theory grid: 5 x 90-minute slots, 09:00-16:30, no lunch break.
|
|
||||||
# Every placeable session (normal lecture, lab, or the special long
|
|
||||||
# lecture) is expressed as one or more of these slot indices (0-4) for
|
|
||||||
# clash-tracking purposes — see LONG_LECTURE_SLOT below for why the long
|
|
||||||
# lecture also uses index 0 despite rendering at a different clock time.
|
|
||||||
TIME_SLOTS = [
|
|
||||||
{"label": "S1", "start": "09:00", "end": "10:30", "is_lunch": False},
|
|
||||||
{"label": "S2", "start": "10:30", "end": "12:00", "is_lunch": False},
|
|
||||||
{"label": "S3", "start": "12:00", "end": "13:30", "is_lunch": False},
|
|
||||||
{"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 (no lunch to skip
|
|
||||||
# anymore, but kept for readability at call sites)
|
|
||||||
TEACHING_SLOT_INDICES = [i for i, s in enumerate(TIME_SLOTS) if not s["is_lunch"]]
|
|
||||||
|
|
||||||
# Consecutive pairs of normal slots usable as a 180-minute lab double-slot:
|
|
||||||
# 09:00-12:00 (S1+S2) and 12:00-15:00 (S3+S4).
|
|
||||||
LAB_SLOT_PAIRS = [(0, 1), (2, 3)]
|
|
||||||
|
|
||||||
# The special fixed slot for courses whose lecture is "2 hours once a
|
|
||||||
# week": 08:30-10:30. This overlaps 09:00-10:30 (normal slot index 0) in
|
|
||||||
# wall-clock time, so a long-lecture session is tracked as occupying slot
|
|
||||||
# index 0 too (same key _is_free/_mark_busy use) — that's what makes it
|
|
||||||
# correctly clash with anything else placed in normal slot 0 that day for
|
|
||||||
# the same room/instructor/batch. duration_minutes on the session dict is
|
|
||||||
# what distinguishes it from a normal 90-minute slot-0 session at render time.
|
|
||||||
LONG_LECTURE_SLOT_INDEX = 0
|
|
||||||
LONG_LECTURE_START = "08:30"
|
|
||||||
LONG_LECTURE_END = "10:30"
|
|
||||||
|
|
||||||
# Rows to render on every generated timetable grid: the special long-lecture
|
|
||||||
# row first (it starts earliest, 08:30), then the 5 normal 90-minute rows.
|
|
||||||
# Templates/xlsx_grid_helper key session lookups on (day, slot_index,
|
|
||||||
# duration_minutes) using these rows' slot_index/duration_minutes pairs.
|
|
||||||
DISPLAY_ROWS = [
|
|
||||||
{"label": "Long", "start": LONG_LECTURE_START, "end": LONG_LECTURE_END,
|
|
||||||
"slot_index": LONG_LECTURE_SLOT_INDEX, "duration_minutes": 120},
|
|
||||||
] + [
|
|
||||||
{"label": s["label"], "start": s["start"], "end": s["end"],
|
|
||||||
"slot_index": i, "duration_minutes": 90}
|
|
||||||
for i, s in enumerate(TIME_SLOTS)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _read_csv(relative_path):
|
def _read_csv(relative_path):
|
||||||
path = os.path.join(DATA_DIR, relative_path)
|
path = os.path.join(DATA_DIR, relative_path)
|
||||||
@@ -139,263 +98,44 @@ def load_data():
|
|||||||
|
|
||||||
|
|
||||||
class Scheduler:
|
class Scheduler:
|
||||||
"""Greedy clash-free scheduler over rooms / instructors / batches."""
|
"""
|
||||||
|
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):
|
def __init__(self, data):
|
||||||
self.data = data
|
self.data = data
|
||||||
self.classrooms = [r for r in data["rooms"] if r["room_type"] == "classroom"]
|
self.board = PlacementBoard(data)
|
||||||
self.labs = [r for r in data["rooms"] if r["room_type"] == "lab"]
|
|
||||||
|
|
||||||
# busy[(day, slot_index)] -> set of keys already occupied
|
@property
|
||||||
self.room_busy = {}
|
def sessions(self):
|
||||||
self.instructor_busy = {}
|
return self.board.sessions
|
||||||
self.batch_busy = {}
|
|
||||||
|
|
||||||
self.sessions = []
|
|
||||||
|
|
||||||
def to_state(self):
|
def to_state(self):
|
||||||
"""
|
return self.board.to_state()
|
||||||
Serializes this scheduler's accumulated sessions and busy-maps to a
|
|
||||||
JSON-able dict, so a later process can resume exactly where this
|
|
||||||
one left off (used to run each phase as a separate CLI command).
|
|
||||||
"""
|
|
||||||
def _dump_busy(busy_map):
|
|
||||||
return {f"{day}|{slot_index}": sorted(values) for (day, slot_index), values in busy_map.items()}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"sessions": self.sessions,
|
|
||||||
"room_busy": _dump_busy(self.room_busy),
|
|
||||||
"instructor_busy": _dump_busy(self.instructor_busy),
|
|
||||||
"batch_busy": _dump_busy(self.batch_busy),
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_state(cls, data, state):
|
def from_state(cls, data, state):
|
||||||
"""Rebuilds a Scheduler from a dict previously produced by to_state()."""
|
|
||||||
def _load_busy(dumped):
|
|
||||||
busy = {}
|
|
||||||
for key, values in dumped.items():
|
|
||||||
day, slot_index = key.rsplit("|", 1)
|
|
||||||
busy[(day, int(slot_index))] = set(values)
|
|
||||||
return busy
|
|
||||||
|
|
||||||
scheduler = cls(data)
|
scheduler = cls(data)
|
||||||
scheduler.sessions = list(state["sessions"])
|
scheduler.board = PlacementBoard.from_state(data, state)
|
||||||
scheduler.room_busy = _load_busy(state["room_busy"])
|
|
||||||
scheduler.instructor_busy = _load_busy(state["instructor_busy"])
|
|
||||||
scheduler.batch_busy = _load_busy(state["batch_busy"])
|
|
||||||
return scheduler
|
return scheduler
|
||||||
|
|
||||||
def _is_free(self, day, slot_index, room_id, instructor_code, batch_codes):
|
def phase_1_general_courses(self):
|
||||||
key = (day, slot_index)
|
return phase1_general.run(self.board, self.data)
|
||||||
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 _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)
|
|
||||||
rest = [r for r in room_pool if r["room_id"] != preferred_room_id]
|
|
||||||
return ([preferred] if preferred else []) + rest
|
|
||||||
|
|
||||||
def _add_session(self, offering, day, slot_indices, room, is_long_lecture=False):
|
|
||||||
course = offering["course"]
|
|
||||||
if is_long_lecture:
|
|
||||||
start, end = LONG_LECTURE_START, LONG_LECTURE_END
|
|
||||||
duration_minutes = 120
|
|
||||||
session_type = "theory"
|
|
||||||
else:
|
|
||||||
start = TIME_SLOTS[slot_indices[0]]["start"]
|
|
||||||
end = TIME_SLOTS[slot_indices[-1]]["end"]
|
|
||||||
duration_minutes = 90 * len(slot_indices)
|
|
||||||
session_type = "lab" if len(slot_indices) > 1 else "theory"
|
|
||||||
session = {
|
|
||||||
"day": day,
|
|
||||||
"slot_indices": slot_indices,
|
|
||||||
"start": start,
|
|
||||||
"end": end,
|
|
||||||
"duration_minutes": duration_minutes,
|
|
||||||
"course_id": offering["course_id"],
|
|
||||||
"course_name": course["course_name"],
|
|
||||||
"course_type": session_type,
|
|
||||||
"is_general": course["is_general"],
|
|
||||||
"section_id": offering["section_id"],
|
|
||||||
"instructor_code": offering["instructor_code"],
|
|
||||||
"instructor_name": offering["instructor_name"],
|
|
||||||
"room_id": room["room_id"],
|
|
||||||
"room_name": room["room_name"],
|
|
||||||
"batch_codes": offering["batch_codes"],
|
|
||||||
}
|
|
||||||
self.sessions.append(session)
|
|
||||||
return session
|
|
||||||
|
|
||||||
def _place_theory_session(self, offering, room_candidates, day_order):
|
|
||||||
"""Finds and locks a free normal 90-minute 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 _place_long_lecture(self, offering, room_candidates, day_order):
|
|
||||||
"""
|
|
||||||
Finds and locks a free 08:30-10:30 long-lecture session (for
|
|
||||||
courses with lecture_duration_minutes=120). Occupies the same
|
|
||||||
clash-tracking slot index as normal slot 0 since they overlap in
|
|
||||||
wall-clock time (09:00-10:30). Returns the session dict or None.
|
|
||||||
"""
|
|
||||||
for day in day_order:
|
|
||||||
for room in room_candidates:
|
|
||||||
if self._is_free(day, LONG_LECTURE_SLOT_INDEX, room["room_id"], offering["instructor_code"], offering["batch_codes"]):
|
|
||||||
self._mark_busy(day, LONG_LECTURE_SLOT_INDEX, room["room_id"], offering["instructor_code"], offering["batch_codes"])
|
|
||||||
return self._add_session(offering, day, [LONG_LECTURE_SLOT_INDEX], room, is_long_lecture=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _place_lecture(self, offering, room_candidates, day_order):
|
|
||||||
"""Dispatches to the long (120-min) or normal (90-min) lecture placement based on the course's lecture_duration_minutes."""
|
|
||||||
if offering["course"]["lecture_duration_minutes"] == 120:
|
|
||||||
return self._place_long_lecture(offering, room_candidates, day_order)
|
|
||||||
return self._place_theory_session(offering, room_candidates, day_order)
|
|
||||||
|
|
||||||
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"]):
|
|
||||||
session = self._place_lecture(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']}")
|
|
||||||
|
|
||||||
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_lecture(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):
|
def phase_2_labs(self):
|
||||||
"""
|
return phase2_labs.run(self.board, self.data)
|
||||||
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"]
|
|
||||||
room_candidates = self._room_candidates(course["lab_room_id"], self.labs)
|
|
||||||
|
|
||||||
for _ in range(course["labs_per_week"]):
|
|
||||||
placed = False
|
|
||||||
for day in DAYS:
|
|
||||||
for slot_a, slot_b in LAB_SLOT_PAIRS:
|
|
||||||
for room in room_candidates:
|
|
||||||
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 a lab for course {offering['course_id']} section {offering['section_id']} ({offering['batch_codes']})")
|
|
||||||
|
|
||||||
return self.sessions[phase_start:]
|
|
||||||
|
|
||||||
def phase_3_remaining_courses(self):
|
def phase_3_remaining_courses(self):
|
||||||
"""
|
return phase3_remaining.run(self.board, self.data)
|
||||||
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_lecture(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):
|
def run(self):
|
||||||
self.phase_1_general_courses()
|
self.phase_1_general_courses()
|
||||||
self.phase_2_labs()
|
self.phase_2_labs()
|
||||||
self.phase_3_remaining_courses()
|
self.phase_3_remaining_courses()
|
||||||
return self.sessions
|
return self.board.sessions
|
||||||
|
|
||||||
|
|
||||||
def build_schedule():
|
def build_schedule():
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ A-101,Room 101,A,classroom,60
|
|||||||
A-102,Room 102,A,classroom,60
|
A-102,Room 102,A,classroom,60
|
||||||
A-103,Room 103,A,classroom,50
|
A-103,Room 103,A,classroom,50
|
||||||
A-201,Room 201,A,classroom,60
|
A-201,Room 201,A,classroom,60
|
||||||
|
A-202,Room 202,A,classroom,60
|
||||||
A-L01,Computer Lab 1,A,lab,35
|
A-L01,Computer Lab 1,A,lab,35
|
||||||
A-L02,Computer Lab 2,A,lab,35
|
A-L02,Computer Lab 2,A,lab,35
|
||||||
|
A-L03,Computer Lab 3,A,lab,35
|
||||||
B-101,Room 101 (B),B,classroom,50
|
B-101,Room 101 (B),B,classroom,50
|
||||||
B-102,Room 102 (B),B,classroom,50
|
B-102,Room 102 (B),B,classroom,50
|
||||||
B-L01,Electronics Lab,B,lab,30
|
B-L01,Electronics Lab,B,lab,30
|
||||||
|
|||||||
|
@@ -2,43 +2,43 @@ course_id,section_id,batch_codes,instructor_code
|
|||||||
CS101,A,25021519,1001
|
CS101,A,25021519,1001
|
||||||
CS201,A,25021519,1002
|
CS201,A,25021519,1002
|
||||||
CS301,A,25021519,1003
|
CS301,A,25021519,1003
|
||||||
CS302,A,25021519,1001
|
CS302,A,25021519,1009
|
||||||
CS101,B,25021520,1001
|
CS101,B,25021520,1001
|
||||||
CS201,B,25021520,1002
|
CS201,B,25021520,1002
|
||||||
CS301,B,25021520,1003
|
CS301,B,25021520,1003
|
||||||
CS302,B,25021520,1001
|
CS302,B,25021520,1012
|
||||||
EE101,A,25031519,1004
|
EE101,A,25031519,1004
|
||||||
EE201,A,25031519,1005
|
EE201,A,25031519,1005
|
||||||
EE301,A,25031519,1004
|
EE301,A,25031519,1010
|
||||||
EE101,B,25031520,1004
|
EE101,B,25031520,1004
|
||||||
EE201,B,25031520,1005
|
EE201,B,25031520,1005
|
||||||
EE301,B,25031520,1004
|
EE301,B,25031520,1010
|
||||||
BBA101,A,25041519,1007
|
BBA101,A,25041519,1007
|
||||||
BBA201,A,25041519,1008
|
BBA201,A,25041519,1008
|
||||||
BBA301,A,25041519,1007
|
BBA301,A,25041519,1011
|
||||||
BBA101,B,25041520,1007
|
BBA101,B,25041520,1007
|
||||||
BBA201,B,25041520,1008
|
BBA201,B,25041520,1008
|
||||||
BBA301,B,25041520,1007
|
BBA301,B,25041520,1011
|
||||||
CS101,C,26021519,1001
|
CS101,C,26021519,1001
|
||||||
CS201,C,26021519,1002
|
CS201,C,26021519,1002
|
||||||
CS301,C,26021519,1003
|
CS301,C,26021519,1003
|
||||||
CS302,C,26021519,1001
|
CS302,C,26021519,1009
|
||||||
CS101,D,26021520,1001
|
CS101,D,26021520,1001
|
||||||
CS201,D,26021520,1002
|
CS201,D,26021520,1002
|
||||||
CS301,D,26021520,1003
|
CS301,D,26021520,1003
|
||||||
CS302,D,26021520,1001
|
CS302,D,26021520,1012
|
||||||
EE101,C,26031519,1004
|
EE101,C,26031519,1004
|
||||||
EE201,C,26031519,1005
|
EE201,C,26031519,1005
|
||||||
EE301,C,26031519,1004
|
EE301,C,26031519,1010
|
||||||
EE101,D,26031520,1004
|
EE101,D,26031520,1004
|
||||||
EE201,D,26031520,1005
|
EE201,D,26031520,1005
|
||||||
EE301,D,26031520,1004
|
EE301,D,26031520,1010
|
||||||
BBA101,C,26041519,1007
|
BBA101,C,26041519,1007
|
||||||
BBA201,C,26041519,1008
|
BBA201,C,26041519,1008
|
||||||
BBA301,C,26041519,1007
|
BBA301,C,26041519,1011
|
||||||
BBA101,D,26041520,1007
|
BBA101,D,26041520,1007
|
||||||
BBA201,D,26041520,1008
|
BBA201,D,26041520,1008
|
||||||
BBA301,D,26041520,1007
|
BBA301,D,26041520,1011
|
||||||
GEN101,ALL,25021519|25021520|25031519|25031520|25041519|25041520,1002
|
GEN101,ALL,25021519|25021520|25031519|25031520|25041519|25041520,1002
|
||||||
GEN102,ALL,25021519|25021520|25031519|25031520|25041519|25041520,1006
|
GEN102,ALL,25021519|25021520|25031519|25031520|25041519|25041520,1006
|
||||||
GEN103,ALL,25021519|25021520|25031519|25031520|25041519|25041520,1006
|
GEN103,ALL,25021519|25021520|25031519|25031520|25041519|25041520,1006
|
||||||
|
|||||||
|
@@ -1,8 +1,8 @@
|
|||||||
course_id,course_name,credit_hours,lectures_per_week,labs_per_week,is_general,lecture_room_id,lab_room_id,lecture_duration_minutes
|
course_id,course_name,credit_hours,lectures_per_week,labs_per_week,is_general,lecture_room_id,lab_room_id,lecture_duration_minutes
|
||||||
CS101,Programming Fundamentals,4,2,1,no,A-101,A-L01,90
|
CS101,Programming Fundamentals,4,2,1,no,A-101,A-L01,90
|
||||||
CS201,Data Structures,4,2,1,no,A-102,A-L01,90
|
CS201,Data Structures,4,2,1,no,A-102,A-L02,90
|
||||||
CS301,Operating Systems,3,1,1,no,A-101,A-L02,120
|
CS301,Operating Systems,3,1,1,no,A-101,A-L03,120
|
||||||
CS302,Database Systems,4,2,2,no,A-102,A-L02,90
|
CS302,Database Systems,4,2,1,no,A-102,A-L01,90
|
||||||
EE101,Basic Electronics,4,2,1,no,A-201,A-L02,90
|
EE101,Basic Electronics,4,2,1,no,A-201,A-L02,90
|
||||||
EE201,Digital Logic Design,4,2,1,no,B-101,B-L01,90
|
EE201,Digital Logic Design,4,2,1,no,B-101,B-L01,90
|
||||||
EE301,Signals and Systems,3,1,0,no,B-102,,120
|
EE301,Signals and Systems,3,1,0,no,B-102,,120
|
||||||
@@ -11,4 +11,4 @@ BBA201,Financial Accounting,3,2,0,no,B-102,,90
|
|||||||
BBA301,Marketing Management,3,1,0,no,A-103,,120
|
BBA301,Marketing Management,3,1,0,no,A-103,,120
|
||||||
GEN101,Functional English,3,2,0,yes,A-103,,90
|
GEN101,Functional English,3,2,0,yes,A-103,,90
|
||||||
GEN102,Islamic Studies,2,1,0,yes,A-103,,90
|
GEN102,Islamic Studies,2,1,0,yes,A-103,,90
|
||||||
GEN103,Pakistan Studies,2,1,0,yes,B-101,,120
|
GEN103,Pakistan Studies,2,1,0,yes,A-202,,120
|
||||||
|
|||||||
|
@@ -38,8 +38,9 @@ def main():
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- rooms: deliberately tight relative to demand (see offerings
|
# ---- rooms: tight enough to keep real contention, but with enough
|
||||||
# below) so room contention is real, spread across both blocks ----
|
# total capacity that a correct solver can always find a fit (room
|
||||||
|
# capacity/student-count is not a constraint here) ----
|
||||||
write_csv(
|
write_csv(
|
||||||
"blocks/rooms.csv",
|
"blocks/rooms.csv",
|
||||||
["room_id", "room_name", "block_id", "room_type", "capacity"],
|
["room_id", "room_name", "block_id", "room_type", "capacity"],
|
||||||
@@ -48,8 +49,10 @@ def main():
|
|||||||
["A-102", "Room 102", "A", "classroom", 60],
|
["A-102", "Room 102", "A", "classroom", 60],
|
||||||
["A-103", "Room 103", "A", "classroom", 50],
|
["A-103", "Room 103", "A", "classroom", 50],
|
||||||
["A-201", "Room 201", "A", "classroom", 60],
|
["A-201", "Room 201", "A", "classroom", 60],
|
||||||
|
["A-202", "Room 202", "A", "classroom", 60],
|
||||||
["A-L01", "Computer Lab 1", "A", "lab", 35],
|
["A-L01", "Computer Lab 1", "A", "lab", 35],
|
||||||
["A-L02", "Computer Lab 2", "A", "lab", 35],
|
["A-L02", "Computer Lab 2", "A", "lab", 35],
|
||||||
|
["A-L03", "Computer Lab 3", "A", "lab", 35],
|
||||||
["B-101", "Room 101 (B)", "B", "classroom", 50],
|
["B-101", "Room 101 (B)", "B", "classroom", 50],
|
||||||
["B-102", "Room 102 (B)", "B", "classroom", 50],
|
["B-102", "Room 102 (B)", "B", "classroom", 50],
|
||||||
["B-L01", "Electronics Lab", "B", "lab", 30],
|
["B-L01", "Electronics Lab", "B", "lab", 30],
|
||||||
@@ -97,15 +100,18 @@ def main():
|
|||||||
#
|
#
|
||||||
# Mixed lecture durations: most courses are 90-min x N/week, but a few
|
# Mixed lecture durations: most courses are 90-min x N/week, but a few
|
||||||
# are the special "2 hours once a week" case (lecture_duration_minutes
|
# are the special "2 hours once a week" case (lecture_duration_minutes
|
||||||
# 120, lectures_per_week 1). Labs vary 0/1/2 per week. Deliberately
|
# 120, lectures_per_week 1). A course's lab, if it has one, is always
|
||||||
# more courses per department than the room/instructor pool can
|
# exactly once a week (180 minutes) — never more. Deliberately more
|
||||||
# comfortably absorb without contention.
|
# courses per department than the room/instructor pool can comfortably
|
||||||
|
# absorb without contention.
|
||||||
courses = [
|
courses = [
|
||||||
# CS department courses
|
# CS department courses — lab rooms spread across A-L01/A-L02/A-L03
|
||||||
|
# so no single lab room is asked for more sessions than exist
|
||||||
|
# weekly slots for it (5 days x 2 lab-slot-pairs = 10/week).
|
||||||
["CS101", "Programming Fundamentals", 4, 2, 1, "no", "A-101", "A-L01", 90],
|
["CS101", "Programming Fundamentals", 4, 2, 1, "no", "A-101", "A-L01", 90],
|
||||||
["CS201", "Data Structures", 4, 2, 1, "no", "A-102", "A-L01", 90],
|
["CS201", "Data Structures", 4, 2, 1, "no", "A-102", "A-L02", 90],
|
||||||
["CS301", "Operating Systems", 3, 1, 1, "no", "A-101", "A-L02", 120],
|
["CS301", "Operating Systems", 3, 1, 1, "no", "A-101", "A-L03", 120],
|
||||||
["CS302", "Database Systems", 4, 2, 2, "no", "A-102", "A-L02", 90],
|
["CS302", "Database Systems", 4, 2, 1, "no", "A-102", "A-L01", 90],
|
||||||
# EE department courses
|
# EE department courses
|
||||||
["EE101", "Basic Electronics", 4, 2, 1, "no", "A-201", "A-L02", 90],
|
["EE101", "Basic Electronics", 4, 2, 1, "no", "A-201", "A-L02", 90],
|
||||||
["EE201", "Digital Logic Design", 4, 2, 1, "no", "B-101", "B-L01", 90],
|
["EE201", "Digital Logic Design", 4, 2, 1, "no", "B-101", "B-L01", 90],
|
||||||
@@ -117,7 +123,7 @@ def main():
|
|||||||
# General (shared) courses
|
# General (shared) courses
|
||||||
["GEN101", "Functional English", 3, 2, 0, "yes", "A-103", "", 90],
|
["GEN101", "Functional English", 3, 2, 0, "yes", "A-103", "", 90],
|
||||||
["GEN102", "Islamic Studies", 2, 1, 0, "yes", "A-103", "", 90],
|
["GEN102", "Islamic Studies", 2, 1, 0, "yes", "A-103", "", 90],
|
||||||
["GEN103", "Pakistan Studies", 2, 1, 0, "yes", "B-101", "", 120],
|
["GEN103", "Pakistan Studies", 2, 1, 0, "yes", "A-202", "", 120],
|
||||||
]
|
]
|
||||||
write_csv(
|
write_csv(
|
||||||
"courses/courses.csv",
|
"courses/courses.csv",
|
||||||
@@ -139,26 +145,46 @@ def main():
|
|||||||
"EE": ["EE101", "EE201", "EE301"],
|
"EE": ["EE101", "EE201", "EE301"],
|
||||||
"BBA": ["BBA101", "BBA201", "BBA301"],
|
"BBA": ["BBA101", "BBA201", "BBA301"],
|
||||||
}
|
}
|
||||||
# Small pools relative to (department batches x courses) demand.
|
# One instructor per course within a department (so no instructor ever
|
||||||
|
# teaches two different courses to the same section/batch, which would
|
||||||
|
# double their weekly slot demand against that batch's narrow shared
|
||||||
|
# availability) — still a tight pool overall since each instructor
|
||||||
|
# teaches their course across every section of the department.
|
||||||
dept_instructors = {
|
dept_instructors = {
|
||||||
"CS": ["1001", "1002", "1003"],
|
"CS": ["1001", "1002", "1003", "1009"],
|
||||||
"EE": ["1004", "1005"],
|
"EE": ["1004", "1005", "1010"],
|
||||||
"BBA": ["1007", "1008"],
|
"BBA": ["1007", "1008", "1011"],
|
||||||
}
|
}
|
||||||
|
# CS302 additionally alternates between two instructors across its
|
||||||
|
# sections (instead of one instructor owning all 4) since it has the
|
||||||
|
# heaviest per-section load (2 lectures + 2 labs/week) — one person
|
||||||
|
# covering every section of it would need more slot-0 room than a
|
||||||
|
# single instructor realistically has once other batches' commitments
|
||||||
|
# are accounted for.
|
||||||
|
course_instructor_rotation = {"CS302": ["1009", "1012"]}
|
||||||
|
instructor_names_extra = {"1012": "Ms. Rabia Nasir"}
|
||||||
|
|
||||||
offerings = []
|
offerings = []
|
||||||
section_letters = ["A", "B", "C", "D"]
|
section_letters = ["A", "B", "C", "D"]
|
||||||
dept_section_counter = {"CS": 0, "EE": 0, "BBA": 0}
|
dept_section_counter = {"CS": 0, "EE": 0, "BBA": 0}
|
||||||
|
|
||||||
for batch_code, department, program, session in all_batches:
|
for batch_code, department, program, session in all_batches:
|
||||||
section_id = section_letters[dept_section_counter[department] % len(section_letters)]
|
section_index = dept_section_counter[department]
|
||||||
|
section_id = section_letters[section_index % len(section_letters)]
|
||||||
dept_section_counter[department] += 1
|
dept_section_counter[department] += 1
|
||||||
|
|
||||||
instr_pool = dept_instructors[department]
|
instr_pool = dept_instructors[department]
|
||||||
|
# One instructor per course, consistent across every section, so
|
||||||
|
# no instructor ever teaches two different courses to the same
|
||||||
|
# batch (which would silently double their contested slot-0
|
||||||
|
# demand against that one batch). The pool is still tight overall
|
||||||
|
# since each instructor teaches their course to every section of
|
||||||
|
# the department -> real instructor contention across sections.
|
||||||
for i, course_id in enumerate(dept_courses[department]):
|
for i, course_id in enumerate(dept_courses[department]):
|
||||||
# Deliberately narrow instructor rotation (2-3 instructors
|
if course_id in course_instructor_rotation:
|
||||||
# covering 3-4 courses per department) so the same instructor
|
rotation = course_instructor_rotation[course_id]
|
||||||
# teaches multiple courses/sections -> instructor contention.
|
instr = rotation[section_index % len(rotation)]
|
||||||
|
else:
|
||||||
instr = instr_pool[i % len(instr_pool)]
|
instr = instr_pool[i % len(instr_pool)]
|
||||||
offerings.append([course_id, section_id, batch_code, instr])
|
offerings.append([course_id, section_id, batch_code, instr])
|
||||||
|
|
||||||
@@ -190,6 +216,10 @@ def main():
|
|||||||
"1006": "Ms. Hira Shah",
|
"1006": "Ms. Hira Shah",
|
||||||
"1007": "Dr. Nadia Farooq",
|
"1007": "Dr. Nadia Farooq",
|
||||||
"1008": "Mr. Kamran Sheikh",
|
"1008": "Mr. Kamran Sheikh",
|
||||||
|
"1009": "Ms. Ayesha Malik",
|
||||||
|
"1010": "Mr. Imran Qureshi",
|
||||||
|
"1011": "Dr. Saima Aziz",
|
||||||
|
**instructor_names_extra,
|
||||||
}
|
}
|
||||||
instructors = []
|
instructors = []
|
||||||
for course_id, section_id, batch_codes, instr_code in offerings:
|
for course_id, section_id, batch_codes, instr_code in offerings:
|
||||||
|
|||||||
@@ -2,43 +2,43 @@ instructor_code,instructor_name,course_id,section_id
|
|||||||
1001,Dr. Ahsan Raza,CS101,A
|
1001,Dr. Ahsan Raza,CS101,A
|
||||||
1002,Ms. Sana Tariq,CS201,A
|
1002,Ms. Sana Tariq,CS201,A
|
||||||
1003,Mr. Bilal Khan,CS301,A
|
1003,Mr. Bilal Khan,CS301,A
|
||||||
1001,Dr. Ahsan Raza,CS302,A
|
1009,Ms. Ayesha Malik,CS302,A
|
||||||
1001,Dr. Ahsan Raza,CS101,B
|
1001,Dr. Ahsan Raza,CS101,B
|
||||||
1002,Ms. Sana Tariq,CS201,B
|
1002,Ms. Sana Tariq,CS201,B
|
||||||
1003,Mr. Bilal Khan,CS301,B
|
1003,Mr. Bilal Khan,CS301,B
|
||||||
1001,Dr. Ahsan Raza,CS302,B
|
1012,Ms. Rabia Nasir,CS302,B
|
||||||
1004,Dr. Farah Iqbal,EE101,A
|
1004,Dr. Farah Iqbal,EE101,A
|
||||||
1005,Mr. Usman Ali,EE201,A
|
1005,Mr. Usman Ali,EE201,A
|
||||||
1004,Dr. Farah Iqbal,EE301,A
|
1010,Mr. Imran Qureshi,EE301,A
|
||||||
1004,Dr. Farah Iqbal,EE101,B
|
1004,Dr. Farah Iqbal,EE101,B
|
||||||
1005,Mr. Usman Ali,EE201,B
|
1005,Mr. Usman Ali,EE201,B
|
||||||
1004,Dr. Farah Iqbal,EE301,B
|
1010,Mr. Imran Qureshi,EE301,B
|
||||||
1007,Dr. Nadia Farooq,BBA101,A
|
1007,Dr. Nadia Farooq,BBA101,A
|
||||||
1008,Mr. Kamran Sheikh,BBA201,A
|
1008,Mr. Kamran Sheikh,BBA201,A
|
||||||
1007,Dr. Nadia Farooq,BBA301,A
|
1011,Dr. Saima Aziz,BBA301,A
|
||||||
1007,Dr. Nadia Farooq,BBA101,B
|
1007,Dr. Nadia Farooq,BBA101,B
|
||||||
1008,Mr. Kamran Sheikh,BBA201,B
|
1008,Mr. Kamran Sheikh,BBA201,B
|
||||||
1007,Dr. Nadia Farooq,BBA301,B
|
1011,Dr. Saima Aziz,BBA301,B
|
||||||
1001,Dr. Ahsan Raza,CS101,C
|
1001,Dr. Ahsan Raza,CS101,C
|
||||||
1002,Ms. Sana Tariq,CS201,C
|
1002,Ms. Sana Tariq,CS201,C
|
||||||
1003,Mr. Bilal Khan,CS301,C
|
1003,Mr. Bilal Khan,CS301,C
|
||||||
1001,Dr. Ahsan Raza,CS302,C
|
1009,Ms. Ayesha Malik,CS302,C
|
||||||
1001,Dr. Ahsan Raza,CS101,D
|
1001,Dr. Ahsan Raza,CS101,D
|
||||||
1002,Ms. Sana Tariq,CS201,D
|
1002,Ms. Sana Tariq,CS201,D
|
||||||
1003,Mr. Bilal Khan,CS301,D
|
1003,Mr. Bilal Khan,CS301,D
|
||||||
1001,Dr. Ahsan Raza,CS302,D
|
1012,Ms. Rabia Nasir,CS302,D
|
||||||
1004,Dr. Farah Iqbal,EE101,C
|
1004,Dr. Farah Iqbal,EE101,C
|
||||||
1005,Mr. Usman Ali,EE201,C
|
1005,Mr. Usman Ali,EE201,C
|
||||||
1004,Dr. Farah Iqbal,EE301,C
|
1010,Mr. Imran Qureshi,EE301,C
|
||||||
1004,Dr. Farah Iqbal,EE101,D
|
1004,Dr. Farah Iqbal,EE101,D
|
||||||
1005,Mr. Usman Ali,EE201,D
|
1005,Mr. Usman Ali,EE201,D
|
||||||
1004,Dr. Farah Iqbal,EE301,D
|
1010,Mr. Imran Qureshi,EE301,D
|
||||||
1007,Dr. Nadia Farooq,BBA101,C
|
1007,Dr. Nadia Farooq,BBA101,C
|
||||||
1008,Mr. Kamran Sheikh,BBA201,C
|
1008,Mr. Kamran Sheikh,BBA201,C
|
||||||
1007,Dr. Nadia Farooq,BBA301,C
|
1011,Dr. Saima Aziz,BBA301,C
|
||||||
1007,Dr. Nadia Farooq,BBA101,D
|
1007,Dr. Nadia Farooq,BBA101,D
|
||||||
1008,Mr. Kamran Sheikh,BBA201,D
|
1008,Mr. Kamran Sheikh,BBA201,D
|
||||||
1007,Dr. Nadia Farooq,BBA301,D
|
1011,Dr. Saima Aziz,BBA301,D
|
||||||
1002,Ms. Sana Tariq,GEN101,ALL
|
1002,Ms. Sana Tariq,GEN101,ALL
|
||||||
1006,Ms. Hira Shah,GEN102,ALL
|
1006,Ms. Hira Shah,GEN102,ALL
|
||||||
1006,Ms. Hira Shah,GEN103,ALL
|
1006,Ms. Hira Shah,GEN103,ALL
|
||||||
|
|||||||
|
@@ -1,12 +1,12 @@
|
|||||||
Timetable generation log - started 2026-09-09T18:19:44.655145
|
Timetable generation log - started 2026-09-09T18:55:26.141293
|
||||||
================================================================================
|
================================================================================
|
||||||
[18:19:44] INFO: clear_all: schedule state, result templates, and log have been reset. Run phase_1 to start over.
|
[18:55:26] INFO: clear_all: schedule state, result templates, and log have been reset. Run phase_1 to start over.
|
||||||
[18:19:48] INFO: Phase 1/3: scheduling general courses (Tue/Thu preferred)...
|
[18:55:26] INFO: Phase 1/3: scheduling general courses (Tue/Thu preferred)...
|
||||||
[18:19:48] INFO: [Phase 1 (general courses)] No conflicts found — clash-free.
|
[18:55:26] INFO: [Phase 1 (general courses)] No conflicts found — clash-free.
|
||||||
[18:19:48] INFO: phase_1 complete: 8 session(s) added this phase, 8 total so far. State saved to D:\projects\UORM\timetable\result\schedule_state.json.
|
[18:55:26] INFO: phase_1 complete: 8 session(s) added this phase, 8 total so far. State saved to D:\projects\UORM\timetable\result\schedule_state.json.
|
||||||
[18:19:57] INFO: Phase 2/3: scheduling labs (on top of phase_1's fixed placements)...
|
[18:55:29] INFO: Phase 2/3: scheduling labs (on top of phase_1's fixed placements)...
|
||||||
[18:19:57] INFO: [Phase 2 (general courses + labs)] No conflicts found — clash-free.
|
[18:55:29] INFO: [Phase 2 (general courses + labs)] No conflicts found — clash-free.
|
||||||
[18:19:57] INFO: phase_2 complete: 23 session(s) added this phase, 31 total so far. State saved to D:\projects\UORM\timetable\result\schedule_state.json.
|
[18:55:29] INFO: phase_2 complete: 24 session(s) added this phase, 32 total so far. State saved to D:\projects\UORM\timetable\result\schedule_state.json.
|
||||||
[18:20:05] INFO: Phase 3/3: scheduling remaining courses (batch by batch)...
|
[18:55:32] INFO: Phase 3/3: scheduling remaining courses (batch by batch)...
|
||||||
[18:20:05] INFO: [Phase 3 (full schedule)] No conflicts found — clash-free.
|
[18:55:32] INFO: [Phase 3 (full schedule)] No conflicts found — clash-free.
|
||||||
[18:20:05] INFO: phase_3 complete: 51 session(s) added this phase, 82 total so far. State saved to D:\projects\UORM\timetable\result\schedule_state.json.
|
[18:55:33] INFO: phase_3 complete: 68 session(s) added this phase, 100 total so far. State saved to D:\projects\UORM\timetable\result\schedule_state.json.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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.
BIN
result/templates/room-wise/A-202.xlsx
Normal file
BIN
result/templates/room-wise/A-202.xlsx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
result/templates/room-wise/A-L03.xlsx
Normal file
BIN
result/templates/room-wise/A-L03.xlsx
Normal file
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.
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user