succesfull testing
This commit is contained in:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user