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