commit f498e493afca224111595e30e24ac025ff499483 Author: Braeden Sowinski Date: Tue May 31 11:45:53 2022 -0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58b23a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Pycache +/__pycache__/* +/util/__pycache__/* +/scheduleGenerator/__pycache__/* + +# Test +/test.py + +# Test output +/output/* + +# Sample Data +/sample_data/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..60f4007 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Schedule Generator + +Hello there! This is the standalone repository for the schedule generator from my [school-management-api project](https://github.com/SowinskiBraeden/school-management-api) +The algorithms have been moved out of `tinker.py` into their own python +script from version 1 to 3. These are in the `/scheduleGenerator` folder. I suggest to look +at the latest work in `generator_v3.py` where the algorithm has come a long way. Using real 2018 course +selection data from my school I am able to test the script to its full potential compared to +V1 and V2. + +V3 has a entirely different approach from V1 and V2, that you can read about at the top of +the function in `generator_v3.py`. The function is broken up into 6 steps, each step is labeled within +the function with a comment, giving a brief explination of what that step contributes to the +algorithm. \ No newline at end of file diff --git a/error.py b/error.py new file mode 100644 index 0000000..b75e1be --- /dev/null +++ b/error.py @@ -0,0 +1,21 @@ +import json + +f = open('./output/students.json') +students = json.load(f) +f.close() + +couldnt_resolve = 0 +missing_classes = 0 +acceptable_missing_classes = 0 + +for student in students: + blocks = [student["schedule"][block] for block in student["schedule"]] + conflicts = sum(1 for b in blocks if len(b)>1) + if conflicts == 0 and student["classes"] == student["expectedClasses"]: continue + if conflicts > 0: couldnt_resolve += 1 + if (student["expectedClasses"] - 2) <= student["classes"] < student["expectedClasses"]: acceptable_missing_classes += 1 + if student["classes"] < (student["expectedClasses"] - 2): missing_classes += 1 + +print(f"Couldn't resolve : {couldnt_resolve}/{len(students)} - {round((couldnt_resolve/len(students))*100, 2)}%") +print(f"Missing classes : {missing_classes}/{len(students)} - {round((missing_classes/len(students))*100, 2)}%") +print(f"Acceptable Missing classes : {acceptable_missing_classes}/{len(students)} - {round((acceptable_missing_classes/len(students))*100, 2)}%") diff --git a/scheduleGenerator/generator_v1.py b/scheduleGenerator/generator_v1.py new file mode 100644 index 0000000..9cd4b72 --- /dev/null +++ b/scheduleGenerator/generator_v1.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +from util.courses import mockCourses, activeCourses # Fake courses +from util.mockStudents import generateMockStudents # Generate fake students +import json + + +''' + Block 1-4 is first semester while + block 5-8 is second semester +''' + +''' +schedule example: +schedule: { + "block1": "className", + "block2": "className", + "block3": "className", + "block4": "className" + "block5": "className", + "block6": "className", + "block7": "className", + "block8": "className" +} +''' + +''' +running example: +running: { + "block1": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block2": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block3": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block4": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block5": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block6": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block7": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block8": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}} +} +''' + +# Error 1: No classes in schedule can fit this student +# Error 2: No more room in schedule for another class + +minReq, median, classCap, blockClassLimit = 18, 24, 30, 12 +activeCourses = {} +running = { + "block1": {}, + "block2": {}, + "block3": {}, + "block4": {}, + "block5": {}, + "block6": {}, + "block7": {}, + "block8": {} +} + +# Currently V1 has an average of 9.4% success rate, with an avverage of 90.6% error rate +def generateScheduleV1(mockStudents, mockCourses): + # Collect data and calculate schedules + for student in mockStudents: + # Tally class request + for request in student["requests"]: + mockCourses[request]["totalrequests"] += 1 + mockCourses[request]["studentindexes"].append(mockStudents.index(student)) + # Add course to active list if enough requests + if mockCourses[request]["totalrequests"] > minReq and mockCourses[request]["code"] not in activeCourses: activeCourses[mockCourses[request]["code"]] = mockCourses[request] + + for student in mockStudents: + alternateOffset = len(student["requests"])-8 + alternateIndex = 8 + for i in range(len(student["requests"])-alternateOffset): # Subtract x classes as they are alternatives + currentCourse = student["requests"][i] + generate = True + while generate: + # If class is allowed to run + if currentCourse in activeCourses: + blockIndex = 1 + courseNum = 1 + getFreeBlock = True + while getFreeBlock: + block = f"block{blockIndex}" + cname = f"{mockCourses[currentCourse]['name']}-{courseNum}" + if cname in running[block]: + if student["schedule"][block] == "": # Add student to class + if len(running[block][cname]["students"]) < classCap: + running[block][cname]["students"].append(student["name"]) + student["schedule"][block] = cname + getFreeBlock = False + else: # Find next available class or create new one + if blockIndex == 8: # No available classes + if len(student["requests"]) == 8: + # This student never had any alternatives + # How to solve? + + # print(f"\nError 1: No more available classes for student {student['name']}") + generate = False + else: + if alternateIndex <= (len(student["requests"]) - 1): + currentCourse = student["requests"][alternateIndex] + alternateIndex += 1 + else: # No more alterantive + # print(f"\nError 1: No more available classes for student {student['name']}") + generate = False + getFreeBlock = False + else: + if (mockCourses[currentCourse]["teachers"] - courseNum) > 0: + courseNum += 1 + else: + blockIndex += 1 + courseNum = 1 + else: + if blockIndex == 8: # No available classes + if len(student["requests"]) == 8: + # This student never had any alternatives + # How to solve? + + # print(f"\nError 1: No more available classes for student {student['name']}") + err1 += 1 + generate = False + else: + if alternateIndex <= (len(student["requests"]) - 1): + currentCourse = student["requests"][alternateIndex] + alternateIndex += 1 + else: # No more alternatives + # print(f"\nError 1: No more available classes for student {student['name']}") + generate = False + getFreeBlock = False + else: + blockIndex += 1 + courseNum = 1 + else: + if blockIndex == 8: + # Class does not exists + # Create new class in first available slot + blockNum = 1 + while True: + newBlock = f"block{blockNum}" + if cname not in running[newBlock] and len(running[newBlock]) < blockClassLimit: + if student["schedule"][newBlock] == "": # Add student to class + running[newBlock][cname] = { + "name": mockCourses[currentCourse]["name"], + "students": [student["name"]] + } + student["schedule"][newBlock] = cname + break + else: + if blockNum == 8: + # All student classes have been filled + break + else: + blockNum += 1 + courseNum = 1 + else: + if blockNum == 8: + # No room in school for more classes + # print(f"\nError 2: No more room in school for another class") + break + else: + if (mockCourses[currentCourse]["teachers"] - courseNum) > 0: + courseNum += 1 + else: + blockNum += 1 + courseNum = 1 + break + else: + blockIndex += 1 + courseNum = 1 + break + elif currentCourse not in activeCourses: + if len(student["requests"]) == 8: + # This student never had any alternatives + # How to solve? + + # print(f"\nError 1: No more available classes for student {student['name']}") + generate = False + else: + if alternateIndex <= (len(student["requests"]) - 1): + currentCourse = student["requests"][alternateIndex] + alternateIndex += 1 + else: # Out of alternatives + # print(f"\nError 1: No more available classes for student {student['name']}") + generate = False + + return running + + +if __name__ == '__main__': + print("Processing...") + + mockStudents = generateMockStudents(400) + timetable = {} + timetable["Version"] = 1 + timetable["timetable"] = generateScheduleV1(mockStudents, mockCourses) + + with open("../output/timetable.json", "w") as outfile: + json.dump(timetable, outfile, indent=2) + + print("Done") diff --git a/scheduleGenerator/generator_v2.py b/scheduleGenerator/generator_v2.py new file mode 100644 index 0000000..9e56f12 --- /dev/null +++ b/scheduleGenerator/generator_v2.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +from util.courses import mockCourses, activeCourses # Fake courses +from util.mockStudents import generateMockStudents # Generate fake students +import math +import json + + +''' + Block 1-4 is first semester while + block 5-8 is second semester +''' + +''' +schedule example: +schedule: { + "block1": "className", + "block2": "className", + "block3": "className", + "block4": "className" + "block5": "className", + "block6": "className", + "block7": "className", + "block8": "className" +} +''' + +''' +running example: +running: { + "block1": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block2": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block3": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block4": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block5": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block6": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block7": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}}, + "block8": {classCode:{"className":name,"students":[student Name]},classCode:{"className":name,"students":[student Name]}} +} +''' + + +minReq, median, classCap, blockClassLimit = 18, 24, 30, 12 +mockStudents = [] +activeCourses = {} +running = { + "block1": {}, + "block2": {}, + "block3": {}, + "block4": {}, + "block5": {}, + "block6": {}, + "block7": {}, + "block8": {} +} + + +# Currently V2 has an average of 0.35% success rate, with an avverage of 99.65% error rate +def generateScheduleV2(mockStudents, mockCourses): + # Collect data and calculate schedules + for student in mockStudents: + # Tally class request + for request in student["requests"]: + mockCourses[request]["totalrequests"] += 1 + mockCourses[request]["studentindexes"].append(mockStudents.index(student)) + # Add course to active list if enough requests + if mockCourses[request]["totalrequests"] > minReq and mockCourses[request]["code"] not in activeCourses: activeCourses[mockCourses[request]["code"]] = mockCourses[request] + + # calculate # of times to run class + for i in range(len(activeCourses)): + index = list(activeCourses)[i] + classRunCount = math.floor(activeCourses[index]["totalrequests"] / classCap) + # If there is minReq+ requests left, 1 more class could be run + if (activeCourses[index]["totalrequests"] % classCap) > minReq: classRunCount += 1 + activeCourses[index]["classRunCount"] = classRunCount + + newBlockIndex = 1 + newCourseNum = 1 + while classRunCount > 0: + block = f"block{newBlockIndex}" + cname = f"{activeCourses[index]['name']}-{newCourseNum}" + if cname not in running[block] and len(running[block]) < blockClassLimit: + # Generate class and sub 1 from classRunCount + running[block][cname] = { + "name": activeCourses[index]["name"], + "students": [] + } + classRunCount -= 1 + else: + if newBlockIndex == 8: + if (activeCourses[index]["teachers"] - newCourseNum) > 0: + newCourseNum += 1 + newBlockIndex = 1 + else: + # No room in school for more classes + # print(f"\nError 2: No more room in school for another class") + classRunCount = 0 + else: newBlockIndex += 1 + + for student in mockStudents: + alternateOffset = len(student["requests"])-8 + alternateIndex = 8 + for i in range(len(student["requests"])-alternateOffset): # Subtract x classes as they are alternatives + currentCourse = student["requests"][i] + generate = True + courseNum = 1 + while generate: + # If class is allowed to run + if currentCourse in activeCourses: + blockIndex = 1 + getFreeBlock = True + cname = f"{mockCourses[currentCourse]['name']}-{courseNum}" + while getFreeBlock: + block = f"block{blockIndex}" + if cname in running[block]: + if student["schedule"][block] == "": # Add student to class + if len(running[block][cname]["students"]) < classCap: + running[block][cname]["students"].append(student["name"]) + student["schedule"][block] = cname + getFreeBlock = False + else: # Find next available class or create new one + if blockIndex == 8: # No available classes + if (activeCourses[index]["teachers"] - newCourseNum) > 0: + newCourseNum += 1 + newBlockIndex = 1 + else: + # No room in school for more classes + # print(f"\nError 2: No more room in school for another class") + classRunCount = 0 + if len(student["requests"]) == 8: + # This student never had any alternatives + # How to solve? + + # print(f"\nError 1: No more available classes for student {student['name']}") + generate = False + else: + if alternateIndex <= (len(student["requests"]) - 1): + currentCourse = student["requests"][alternateIndex] + alternateIndex += 1 + else: # No more alterantive + # print(f"\nError 1: No more available classes for student {student['name']}") + generate = False + getFreeBlock = False + else: blockIndex += 1 + else: + if blockIndex == 8: # No available classes + if len(student["requests"]) == 8: + # This student never had any alternatives + # How to solve? + + # print(f"\nError 1: No more available classes for student {student['name']}") + generate = False + else: + if alternateIndex <= (len(student["requests"]) - 1): + currentCourse = student["requests"][alternateIndex] + alternateIndex += 1 + else: # No more alternatives + # print(f"\nError 1: No more available classes for student {student['name']}") + generate = False + getFreeBlock = False + else: + if (mockCourses[currentCourse]["teachers"] - courseNum) > 0: + courseNum += 1 + else: + blockIndex += 1 + courseNum = 1 + else: + if blockIndex == 8: + # Class does not exists + # Resort to alternative + if alternateIndex <= (len(student["requests"]) - 1): + currentCourse = student["requests"][alternateIndex] + alternateIndex += 1 + else: # Out of alternatives + # print(f"\nError 1: No more available classes for student {student['name']}") + generate = False + break + else: + if (mockCourses[currentCourse]["teachers"] - courseNum) > 0: + courseNum += 1 + else: + blockIndex += 1 + courseNum = 1 + break + elif currentCourse not in activeCourses: + if len(student["requests"]) == 8: + # This student never had any alternatives + # How to solve? + + # print(f"\nError 1: No more available classes for student {student['name']}") + generate = False + else: + if alternateIndex <= (len(student["requests"]) - 1): + currentCourse = student["requests"][alternateIndex] + alternateIndex += 1 + else: # Out of alternatives + # print(f"\nError 1: No more available classes for student {student['name']}") + generate = False + + return running + + +if __name__ == '__main__': + print("Processing...") + + mockStudents = generateMockStudents(400) + timetable = {} + timetable["Version"] = 2 + timetable["timetable"] = generateScheduleV2(mockStudents, mockCourses) + + with open("../output/timetable.json", "w") as outfile: + json.dump(timetable, outfile, indent=2) + + print("Done") diff --git a/scheduleGenerator/generator_v3.py b/scheduleGenerator/generator_v3.py new file mode 100644 index 0000000..dfc3485 --- /dev/null +++ b/scheduleGenerator/generator_v3.py @@ -0,0 +1,613 @@ +#!/usr/bin/env python3 +import json +import math +import random +from inspect import currentframe + +from click import pass_context + +# Import from custom utilities +from util.mockStudents import getSampleStudents +from util.generateCourses import getSampleCourses + + +''' + Block 1-5 is first semester while + block 6-10 is second semester + + + schedule example: + schedule: { + "block1": "className", + "block2": "className", + "block3": "className", + ... + } + + + running example: + running: { + "block1": { + classCode: { + "className": name, + "students": [student Name] + }, + classCode: { + "className": name, + "students": [student Name] + } + }, + ... + } +''' + +def getLineNumber(): return currentframe().f_back.f_lineno + +# 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, type: str, code: str, description: str, logs: dict) -> bool: + exists = True if pupilNum in logs else False + if exists: logs[pupilNum].append({ + "Pupil #": pupilNum, + "Email": email, + "Type": type, + "Code": code, + "Conflict": description + }) + else: + logs[pupilNum] = [{ + "Pupil #": pupilNum, + "Email": email, + "Type": type, + "Code": code, + "Conflict": description + }] + return exists + +minReq, median, classCap = 18, 24, 30 +activeCourses = {} +running = { + "block1": {}, + "block2": {}, + "block3": {}, + "block4": {}, + "block5": {}, + "block6": {}, + "block7": {}, + "block8": {}, + "block9": {}, + "block10": {} +} + +# These are the codes for Flex (spare) blocks +# Semester 1 and 2 +flex = ["XAT--12A-S", "XAT--12B-S"] + +# 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, + courses: dict, + blockClassLimit: int=40, + studentsDir: str="../output/students.json", + conflictsDir: str="../output/conflicts.json" + ) -> dict[str, dict]: + + 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 + 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 = [] + courseRunInfo = {} # Generated now, used in step 4 + emptyClasses = {} # List of all classes with how many students should be entered during step 3 + # calculate # of times to run class + for i in range(len(activeCourses)): + index = list(activeCourses)[i] + if index not in emptyClasses: emptyClasses[index] = {} + classRunCount = math.floor(activeCourses[index]["Requests"] / median) + remaining = activeCourses[index]["Requests"] % median + + # Put # of classRunCount classes in emptyClasses + for j in range(classRunCount): + emptyClasses[index][f"{index}-{j}"] = { + "CrsNo": index, + "Description": activeCourses[index]["Description"], + "expectedLen": median # Number of students expected in this class / may be altered + } + + # If remaining fit in open slots in existing classes + if remaining <= classRunCount * (classCap - median): + # Equally disperse remaining into existing classes + while remaining > 0: + for j in range(classRunCount): + if remaining == 0: break + emptyClasses[index][f"{index}-{j}"]["expectedLen"] += 1 + remaining -= 1 + + # If we can create a class using remaining, but no other classes + # exists, create class, and do not equalize + elif remaining >= minReq: + # Create a class using remaining + emptyClasses[index][f"{index}-{classRunCount}"] = { + "CrsNo": index, + "Description": activeCourses[index]["Description"], + "expectedLen": remaining + } + + classRunCount += 1 + if classRunCount >= 2: + # Equalize (level) class expectedLen's + expectedLengths = [emptyClasses[index][f"{index}-{j}"]["expectedLen"] for j in range(classRunCount)] + newExpectedLens = equal(expectedLengths) + for j in range(len(newExpectedLens)): + emptyClasses[index][f"{index}-{j}"]["expectedLen"] = newExpectedLens[j] + + # Else if we can't fit remaining in open slots in existing classes + # and it is unable to create its own class, + # and requiered number to make a class is less than the max number we can provide from existing classes + elif minReq - remaining < classRunCount * (median - minReq): + # Take 1 from each class till min requirment met + for j in range(classRunCount): + emptyClasses[index][f"{index}-{j}"]["expectedLen"] -= 1 + remaining += 1 + if remaining == minReq: break + + # Create a class using remaining + emptyClasses[index][f"{index}-{classRunCount}"] = { + "CrsNo": index, + "Description": activeCourses[index]["Description"], + "expectedLen": remaining + } + + classRunCount += 1 + + # Equalize (level) class expectedLen's + expectedLengths = [emptyClasses[index][f"{index}-{j}"]["expectedLen"] for j in range(classRunCount)] + newExpectedLens = equal(expectedLengths) + for j in range(len(newExpectedLens)): + emptyClasses[index][f"{index}-{j}"]["expectedLen"] = newExpectedLens[j] + + else: + # In the case that the remaining requests are unable to be resolved + # Fill as many requests into class as possible, any left that can't fit, + # Will need to be ignored so later we can fold them into their alternative + # choices + for j in range(classRunCount): + if emptyClasses[index][f"{index}-{j}"]["expectedLen"] < classCap and remaining > 0: + emptyClasses[index][f"{index}-{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: + 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["Pupil #"], + "index": student["studentIndex"] + }) + getAvailableCourse = False + break + elif len(selectedCourses[cname]["students"]) == emptyClasses[course][cname]["expectedLen"]: + # If class is full, and is last class of 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["Pupil #"], + "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: + # stepType 0 is for stepping between first and second semester + if stepType == 0: + if offset == 0 or offset == -4: return 5 + else: return -4 + + # stepType 1 is for stepping between second and first semester + elif stepType == 1: + if offset == 0 or offset == 6: return -5 + else: return 6 + + # Return Error if code is altered to cause error + else: raise SystemExit(f"Invalid 'stepType' in func 'stepIndex' line {getLineNumber()}") + + while len(allClassRunCounts) > 0: + # Get highest resource class (most times run) + index = allClassRunCounts.index(min(allClassRunCounts)) + course = list(courseRunInfo)[index] + + # Tally first and second semester + sem1, sem2 = 0, 0 + sem1List, sem2List = {}, {} + for i in range(1, 6): + sem1 += len(running[f"block{i}"]) + sem1List[f"block{i}"] = running[f"block{i}"] + for i in range(5, 11): + sem2 += len(running[f"block{i}"]) + sem2List[f"block{i}"] = running[f"block{i}"] + + # If there is more than one class Running + if allClassRunCounts[index] > 1: + blockIndex = 0 if sem1 <= sem2 else 5 + stepType = 0 if sem1 <= sem2 else 1 + offset = 0 + + # Spread classes throughout both semesters + for i in range(courseRunInfo[course]["Total"]): + cname = f"{course}-{i}" + classInserted = False + while not classInserted: + + blockIndex += offset + if len(running[list(running)[blockIndex]]) < 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 >= 9: + blockIndex = 0 if sem1 <= sem2 else 5 + offset = 0 + + # If the class only runs once, place in semester with least classes + elif allClassRunCounts[index] == 1: + # Equally disperse into semesters classes + semBlocks = [] + offset = 1 + + # If sem1 is less than or equal to sem2, add to sem1 + if sem1 <= sem2: + for block in sem1List: + semBlocks.append(len(block)) + + # If sem2 is less than sem1, add to sem2 + elif sem1 > sem2: + offset = 5 + for block in sem2List: + semBlocks.append(len(block)) + + # Get block with least classes + leastBlock = semBlocks.index(min(semBlocks)) + cname = f"{course}-0" + + running[f"block{leastBlock+offset}"][course] = { + "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 = {} + criticalCount, acceptableCount = 0, 0 + c_mc_count, c_cr_count, a_mc_count = 0, 0, 0 + studentsCritical, studentsAcceptable = 0, 0 + + for student in students: + initialBlocks = [student["schedule"][block] for block in student["schedule"]] + conflicts = sum(1 for b in initialBlocks if len(b)>1) + hasConflicts = True if conflicts > 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"]: + a_mc_count += 1 + acceptableCount += 1 + if not newConflict(student["Pupil #"], "", "Acceptable", "A-MC", "Missing 1-2 Classses", conflictLogs): studentsAcceptable += 1 + continue + + studentData = { + "Pupil #": student["Pupil #"], + "index": student["studentIndex"] + } + + # If we are unable to solve, we add to exceptions + # and keep trying to resolve the rest of the schedule + exceptions = [] + + while hasConflicts: + # Check if conflicts have been resolved + blocks = [student["schedule"][block] for block in student["schedule"]] + # If there is exceptions, make them look normal in blocks + # list to ginore them when looking for clashes, and not to + # accidently overwrite while evaluating other classes + if len(exceptions) > 0: + for i in range(len(blocks)): + if i in exceptions: blocks[i] = ['EXPT'] + conflicts = sum(1 for b in blocks if len(b)>1) + + if conflicts == 0: hasConflicts = False + + elif conflicts > 0: + + blockLens = [len(block) for block in blocks] + freeBlocks = [index for index in range(len(blocks)) if len(blocks[index]) == 0] + + clashIndex = blockLens.index(max(blockLens)) + blockOut = f"block{clashIndex+1}" + classIndex = 0 + done, advancedConflict = False, False + + while not done: + classOut = blocks[clashIndex][classIndex] + found = False + for index in freeBlocks: + if found: break + if index != clashIndex: + blockIn = list(running)[index] + for cname in running[blockIn]: + if cname[:-2] == classOut[:-2] and len(running[blockIn][cname]["students"]) < classCap: + + # Update records + student["schedule"][blockOut].remove(classOut) + student["schedule"][blockIn].append(cname) + + running[blockOut][classOut]["students"].remove(studentData) + running[blockIn][cname]["students"].append(studentData) + + found = True + break + + if not found: + if classIndex < len(blocks[clashIndex])-1: classIndex += 1 + elif classIndex == len(blocks[clashIndex])-1: + classIndex = 0 + advancedConflict = True + done = True + else: + print(f"Fatal error ({getLineNumber()}): Impossible error") + continue + + elif found: done = True + + if advancedConflict: + attemptResolve = True + classOutIndex = 0 + while attemptResolve: + foundSolution = False + newClassOut = blocks[clashIndex][classOutIndex] + for blockIndex in range(len(running)): + if foundSolution: break + if blockIndex != clashIndex and blockIndex not in exceptions: + for cname in running[list(running)[blockIndex]]: + if cname[:-2] == newClassOut[:-2]: + blockIn = f"block{blockIndex+1}" + if blockIndex in freeBlocks: + if len(running[blockIn][cname]["students"]) < classCap: + # In the rare or impossible case a student was not inserted to a class + # And it weren't full, insert them into the class + student["schedule"][blockOut].remove(newClassOut) + student["schedule"][blockIn].append(cname) + + running[blockOut][newClassOut]["students"].remove(studentData) + running[blockIn][cname]["students"].append(studentData) + foundSolution = True + break + + elif len(blocks[blockIndex]) == 1: + oClassOut = blocks[blockIndex][0] + found_oBlockSolution = False + for oBlockIndex in range(len(running)): + if found_oBlockSolution: break + if oBlockIndex != clashIndex and oBlockIndex not in exceptions and oBlockIndex in freeBlocks: + for ocname in running[list(running)[oBlockIndex]]: + if ocname[:-2] == oClassOut[:-2]: + oBlockIn = f"block{oBlockIndex+1}" + if len(running[oBlockIn][ocname]["students"]) < classCap: + student["schedule"][blockOut].remove(newClassOut) + student["schedule"][blockIn].append(cname) + student["schedule"][blockIn].remove(oClassOut) + student["schedule"][oBlockIn].append(ocname) + + running[blockOut][newClassOut]["students"].remove(studentData) + running[blockIn][cname]["students"].append(studentData) + running[blockIn][oClassOut]["students"].remove(studentData) + running[oBlockIn][ocname]["students"].append(studentData) + + found_oBlockSolution = True + break + + + if not found_oBlockSolution: + exceptions.append(clashIndex) + criticalCount += 1 + c_cr_count += 1 + if not newConflict(student["Pupil #"], "", "Critial", "C-CR", "Couldn't Resolve", conflictLogs): studentsCritical += 1 + + foundSolution = True + break + + if not foundSolution: + if classOutIndex < len(blocks[clashIndex]) - 1: classOutIndex += 1 + elif classOutIndex == len(blocks[clashIndex]) - 1: + if len(student["remainingAlts"]) > 0: + exceptions.append(clashIndex) + criticalCount += 1 + c_cr_count += 1 + if not newConflict(student["Pupil #"], "", "Critical", "C-CR", "Couldn't Resolve", conflictLogs): studentsCritical += 1 + + attemptResolve = False + # Attempt to use alt + elif len(student["remainingAlts"]) == 0: + exceptions.append(clashIndex) + criticalCount += 1 + c_cr_count += 1 + if not newConflict(student["Pupil #"], "", "Critical", "C-CR", "Couldn't Resolve", conflictLogs): studentsCritical += 1 + + attemptResolve = False + else: print("Impossible - Thanos") + elif foundSolution: + attemptResolve = False + + else: + print(f"Fatal error ({getLineNumber()}): Impossible error") + continue + + metSelfRequirements = True if student["classes"] == student["expectedClasses"] else False + while not metSelfRequirements: + + if (student["expectedClasses"] - 2) <= student["classes"] < student["expectedClasses"]: + a_mc_count += 1 + acceptableCount += 1 + if not newConflict(student["Pupil #"], "", "Acceptable", "A-MC", "Missing 1-2 Classses", conflictLogs): studentsAcceptable += 1 + break + + elif student["classes"] < (student["expectedClasses"] - 2): + # Difference between classes inserted to and + # expected classes is too great, attempt to fix + if student["Pupil #"] in conflictLogs: + c_mc_count += 1 + criticalCount += 1 + if not newConflict(student["Pupil #"], "", "Critical", "C-MC", "Missing too many Classses", conflictLogs): studentsCritical += 1 + + break + + else: + print(f"Fatal error ({getLineNumber()}): Impossible error") + continue + + finalConflictLogs = { + "Conflicts": 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" + }] + } + } + + # Update Student records + with open(studentsDir, "w") as outfile: + json.dump(students, outfile, indent=2) + + # Log Conflict to records + with open(conflictsDir, "w") as outfile: + json.dump(finalConflictLogs, outfile, indent=2) + + return running + +if __name__ == '__main__': + print("Processing...") + + sampleStudents = getSampleStudents(True) + samplemockCourses = getSampleCourses(True) + timetable = {} + timetable["Version"] = 3 + timetable["timetable"] = generateScheduleV3(sampleStudents, samplemockCourses) + + with open("../output/timetable.json", "w") as outfile: + json.dump(timetable, outfile, indent=2) + + print("Done") \ No newline at end of file diff --git a/tinker.py b/tinker.py new file mode 100644 index 0000000..393134e --- /dev/null +++ b/tinker.py @@ -0,0 +1,98 @@ +#!/usr/bin/python3 +from prettytable import PrettyTable +from typing import Tuple +import json +import sys + +# Import required utilities +from util.mockStudents import generateMockStudents, getSampleStudents +from util.generateCourses import getSampleCourses +from util.courses import mockCourses + +# Import Algorithms +from scheduleGenerator.generator_v1 import generateScheduleV1 +from scheduleGenerator.generator_v2 import generateScheduleV2 +from scheduleGenerator.generator_v3 import generateScheduleV3 + +def errorOutput(students) -> Tuple[PrettyTable, dict, dict]: + # Error Table calulation / output + f = open('./output/conflicts.json') + conflicts = json.load(f) + f.close() + totalCritical = conflicts["Critical"]["Students"] + totalAcceptable = conflicts["Acceptable"]["Students"] + + t = PrettyTable(['Type', 'Error %', 'Success %', 'Student Error Ratio']) + + errorsC = round(totalCritical / len(students) * 100, 2) + successC = round(100 - errorsC, 2) + errorsA = round(totalAcceptable / len(students) * 100, 2) + successA = round(100 - errorsA, 2) + + t.add_row(['Critical', f"{errorsC} %", f"{successC} %", f"{totalCritical}/{len(students)} Students"]) + t.add_row(['Acceptable', f"{errorsA} %", f"{successA} %", f"{totalAcceptable}/{len(students)} Students"]) + + return t, conflicts["Critical"], conflicts["Acceptable"] + +if __name__ == '__main__': + + if len(sys.argv) == 1: + print("Missing argument") + exit() + + if sys.argv[1].upper() == 'V1': + print("Processing...") + + mockStudents = generateMockStudents(400) + timetable = {} + timetable["Version"] = 1 + timetable["timetable"] = generateScheduleV1(mockStudents, mockCourses) + + elif sys.argv[1].upper() == 'V2': + print("Processing...") + + mockStudents = generateMockStudents(400) + timetable = {} + timetable["Version"] = 2 + timetable["timetable"] = generateScheduleV2(mockStudents, mockCourses) + + + elif sys.argv[1].upper() == 'V3': + + print("Processing...\n") + + sampleStudents = getSampleStudents("./sample_data/course_selection_data.csv", True) + samplemockCourses = getSampleCourses("./sample_data/course_selection_data.csv", True) + timetable = {} + timetable["Version"] = 3 + timetable["timetable"] = generateScheduleV3(sampleStudents, samplemockCourses, 40, "./output/students.json", "./output/conflicts.json") + + errors, _, _ = errorOutput(sampleStudents) + print(errors) + + elif sys.argv[1].upper() == "ERRORS": + f = open('./output/students.json') + studentData = json.load(f) + f.close() + errors, critical, acceptable = errorOutput(studentData) + print() + print(errors) + + print(f"\n{critical['Total']} critical errors") + for i in range(len(critical["Errors"])): + print(f"x{critical['Errors'][i]['Total']} {critical['Errors'][i]['Code']} Errors: Critical - {critical['Errors'][i]['Description']}") + + print(f"\n{acceptable['Total']} acceptable errors") + for i in range(len(acceptable["Errors"])): + print(f"x{acceptable['Errors'][i]['Total']} {acceptable['Errors'][i]['Code']} Errors: Critical - {acceptable['Errors'][i]['Description']}") + + exit() + + else: + print("Invalid argument") + exit() + + with open("./output/timetable.json", "w") as outfile: + json.dump(timetable, outfile, indent=2) + + print("\nDone") diff --git a/util/courses.py b/util/courses.py new file mode 100644 index 0000000..fbf8d0c --- /dev/null +++ b/util/courses.py @@ -0,0 +1,222 @@ +# Fake sample data + +mockCourses = { + "P-CL": { + "name": "Pre-Calculus", + "code": "P-CL", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "CL": { + "name": "Calculus", + "code": "CL", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "EN-CW": { + "name": "English: Creative Writing", + "code": "EN-CW", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "EN-LS": { + "name": "English: Literary Studies", + "code": "EN-LS", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "EN-C": { + "name": "English: Compisition", + "code": "EN-C", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "S-J": { + "name": "Social Justice", + "code": "S-J", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "S-H": { + "name": "History", + "code": "S-H", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "S-P": { + "name": "Psychology", + "code": "S-P", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "SC-P": { + "name": "Physics", + "code": "SC-P", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "SC-B": { + "name": "Biology", + "code": "SC-B", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "SC-C": { + "name": "Chemistry", + "code": "SC-C", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "SC-ES": { + "name": "Earth Science", + "code": "SC-ES", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "M": { + "name": "Music", + "code": "M", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "CH": { + "name": "Choir", + "code": "CH", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "DN": { + "name": "Dance", + "code": "DN", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "CS": { + "name": "Computer Science", + "code": "CS", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "GD": { + "name": "Game Development", + "code": "GD", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "WW": { + "name": "Woodwork", + "code": "WW", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "MW": { + "name": "MetwalWork", + "code": "MW", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "M-WP": { + "name": "Math: Workplace", + "code": "M-WP", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "M-F": { + "name": "Math: Foundations", + "code": "M-F", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "BN": { + "name": "Band", + "code": "BN", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "TH": { + "name": "Theatre", + "code": "TH", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + }, + "VD-P": { + "name": "Video Production", + "code": "VD-P", + "credits": 4, + "gradelevel": 11, + "totalrequests": 0, + "studentindexes": [], + "teachers": 3 + } +} + +activeCourses = {} \ No newline at end of file diff --git a/util/generateCourses.py b/util/generateCourses.py new file mode 100644 index 0000000..d2b64a8 --- /dev/null +++ b/util/generateCourses.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +import json +import csv + +realCourses = {} + +# Get all courses from real sample data +def getSampleCourses(data_dir, log=False) -> dict: + with open(data_dir, newline='') as csvfile: + reader = csv.DictReader(csvfile) + for row in reader: + exists = False + for course in realCourses: + exists = True if realCourses[course]["CrsNo"] == row["CrsNo"] else False + if exists: break + if not exists: + realCourses[row["CrsNo"]] = { + "CrsNo": row["CrsNo"], + "Requests": 0, + "Description": row["Description"], + "Credits": 4, + "students": [] + } + + if log: + with open("./output/realCourses.json", "w") as outfile: + json.dump(realCourses, outfile, indent=2) + + return realCourses + + +if __name__ == '__main__': + courseSet: dict = getSampleCourses("../sample_data/course_selection_data.csv") + + with open("../output/realCourses.json", "w") as outfile: + json.dump(courseSet, outfile, indent=2) diff --git a/util/mockStudents.py b/util/mockStudents.py new file mode 100644 index 0000000..23a3f29 --- /dev/null +++ b/util/mockStudents.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +import random +import names +import json +import csv +import sys +from util.courses import mockCourses + +flex= ["XAT--12A-S", "XAT--12B-S"] + +mockStudents: list[dict] = [] + +# Generate n students for mock data +def generateMockStudents(n: int) -> list[dict]: + for _ in range(n): + newStudent = { + "name": names.get_full_name(), + "requests": [], # list of class codes + "schedule": { + "block1": "", + "block2": "", + "block3": "", + "block4": "", + "block5": "", + "block6": "", + "block7": "", + "block8": "" + } + } + # Get list of random class choices with no repeats + # 8 primary choices, 2 secondary choices + courseSelection = random.sample(range(0, len(mockCourses)), 10) + for courseNum in courseSelection: + newStudent["requests"].append(list(mockCourses)[courseNum]) + mockStudents.append(newStudent) + + return mockStudents + + +# sort real sample data into usable dictionary +def getSampleStudents(data_dir: str, log: bool = False) -> list[dict]: + with open(data_dir, newline='') as csvfile: + reader = csv.DictReader(csvfile) + for row in reader: + exists = False + for student in mockStudents: + exists = True if student["Pupil #"] == row["Pupil #"] else False + if exists: break + alternate = True if row["Alternate?"] == 'TRUE' else False + if exists: + if len(mockStudents[student["studentIndex"]]["requests"]) >= 10 and not alternate and row["CrsNo"] not in flex: alternate = True + mockStudents[student["studentIndex"]]["requests"].append({ + "CrsNo": row["CrsNo"], + "Description": row["Description"], + "alt": alternate + }) + if row["CrsNo"] not in flex and not alternate and mockStudents[student["studentIndex"]]["expectedClasses"] < 10: + mockStudents[student["studentIndex"]]["expectedClasses"] += 1 + else: + newStudent = { + "Pupil #": row["Pupil #"], + "requests": [{ + "CrsNo": row["CrsNo"], + "Description": row["Description"], + "alt": alternate + }], + "schedule": { + "block1": [], + "block2": [], + "block3": [], + "block4": [], + "block5": [], + "block6": [], + "block7": [], + "block8": [], + "block9": [], + "block10": [] + }, + "expectedClasses": 1, + "classes": 0, + "remainingAlts": [], + "studentIndex": len(mockStudents) + } + mockStudents.append(newStudent) + + if log: + with open("./output/students.json", "w") as outfile: + json.dump(mockStudents, outfile, indent=2) + + return mockStudents + + +if __name__ == '__main__': + if len(sys.argv) == 1: + print("Missing argument") + exit() + if sys.argv[1].lower() == 'sample': + studentRequests: list[dict] = getSampleStudents("../sample_data/course_selection_data.csv") + + with open("../output/students.json", "w") as outfile: + json.dump(studentRequests, outfile, indent=2) + elif sys.argv[1].lower() == 'mock': + studentRequests: list[dict] = generateMockStudents(400) + + with open("../output/students.json", "w") as outfile: + json.dump(studentRequests, outfile, indent=2) + else: + print("Invalid argument") + exit() + + print("done")