succesfull testing

This commit is contained in:
Talhadeveloperr
2026-09-09 18:55:53 +05:00
parent 868bcbe175
commit 9079ba2739
268 changed files with 2284 additions and 1495 deletions

View File

54
controller/solver/grid.py Normal file
View 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)
]

View 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")

View 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"]))

View 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"]))

View 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

View File

@@ -1,24 +1,23 @@
"""
Loads dummy CSV data and builds a clash-free schedule of sessions that all
five templates/*-wise template modules render from.
Loads dummy CSV data and exposes a thin Scheduler facade over the solver
engine in controller/solver/ (grid constants, PlacementBoard, and one
module per phase — phase1_general, phase2_labs, phase3_remaining).
Scheduling is done in three sequential, independently-verified phases (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.
Scheduling is done in three sequential, independently-verified phases, each
with its own tailored strategy (see controller/solver/phase*_*.py for the
per-phase logic). Each phase's placements are locked before the next phase
starts: once a (day, slot, room/instructor/batch) combination is marked
busy by an earlier phase, later phases can never reuse it — except Phase
3, which is allowed to backtrack and reopen *its own* earlier batches
(never Phase 1 or Phase 2) when a later batch can't otherwise be placed.
1. General courses (shared across every batch of a session), preferring
Tuesday/Thursday first. 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.
Tuesday/Thursday first, self-repaired until clash-free.
2. Labs, scheduled against Phase 1's fixed placements, then self-repaired
for lab-vs-lab clashes within Phase 2 only.
3. Remaining (non-general) theory sessions, scheduled batch by batch in
sorted batch_code order: for each batch, every one of its remaining
offerings is placed and locked before the next batch is considered.
sorted batch_code order, with backtracking into earlier Phase 3
batches when a later batch can't otherwise fit.
Time grid: 5 normal 90-minute slots, 09:00-16:30, no lunch break. Labs use
180-minute double-slots (09:00-12:00 and 12:00-15:00). A course whose
@@ -34,55 +33,15 @@ preferred one is busy for that day/slot.
import csv
import os
from solver.grid import ( # noqa: F401 (re-exported for templates/backward compatibility)
DAYS, TIME_SLOTS, TEACHING_SLOT_INDICES, LAB_SLOT_PAIRS,
LONG_LECTURE_SLOT_INDEX, LONG_LECTURE_START, LONG_LECTURE_END, DISPLAY_ROWS,
)
from solver.placement_board import PlacementBoard
from solver import phase1_general, phase2_labs, phase3_remaining
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "dummy")
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):
path = os.path.join(DATA_DIR, relative_path)
@@ -139,263 +98,44 @@ def load_data():
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):
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"]
self.board = PlacementBoard(data)
# busy[(day, slot_index)] -> set of keys already occupied
self.room_busy = {}
self.instructor_busy = {}
self.batch_busy = {}
self.sessions = []
@property
def sessions(self):
return self.board.sessions
def to_state(self):
"""
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),
}
return self.board.to_state()
@classmethod
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.sessions = list(state["sessions"])
scheduler.room_busy = _load_busy(state["room_busy"])
scheduler.instructor_busy = _load_busy(state["instructor_busy"])
scheduler.batch_busy = _load_busy(state["batch_busy"])
scheduler.board = PlacementBoard.from_state(data, state)
return scheduler
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
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_1_general_courses(self):
return phase1_general.run(self.board, self.data)
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"]
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:]
return phase2_labs.run(self.board, self.data)
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_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:]
return phase3_remaining.run(self.board, self.data)
def run(self):
self.phase_1_general_courses()
self.phase_2_labs()
self.phase_3_remaining_courses()
return self.sessions
return self.board.sessions
def build_schedule():

View File

@@ -3,8 +3,10 @@ A-101,Room 101,A,classroom,60
A-102,Room 102,A,classroom,60
A-103,Room 103,A,classroom,50
A-201,Room 201,A,classroom,60
A-202,Room 202,A,classroom,60
A-L01,Computer Lab 1,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-102,Room 102 (B),B,classroom,50
B-L01,Electronics Lab,B,lab,30
1 room_id room_name block_id room_type capacity
3 A-102 Room 102 A classroom 60
4 A-103 Room 103 A classroom 50
5 A-201 Room 201 A classroom 60
6 A-202 Room 202 A classroom 60
7 A-L01 Computer Lab 1 A lab 35
8 A-L02 Computer Lab 2 A lab 35
9 A-L03 Computer Lab 3 A lab 35
10 B-101 Room 101 (B) B classroom 50
11 B-102 Room 102 (B) B classroom 50
12 B-L01 Electronics Lab B lab 30

View File

