more complex
This commit is contained in:
@@ -20,6 +20,12 @@ earlier phase, later phases can never reuse it.
|
||||
sorted batch_code order: for each batch, every one of its remaining
|
||||
offerings is placed and locked before the next batch is considered.
|
||||
|
||||
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
|
||||
lecture_duration_minutes is 120 ("2 hours once a week") is instead placed
|
||||
in a special fixed 08:30-10:30 slot, which overlaps normal slot 0
|
||||
(09:00-10:30) in wall-clock time and is tracked as clashing with it.
|
||||
|
||||
Room selection always tries a course's preferred lecture_room_id /
|
||||
lab_room_id first, falling back to another same-type room only if the
|
||||
preferred one is busy for that day/slot.
|
||||
@@ -32,24 +38,49 @@ DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__
|
||||
|
||||
DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
|
||||
|
||||
# Theory grid: 5 x 90-minute slots with a lunch break, 08:30-16:30
|
||||
# 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": "08:30", "end": "10:00", "is_lunch": False},
|
||||
{"label": "S2", "start": "10:00", "end": "11:30", "is_lunch": False},
|
||||
{"label": "S3", "start": "11:30", "end": "13:00", "is_lunch": False},
|
||||
{"label": "LUNCH", "start": "13:00", "end": "13:30", "is_lunch": True},
|
||||
{"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 (skip lunch)
|
||||
# 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 teaching slots usable as a 180-minute lab double-slot
|
||||
LAB_SLOT_PAIRS = [
|
||||
(a, b)
|
||||
for a, b in zip(TEACHING_SLOT_INDICES, TEACHING_SLOT_INDICES[1:])
|
||||
if TIME_SLOTS[b]["start"] == TIME_SLOTS[a]["end"]
|
||||
# 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)
|
||||
]
|
||||
|
||||
|
||||
@@ -82,6 +113,7 @@ def load_data():
|
||||
c["lectures_per_week"] = int(c["lectures_per_week"])
|
||||
c["labs_per_week"] = int(c["labs_per_week"])
|
||||
c["is_general"] = c["is_general"].lower() == "yes"
|
||||
c["lecture_duration_minutes"] = int(c.get("lecture_duration_minutes") or 90)
|
||||
courses_by_id[c["course_id"]] = c
|
||||
|
||||
# normalize offerings: expand "batch1|batch2" section batch groups into
|
||||
@@ -183,16 +215,23 @@ class Scheduler:
|
||||
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):
|
||||
def _add_session(self, offering, day, slot_indices, room, is_long_lecture=False):
|
||||
course = offering["course"]
|
||||
start = TIME_SLOTS[slot_indices[0]]["start"]
|
||||
end = TIME_SLOTS[slot_indices[-1]]["end"]
|
||||
session_type = "lab" if len(slot_indices) > 1 else "theory"
|
||||
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,
|
||||
@@ -208,7 +247,7 @@ class Scheduler:
|
||||
return session
|
||||
|
||||
def _place_theory_session(self, offering, room_candidates, day_order):
|
||||
"""Finds and locks a free single-slot session; returns the session dict or None."""
|
||||
"""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:
|
||||
@@ -217,6 +256,26 @@ class Scheduler:
|
||||
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
|
||||
@@ -233,7 +292,7 @@ class Scheduler:
|
||||
course = offering["course"]
|
||||
room_candidates = self._room_candidates(course["lecture_room_id"], self.classrooms)
|
||||
for _ in range(course["lectures_per_week"]):
|
||||
session = self._place_theory_session(offering, room_candidates, preferred_order)
|
||||
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']}")
|
||||
|
||||
@@ -266,7 +325,7 @@ class Scheduler:
|
||||
phase_sessions.remove(losing_session)
|
||||
|
||||
room_candidates = self._room_candidates(offering["course"]["lecture_room_id"], self.classrooms)
|
||||
new_session = self._place_theory_session(offering, room_candidates, any_day_order)
|
||||
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
|
||||
@@ -326,7 +385,7 @@ class Scheduler:
|
||||
course = offering["course"]
|
||||
room_candidates = self._room_candidates(course["lecture_room_id"], self.classrooms)
|
||||
for _ in range(course["lectures_per_week"]):
|
||||
session = self._place_theory_session(offering, room_candidates, DAYS)
|
||||
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']})")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user