#!/usr/bin/env python3.11 import json import random from string import hexdigits from dataclasses import dataclass from app.util.globals import flex, Error from app.util.estimateGrade import getGrade from app.util.students import Student, studentsToDict from app.util.courses import Course, coursesToDict @dataclass class Conflict: PupilNum: str Email: str Type: str Code: str Conflict: str Missing: list = None # Takes in information to create or add a new conflict # Returns if the particular student has a previous error def newConflict( pupilNum: str, email: str, conflictType: str, code: str, description: str, logs: dict[str: list[Conflict]] ) -> bool: exists = True if pupilNum in logs else False log = Conflict( pupilNum, email, conflictType, code, description ) if exists: logs[pupilNum].append(log) else: logs[pupilNum] = [log] return exists if conflictType == "Critical" else False def insertConflictSolutions( pupilNum: str, logs: dict[str: list[Conflict]], data: list[dict[str: any]] ) -> None: for conflict in logs[pupilNum]: if conflict.Type == "Critical": conflict.Missing = data break # V3 differs a lot by V1/2 as it does not focus on fitting the classes # into the time table first. # It starts by trying to get all classes full and give all students a full class list. # Then it starts to attempt to fit all classes into a timetable, making corretions along # the way. Corrections being moving a students class def generateScheduleV3( students: list[Student], # Refer to /util/students.py to see the students list structure courses: dict[str: Course], # Refer to /util/courses.py to see the courses dictionary structure minReq: int = 18, # minimum requests for a class to run classCap: int = 30, # maximum students per class blockClassLimit: int = 40, # Block class limit is the number of classrooms available per block. Default 40 classes per block totalBlocks: int = 10, # total blocks between two semesters -> default is 10 for 5 per semester... or this can be 8 for 4 blocks per semester studentsDir: str = "../output/raw/students.json", conflictsDir: str = "../output/raw/conflicts.json", coursesDir: str = "../output/raw/courses.json" ) -> tuple[dict, Error]: # Returns the completed 'running' dictionary # Return error that totalBlocks is invalid if totalBlocks not in (10, 8): totalBlockError = Error('Invalid totalBlocks', 'An invalid \'totalBlocks\' value was provided -> must be 10 or 8') return (None, totalBlockError) # return none and signal failure # First we need to setup some values median: int = (minReq + classCap) // 2 blockPerSem: int = int(totalBlocks / 2) running: dict[str: dict] = {f'block{i}': {} for i in range(1, totalBlocks + 1)} def equal(l: list) -> list: # Used to equalize list of numbers q,r = divmod(sum(l),len(l)) return [q+1]*r + [q]*(len(l)-r) # Step 1 - Calculate which classes can run activeCourses = {} for student in students: # Tally class request for request in (request for request in student.Requests if not request.Alt and request.CrsNo not in flex): code = request.CrsNo courses[code].Requests += 1 # Add course to active list if enough requests if courses[code].Requests > minReq and courses[code].CrsNo not in activeCourses: activeCourses[code] = courses[code] # Step 2 - Generate empty classes allClassRunCounts: list[int] = [] courseRunInfo: dict[str: dict[str: any]] = {} # Generated now, used in step 4 emptyClasses: dict[str: dict[str: any]] = {} # List of all classes with how many students should be entered during step 3 # calculate number of times to run class for i in range(len(activeCourses)): index = list(activeCourses)[i] if index not in emptyClasses: emptyClasses[index] = {} classRunCount = activeCourses[index].Requests // median remaining = activeCourses[index].Requests % median # Put number of classRunCount classes in emptyClasses for j in range(classRunCount): emptyClasses[index][f"{index}-{hexdigits[j]}"] = { "CrsNo": index, "Description": activeCourses[index].Description, "expectedLen": median # Number of students expected in this class / may be altered later } # If remaining fit in open slots in existing classes if remaining <= classRunCount * (classCap - median): # Equally disperse remaining into existing classes for j in range(classRunCount): if remaining == 0: break emptyClasses[index][f"{index}-{hexdigits[j]}"]["expectedLen"] += 1 remaining -= 1 # If we can create a class using remaining, create class elif remaining >= minReq: # Create a class using remaining emptyClasses[index][f"{index}-{hexdigits[classRunCount]}"] = { "CrsNo": index, "Description": activeCourses[index].Description, "expectedLen": remaining } classRunCount += 1 # If a class previously existed if classRunCount >= 2: # Equalize (level) class expectedLen's expectedLengths = [emptyClasses[index][f"{index}-{hexdigits[j]}"]["expectedLen"] for j in range(classRunCount)] newExpectedLens = equal(expectedLengths) for j in range(len(newExpectedLens)): emptyClasses[index][f"{index}-{hexdigits[j]}"]["expectedLen"] = newExpectedLens[j] # Else if we can't fit remaining into available slots in existing classes, # and it's unable to create its own class, # and the required amount (minReq - remaining) to make a class is less than # the number that existing classes can provide (classRunCount * (median - minReq)) elif minReq - remaining < classRunCount * (median - minReq): # Take 1 from each class till min requirment met for j in range(classRunCount): emptyClasses[index][f"{index}-{hexdigits[j]}"]["expectedLen"] -= 1 remaining += 1 if remaining == minReq: break # Create a class using remaining + required amount from existing classes emptyClasses[index][f"{index}-{hexdigits[classRunCount]}"] = { "CrsNo": index, "Description": activeCourses[index].Description, "expectedLen": remaining } classRunCount += 1 # Equalize (level) class expectedLen's expectedLengths = [emptyClasses[index][f"{index}-{hexdigits[j]}"]["expectedLen"] for j in range(classRunCount)] newExpectedLens = equal(expectedLengths) for j in range(len(newExpectedLens)): emptyClasses[index][f"{index}-{hexdigits[j]}"]["expectedLen"] = newExpectedLens[j] else: # In the case that the remaining requests are unable to be resolved # Fill as many requests into existing classes. Any left that can't fit, # Will need to be ignored so later we can fold them into their alternative # requests for j in range(classRunCount): if remaining == 0: break if emptyClasses[index][f"{index}-{hexdigits[j]}"]["expectedLen"] < classCap: emptyClasses[index][f"{index}-{hexdigits[j]}"]["expectedLen"] += 1 remaining -= 1 courseRunInfo[index] = { "Total": classRunCount, "CrsNo": index } allClassRunCounts.append(classRunCount) # Step 3 - Fill 'emptyClasses' with Students selectedCourses = {} tempStudents = list(students) while len(tempStudents) > 0: # Choose random student to prevent any success # bias to students at the top of the list student = tempStudents[random.randint(0, len(tempStudents)-1)] alternates = [request for request in student.Requests if request.Alt] for request in (request for request in student.Requests if not request.Alt and request.CrsNo not in flex): course = request.CrsNo getAvailableCourse = True isAlt = False while getAvailableCourse: if course in emptyClasses: # if course exists, get first available class for cname in emptyClasses[course]: if cname in selectedCourses: if isAlt and emptyClasses[course][cname]["expectedLen"] < classCap: emptyClasses[course][cname]["expectedLen"] += 1 if len(selectedCourses[cname]["students"]) < emptyClasses[course][cname]["expectedLen"]: # Class exists with room for student selectedCourses[cname]["students"].append({ "Pupil #": student.PupilNum, "index": student.StudentIndex }) getAvailableCourse = False break elif len(selectedCourses[cname]["students"]) == emptyClasses[course][cname]["expectedLen"]: # If class is full, and there's no more classes available for that course if cname[len(cname)-1] == f"{len(emptyClasses[course])-1}": if len(alternates) > 0: # Use alternate course = alternates[0].CrsNo alternates.remove(alternates[0]) isAlt = True break else: # Force break loop, ignore and let an admin # handle options to solve for missing class getAvailableCourse = False break elif cname not in selectedCourses: selectedCourses[cname] = { "students": [{ "Pupil #": student.PupilNum, "index": student.StudentIndex }], "CrsNo": course, "Description": courses[course].Description } getAvailableCourse = False break elif course not in emptyClasses: if len(alternates) > 0: # Use alternate course = alternates[0].CrsNo alternates.remove(alternates[0]) isAlt = True else: # Force break loop, ignore and let an admin # handle options to solve for missing class getAvailableCourse = False students[student.StudentIndex].RemainingAlts = alternates tempStudents.remove(student) # Step 4 - Attempt to fit classes into timetable def stepIndex(offset: int, stepType: int) -> int: # ALl this logic handles taking the # of blocks per semester, 4 or 5 and calculating # the number used to step between the index of the first or second semester # stepType 0 is for stepping between first and second semester if stepType == 0: return blockPerSem if offset in (0, (-1 * (blockPerSem - 1))) else (-1 * (blockPerSem - 1)) # stepType 1 is for stepping between second and first semester elif stepType == 1: return (-1 * blockPerSem) if offset in (0, (blockPerSem + 1)) else (blockPerSem + 1) # Return Error if code is altered to cause error else: invalidStepTypeError = Error('Invalid stepType', 'An invalid \'stepType\' was passed to func \'stepIndex\'') return (None, invalidStepTypeError) # Create copy for step 6 courseRunInfoCopy = dict(courseRunInfo) while len(allClassRunCounts) > 0: # Get highest resource class (most times run) index = allClassRunCounts.index(max(allClassRunCounts)) course = list(courseRunInfo)[index] # Tally first and second semester allSemBlockLens = [len(running[f'block{i}']) for i in range(1, totalBlocks + 1)] # If there is more than one class Running if allClassRunCounts[index] > 1: blockIndex = allSemBlockLens.index(min(allSemBlockLens)) stepType = 0 if blockIndex < blockPerSem else 1 offset = 0 # Spread classes throughout both semesters for i in range(courseRunInfo[course]["Total"]): cname = f"{course}-{hexdigits[i]}" classInserted = False while not classInserted: blockIndex += offset if len(running[f'block{blockIndex+1}']) < blockClassLimit: running[list(running)[blockIndex]][cname] = { "CrsNo": course, "Description": emptyClasses[course][cname]["Description"], "students": selectedCourses[cname]["students"] } allClassRunCounts[index] -= 1 classInserted = True offset = stepIndex(offset, stepType) if blockIndex >= (totalBlocks - 1): blockIndex = 0 if stepType == 0 else blockPerSem offset = 0 # If the class only runs once, place in semester with least classes elif allClassRunCounts[index] == 1: # Equally disperse into semesters classes # Get block with least classes leastBlock = allSemBlockLens.index(min(allSemBlockLens)) + 1 cname = f"{course}-0" running[f"block{leastBlock}"][cname] = { "CrsNo": course, "Description": emptyClasses[course][cname]["Description"], "students": selectedCourses[cname]["students"], } allClassRunCounts[index] -= 1 # Remove course when fully inserted if allClassRunCounts[index] == 0: allClassRunCounts.remove(allClassRunCounts[index]) courseRunInfo.pop(list(courseRunInfo)[index]) # Step 5 - Fill student schedule for block in running: for cname in running[block]: for student in running[block][cname]["students"]: students[student["index"]].Schedule[block].append(cname) students[student["index"]].Classes += 1 # Step 6 - Evaluate, move students to fix conflicts conflictLogs: dict[str: list[Conflict]] = {} criticalCount, acceptableCount = 0, 0 c_mc_count, c_cr_count, a_mc_count = 0, 0, 0 studentsCritical, studentsAcceptable = 0, 0 for student in students: blocks = [student.Schedule[block] for block in student.Schedule] hasConflicts = True if sum(1 for b in blocks if len(b)>1) > 0 else False # If there is no conflicts # and classes inserted to is equal to expectedClasses # or classes the student is inserted to is missing # no more than two classes: # continue to next student if not hasConflicts and student.Classes == student.ExpectedClasses: continue elif not hasConflicts and (student.ExpectedClasses-2) <= student.Classes < student.ExpectedClasses: # TODO: Insert alternates if available? a_mc_count += 1 acceptableCount += 1 if not newConflict(student.PupilNum, "", "Acceptable", "A-MC", "Missing 1-2 Classses", conflictLogs): studentsAcceptable += 1 continue studentData = { "Pupil #": student.PupilNum, "index": student.StudentIndex } if hasConflicts: student.Classes = 0 missing = [] # Clear student schedule to restructure for block in student.Schedule: [running[block][cname]["students"].remove(studentData) for cname in student.Schedule[block]] student.Schedule[block] = [] # Find class in student schedule that's run the least classes, runCounts = [], [] for block in blocks: for cname in block: classes.append(cname[:-2]) runCounts.append(courseRunInfoCopy[cname[:-2]]["Total"]) # Rebuild student schedule availableBlocks = [f'block{i}' for i in range(1, totalBlocks + 1)] while len(classes) > 0: index = runCounts.index(min(runCounts)) # Get class least run found = False # find slot for class for block in availableBlocks: if found: break for cname in running[block]: if cname[:-2] == classes[index] and len(running[block][cname]["students"]) < classCap: running[block][cname]["students"].append(studentData) student.Schedule[block].append(cname) availableBlocks.remove(block) student.Classes += 1 found = True break if not found: # Determine all places class exists existsIn, existingClassNames = [], [] for block in running: for cname in running[block]: if cname[:-2] == classes[index] and len(running[block][cname]["students"]) < classCap: existsIn.append(block) existingClassNames.append(cname) # Attempt to fix solution = False if len(existsIn) > 0: for i, existing in enumerate(existsIn): if solution: break classOut = student.Schedule[existing][0] for block in running: if solution: break if block == existing or block not in availableBlocks: continue for cname in running[block]: if cname[:-2] == classOut[:-2] and len(running[block][cname]["students"]) < classCap: student.Classes += 1 # Move to existing class elsewhere student.Schedule[block].append(cname) running[block][cname]["students"].append(studentData) # Overwrite old class running[existing][student.Schedule[existing][0]]["students"].remove(studentData) student.Schedule[existing][0] = existingClassNames[i] running[existing][existingClassNames[i]]["students"].append(studentData) solution = True break if not solution: # Try alternate alternates = [alt.CrsNo for alt in students[student.StudentIndex].RemainingAlts if alt.CrsNo not in flex and alt.CrsNo in courseRunInfoCopy] if len(alternates) == 0: # If no alternates, create critical error c_cr_count += 1 criticalCount += 1 missing.append({ "block": f'block{index + 1}', "CrsNo": classes[index] }) if not newConflict( student.PupilNum, "", # Student Email "Critical", # Err type "C-CR", # Err code "Couldn't Resolve", # Err msg conflictLogs): # logs dir studentsCritical += 1 else: # Get alternate least run altRunCounts = [courseRunInfoCopy[alt]["Total"] for alt in alternates] altIndex = altRunCounts.index(min(altRunCounts)) # add alternate classes.append(alternates[altIndex]) runCounts.append(altRunCounts[altIndex]) # Remove alternate from remaining alternates for remaining in students[student.StudentIndex].RemainingAlts: if remaining.CrsNo == alternates[altIndex]: students[student.StudentIndex].RemainingAlts.remove(remaining) # Remove class after inserted or failed to insert classes.remove(classes[index]) runCounts.remove(runCounts[index]) # Collect missing course data and if len(missing) > 0: data = [] if student.Gradelevel is None: for obj in missing: data.append({ "CrsNo": obj["CrsNo"], "block": obj['block'], "solutions": None, "error": "Unable to find solutions, err no grade" }) else: for obj in missing: blockSolution = { "CrsNo": obj["CrsNo"], "block": obj['block'], "solutions": [] } for cname in running[obj['block']]: courseInfo = running[obj['block']][cname] courseGrade = getGrade(courseInfo['CrsNo'], courseInfo['Description']) if courseGrade is None: continue if (student.Gradelevel == courseGrade) or (student.Gradelevel == 12 and courseGrade == 11): if len(running[obj['block']][cname]["students"]) < classCap: blockSolution["solutions"].append({ "CrsNo": cname, "Description": running[obj['block']][cname]["Description"] }) data.append(blockSolution) insertConflictSolutions(student.PupilNum, conflictLogs, data) metSelfRequirements = True if student.Classes == student.ExpectedClasses else False if not metSelfRequirements: if (student.ExpectedClasses - 2) <= student.Classes < student.ExpectedClasses: a_mc_count += 1 acceptableCount += 1 if not newConflict(student.PupilNum, "", "Acceptable", "A-MC", "Missing 1-2 Classses", conflictLogs): studentsAcceptable += 1 elif student.Classes < (student.ExpectedClasses - 2): # Difference between classes inserted to and # expected classes is too great if student.PupilNum in conflictLogs: c_mc_count += 1 criticalCount += 1 if not newConflict(student.PupilNum, "", "Critical", "C-MC", "Missing too many Classses", conflictLogs): studentsCritical += 1 finalConflictLogs: dict[str: any] = { "Conflicts": {s: [c.__dict__ for c in conflictLogs[s]] for s in conflictLogs}, "Critical": { "Total": criticalCount, "Students": studentsCritical, "Errors": [{ "Total": c_mc_count, "Description": "Missing too many Classes", "Code": "C-MC" }, { "Total": c_cr_count, "Description": "Couldn't Resolve", "Code": "C-CR" }] }, "Acceptable": { "Total": acceptableCount, "Students": studentsAcceptable, "Errors": [{ "Total": a_mc_count, "Description": "Missing 1-2 Classes", "Code": "A-MC" }] } } # Log Conflict to records with open(conflictsDir, "w") as outfile: json.dump(finalConflictLogs, outfile, indent=2) # Insert flex (spare) course codes to empty blocks for student in students: for block in student.Schedule: if len(student.Schedule[block]) == 0: i = 0 if int(block[5:]) <= blockPerSem else 1 student.Schedule[block].append(flex[i]) # Read timetable and collect data on courses for block in running: for course in running[block]: c = running[block][course]["CrsNo"] if int(block[5:]) <= blockPerSem: courses[c].Sem1 += 1 else: courses[c].Sem2 += 1 courses[c].Occupied += len(running[block][course]["students"]) for course in courses: courses[course].Total = courses[course].Sem1 + courses[course].Sem2 courses[course].Seats = courses[course].Total * classCap # Update/log new student records with open(studentsDir, "w") as outfile: json.dump(studentsToDict(students), outfile, indent=2) # Update/log new course records with open(coursesDir, "w") as outfile: json.dump(coursesToDict(courses), outfile, indent=2) # Save timetable to json with open('./output/raw/json/timetable.json', 'w') as outfile: json.dump(running, outfile, indent=2) return (running, None)