@@ -2,43 +2,43 @@ course_id,section_id,batch_codes,instructor_code
CS101,A,25021519,1001
CS201,A,25021519,1002
CS301,A,25021519,1003
CS302,A,25021519,1001
CS302,A,25021519,1009
CS101,B,25021520,1001
CS201,B,25021520,1002
CS301,B,25021520,1003
CS302,B,25021520,1001
CS302,B,25021520,1012
EE101,A,25031519,1004
EE201,A,25031519,1005
EE301,A,25031519,1004
EE301,A,25031519,1010
EE101,B,25031520,1004
EE201,B,25031520,1005
EE301,B,25031520,1004
EE301,B,25031520,1010
BBA101,A,25041519,1007
BBA201,A,25041519,1008
BBA301,A,25041519,1007
BBA301,A,25041519,1011
BBA101,B,25041520,1007
BBA201,B,25041520,1008
BBA301,B,25041520,1007
BBA301,B,25041520,1011
CS101,C,26021519,1001
CS201,C,26021519,1002
CS301,C,26021519,1003
CS302,C,26021519,1001
CS302,C,26021519,1009
CS101,D,26021520,1001
CS201,D,26021520,1002
CS301,D,26021520,1003
CS302,D,26021520,1001
CS302,D,26021520,1012
EE101,C,26031519,1004
EE201,C,26031519,1005
EE301,C,26031519,1004
EE301,C,26031519,1010
EE101,D,26031520,1004
EE201,D,26031520,1005
EE301,D,26031520,1004
EE301,D,26031520,1010
BBA101,C,26041519,1007
BBA201,C,26041519,1008
BBA301,C,26041519,1007
BBA301,C,26041519,1011
BBA101,D,26041520,1007
BBA201,D,26041520,1008
BBA301,D,26041520,1007
BBA301,D,26041520,1011
GEN101,ALL,25021519|25021520|25031519|25031520|25041519|25041520,1002
GEN102,ALL,25021519|25021520|25031519|25031520|25041519|25041520,1006
GEN103,ALL,25021519|25021520|25031519|25031520|25041519|25041520,1006
1 course_id section_id batch_codes instructor_code
2 CS101 A 25021519 1001
3 CS201 A 25021519 1002
4 CS301 A 25021519 1003
5 CS302 A 25021519 1001 1009
6 CS101 B 25021520 1001
7 CS201 B 25021520 1002
8 CS301 B 25021520 1003
9 CS302 B 25021520 1001 1012
10 EE101 A 25031519 1004
11 EE201 A 25031519 1005
12 EE301 A 25031519 1004 1010
13 EE101 B 25031520 1004
14 EE201 B 25031520 1005
15 EE301 B 25031520 1004 1010
16 BBA101 A 25041519 1007
17 BBA201 A 25041519 1008
18 BBA301 A 25041519 1007 1011
19 BBA101 B 25041520 1007
20 BBA201 B 25041520 1008
21 BBA301 B 25041520 1007 1011
22 CS101 C 26021519 1001
23 CS201 C 26021519 1002
24 CS301 C 26021519 1003
25 CS302 C 26021519 1001 1009
26 CS101 D 26021520 1001
27 CS201 D 26021520 1002
28 CS301 D 26021520 1003
29 CS302 D 26021520 1001 1012
30 EE101 C 26031519 1004
31 EE201 C 26031519 1005
32 EE301 C 26031519 1004 1010
33 EE101 D 26031520 1004
34 EE201 D 26031520 1005
35 EE301 D 26031520 1004 1010
36 BBA101 C 26041519 1007
37 BBA201 C 26041519 1008
38 BBA301 C 26041519 1007 1011
39 BBA101 D 26041520 1007
40 BBA201 D 26041520 1008
41 BBA301 D 26041520 1007 1011
42 GEN101 ALL 25021519|25021520|25031519|25031520|25041519|25041520 1002
43 GEN102 ALL 25021519|25021520|25031519|25031520|25041519|25041520 1006
44 GEN103 ALL 25021519|25021520|25031519|25031520|25041519|25041520 1006

View File

@@ -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
CS101,Programming Fundamentals,4,2,1,no,A-101,A-L01,90
CS201,Data Structures,4,2,1,no,A-102,A-L01,90
CS301,Operating Systems,3,1,1,no,A-101,A-L02,120
CS302,Database Systems,4,2,2,no,A-102,A-L02,90
CS201,Data Structures,4,2,1,no,A-102,A-L02,90
CS301,Operating Systems,3,1,1,no,A-101,A-L03,120
CS302,Database Systems,4,2,1,no,A-102,A-L01,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
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
GEN101,Functional English,3,2,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
1 course_id course_name credit_hours lectures_per_week labs_per_week is_general lecture_room_id lab_room_id lecture_duration_minutes
2 CS101 Programming Fundamentals 4 2 1 no A-101 A-L01 90
3 CS201 Data Structures 4 2 1 no A-102 A-L01 A-L02 90
4 CS301 Operating Systems 3 1 1 no A-101 A-L02 A-L03 120
5 CS302 Database Systems 4 2 2 1 no A-102 A-L02 A-L01 90
6 EE101 Basic Electronics 4 2 1 no A-201 A-L02 90
7 EE201 Digital Logic Design 4 2 1 no B-101 B-L01 90
8 EE301 Signals and Systems 3 1 0 no B-102 120
11 BBA301 Marketing Management 3 1 0 no A-103 120
12 GEN101 Functional English 3 2 0 yes A-103 90
13 GEN102 Islamic Studies 2 1 0 yes A-103 90
14 GEN103 Pakistan Studies 2 1 0 yes B-101 A-202 120

View File

@@ -38,8 +38,9 @@ def main():
],
)
# ---- rooms: deliberately tight relative to demand (see offerings
# below) so room contention is real, spread across both blocks ----
# ---- rooms: tight enough to keep real contention, but with enough
# total capacity that a correct solver can always find a fit (room
# capacity/student-count is not a constraint here) ----
write_csv(
"blocks/rooms.csv",
["room_id", "room_name", "block_id", "room_type", "capacity"],
@@ -48,8 +49,10 @@ def main():
["A-102", "Room 102", "A", "classroom", 60],
["A-103", "Room 103", "A", "classroom", 50],
["A-201", "Room 201", "A", "classroom", 60],
["A-202", "Room 202", "A", "classroom", 60],
["A-L01", "Computer Lab 1", "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-102", "Room 102 (B)", "B", "classroom", 50],
["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
# 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
# more courses per department than the room/instructor pool can
# comfortably absorb without contention.
# 120, lectures_per_week 1). A course's lab, if it has one, is always
# exactly once a week (180 minutes) — never more. Deliberately more
# courses per department than the room/instructor pool can comfortably
# absorb without contention.
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],
["CS201", "Data Structures", 4, 2, 1, "no", "A-102", "A-L01", 90],
["CS301", "Operating Systems", 3, 1, 1, "no", "A-101", "A-L02", 120],
["CS302", "Database Systems", 4, 2, 2, "no", "A-102", "A-L02", 90],
["CS201", "Data Structures", 4, 2, 1, "no", "A-102", "A-L02", 90],
["CS301", "Operating Systems", 3, 1, 1, "no", "A-101", "A-L03", 120],
["CS302", "Database Systems", 4, 2, 1, "no", "A-102", "A-L01", 90],
# EE department courses
["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],
@@ -117,7 +123,7 @@ def main():
# General (shared) courses
["GEN101", "Functional English", 3, 2, 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(
"courses/courses.csv",
@@ -139,26 +145,46 @@ def main():
"EE": ["EE101", "EE201", "EE301"],
"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 = {
"CS": ["1001", "1002", "1003"],
"EE": ["1004", "1005"],
"BBA": ["1007", "1008"],
"CS": ["1001", "1002", "1003", "1009"],
"EE": ["1004", "1005", "1010"],
"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 = []
section_letters = ["A", "B", "C", "D"]
dept_section_counter = {"CS": 0, "EE": 0, "BBA": 0}
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
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]):
# Deliberately narrow instructor rotation (2-3 instructors
# covering 3-4 courses per department) so the same instructor
# teaches multiple courses/sections -> instructor contention.
if course_id in course_instructor_rotation:
rotation = course_instructor_rotation[course_id]
instr = rotation[section_index % len(rotation)]
else:
instr = instr_pool[i % len(instr_pool)]
offerings.append([course_id, section_id, batch_code, instr])
@@ -190,6 +216,10 @@ def main():
"1006": "Ms. Hira Shah",
"1007": "Dr. Nadia Farooq",
"1008": "Mr. Kamran Sheikh",
"1009": "Ms. Ayesha Malik",
"1010": "Mr. Imran Qureshi",
"1011": "Dr. Saima Aziz",
**instructor_names_extra,
}
instructors = []
for course_id, section_id, batch_codes, instr_code in offerings:

View File

@@ -2,43 +2,43 @@ instructor_code,instructor_name,course_id,section_id
1001,Dr. Ahsan Raza,CS101,A
1002,Ms. Sana Tariq,CS201,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
1002,Ms. Sana Tariq,CS201,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
1005,Mr. Usman Ali,EE201,A
1004,Dr. Farah Iqbal,EE301,A
1010,Mr. Imran Qureshi,EE301,A
1004,Dr. Farah Iqbal,EE101,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
1008,Mr. Kamran Sheikh,BBA201,A
1007,Dr. Nadia Farooq,BBA301,A
1011,Dr. Saima Aziz,BBA301,A
1007,Dr. Nadia Farooq,BBA101,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
1002,Ms. Sana Tariq,CS201,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
1002,Ms. Sana Tariq,CS201,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
1005,Mr. Usman Ali,EE201,C
1004,Dr. Farah Iqbal,EE301,C
1010,Mr. Imran Qureshi,EE301,C
1004,Dr. Farah Iqbal,EE101,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
1008,Mr. Kamran Sheikh,BBA201,C
1007,Dr. Nadia Farooq,BBA301,C
1011,Dr. Saima Aziz,BBA301,C
1007,Dr. Nadia Farooq,BBA101,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
1006,Ms. Hira Shah,GEN102,ALL
1006,Ms. Hira Shah,GEN103,ALL
1 instructor_code instructor_name course_id section_id
2 1001 Dr. Ahsan Raza CS101 A
3 1002 Ms. Sana Tariq CS201 A
4 1003 Mr. Bilal Khan CS301 A
5 1001 1009 Dr. Ahsan Raza Ms. Ayesha Malik CS302 A
6 1001 Dr. Ahsan Raza CS101 B
7 1002 Ms. Sana Tariq CS201 B
8 1003 Mr. Bilal Khan CS301 B
9 1001 1012 Dr. Ahsan Raza Ms. Rabia Nasir CS302 B
10 1004 Dr. Farah Iqbal EE101 A
11 1005 Mr. Usman Ali EE201 A
12 1004 1010 Dr. Farah Iqbal Mr. Imran Qureshi EE301 A
13 1004 Dr. Farah Iqbal EE101 B
14 1005 Mr. Usman Ali EE201 B
15 1004 1010 Dr. Farah Iqbal Mr. Imran Qureshi EE301 B
16 1007 Dr. Nadia Farooq BBA101 A
17 1008 Mr. Kamran Sheikh BBA201 A
18 1007 1011 Dr. Nadia Farooq Dr. Saima Aziz BBA301 A
19 1007 Dr. Nadia Farooq BBA101 B
20 1008 Mr. Kamran Sheikh BBA201 B
21 1007 1011 Dr. Nadia Farooq Dr. Saima Aziz BBA301 B
22 1001 Dr. Ahsan Raza CS101 C
23 1002 Ms. Sana Tariq CS201 C
24 1003 Mr. Bilal Khan CS301 C
25 1001 1009 Dr. Ahsan Raza Ms. Ayesha Malik CS302 C
26 1001 Dr. Ahsan Raza CS101 D
27 1002 Ms. Sana Tariq CS201 D
28 1003 Mr. Bilal Khan CS301 D
29 1001 1012 Dr. Ahsan Raza Ms. Rabia Nasir CS302 D
30 1004 Dr. Farah Iqbal EE101 C
31 1005 Mr. Usman Ali EE201 C
32 1004 1010 Dr. Farah Iqbal Mr. Imran Qureshi EE301 C
33 1004 Dr. Farah Iqbal EE101 D
34 1005 Mr. Usman Ali EE201 D
35 1004 1010 Dr. Farah Iqbal Mr. Imran Qureshi EE301 D
36 1007 Dr. Nadia Farooq BBA101 C
37 1008 Mr. Kamran Sheikh BBA201 C
38 1007 1011 Dr. Nadia Farooq Dr. Saima Aziz BBA301 C
39 1007 Dr. Nadia Farooq BBA101 D
40 1008 Mr. Kamran Sheikh BBA201 D
41 1007 1011 Dr. Nadia Farooq Dr. Saima Aziz BBA301 D
42 1002 Ms. Sana Tariq GEN101 ALL
43 1006 Ms. Hira Shah GEN102 ALL
44 1006 Ms. Hira Shah GEN103 ALL

View File

@@ -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:19:48] INFO: Phase 1/3: scheduling general courses (Tue/Thu preferred)...
[18:19:48] 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:19:57] 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: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:20:05] INFO: Phase 3/3: scheduling remaining courses (batch by batch)...
[18:20:05] 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:26] INFO: clear_all: schedule state, result templates, and log have been reset. Run phase_1 to start over.
[18:55:26] INFO: Phase 1/3: scheduling general courses (Tue/Thu preferred)...
[18:55:26] INFO: [Phase 1 (general courses)] No conflicts found — clash-free.
[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:55:29] INFO: Phase 2/3: scheduling labs (on top of phase_1's fixed placements)...
[18:55:29] INFO: [Phase 2 (general courses + labs)] No conflicts found — clash-free.
[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:55:32] INFO: Phase 3/3: scheduling remaining courses (batch by batch)...
[18:55:32] INFO: [Phase 3 (full schedule)] No conflicts found — clash-free.
[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.

Some files were not shown because too many files have changed in this diff Show More