From 283c5e72ff596d9565c3a672dfcf72397a84604d Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Wed, 30 Nov 2022 19:14:02 -0800 Subject: [PATCH] add student dataclass --- app/generator.py | 111 +++++++++++++++++++------------------ app/util/convertRawData.py | 8 +-- app/util/courses.py | 7 +++ app/util/estimateGrade.py | 6 +- app/util/students.py | 78 ++++++++++++++++---------- main.py | 12 ++-- 6 files changed, 124 insertions(+), 98 deletions(-) diff --git a/app/generator.py b/app/generator.py index 151f417..a80284f 100644 --- a/app/generator.py +++ b/app/generator.py @@ -5,6 +5,7 @@ from string import hexdigits from app.util.globals import flex, Error from app.util.estimateGrade import getGrade +from app.util.students import Student, studentsToDict # Takes in information to create or add a new conflict # Returns if the particular student has a previous error @@ -44,12 +45,12 @@ def insertConflictSolutions( # 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, # Refer to /util/students.py to see the students list structure - courses: dict, # 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 + students: list[Student], # Refer to /util/students.py to see the students list structure + courses: dict, # 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" @@ -77,8 +78,8 @@ def generateScheduleV3( 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"] + 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: @@ -183,9 +184,9 @@ def generateScheduleV3( # 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"] + 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: @@ -198,8 +199,8 @@ def generateScheduleV3( 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"] + "Pupil #": student.PupilNum, + "index": student.StudentIndex }) getAvailableCourse = False break @@ -208,7 +209,7 @@ def generateScheduleV3( if cname[len(cname)-1] == f"{len(emptyClasses[course])-1}": if len(alternates) > 0: # Use alternate - course = alternates[0]["CrsNo"] + course = alternates[0].CrsNo alternates.remove(alternates[0]) isAlt = True break @@ -220,8 +221,8 @@ def generateScheduleV3( elif cname not in selectedCourses: selectedCourses[cname] = { "students": [{ - "Pupil #": student["Pupil #"], - "index": student["studentIndex"] + "Pupil #": student.PupilNum, + "index": student.StudentIndex }], "CrsNo": course, "Description": courses[course]["Description"] @@ -232,7 +233,7 @@ def generateScheduleV3( elif course not in emptyClasses: if len(alternates) > 0: # Use alternate - course = alternates[0]["CrsNo"] + course = alternates[0].CrsNo alternates.remove(alternates[0]) isAlt = True else: @@ -240,7 +241,7 @@ def generateScheduleV3( # handle options to solve for missing class getAvailableCourse = False - students[student["studentIndex"]]["remainingAlts"] = alternates + students[student.StudentIndex].RemainingAlts = alternates tempStudents.remove(student) @@ -323,8 +324,8 @@ def generateScheduleV3( 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 + students[student["index"]].Schedule[block].append(cname) + students[student["index"]].Classes += 1 # Step 6 - Evaluate, move students to fix conflicts @@ -334,7 +335,7 @@ def generateScheduleV3( studentsCritical, studentsAcceptable = 0, 0 for student in students: - blocks = [student["schedule"][block] for block in student["schedule"]] + 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 @@ -342,26 +343,26 @@ def generateScheduleV3( # 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"]: + 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["Pupil #"], "", "Acceptable", "A-MC", "Missing 1-2 Classses", conflictLogs): studentsAcceptable += 1 + if not newConflict(student.PupilNum, "", "Acceptable", "A-MC", "Missing 1-2 Classses", conflictLogs): studentsAcceptable += 1 continue studentData = { - "Pupil #": student["Pupil #"], - "index": student["studentIndex"] + "Pupil #": student.PupilNum, + "index": student.StudentIndex } if hasConflicts: - student["classes"] = 0 + 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] = [] + 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 = [], [] @@ -382,9 +383,9 @@ def generateScheduleV3( 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) + student.Schedule[block].append(cname) availableBlocks.remove(block) - student["classes"] += 1 + student.Classes += 1 found = True break @@ -402,21 +403,21 @@ def generateScheduleV3( if len(existsIn) > 0: for i, existing in enumerate(existsIn): if solution: break - classOut = student["schedule"][existing][0] + 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 + student.Classes += 1 # Move to existing class elsewhere - student["schedule"][block].append(cname) + 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][student.Schedule[existing][0]]["students"].remove(studentData) + student.Schedule[existing][0] = existingClassNames[i] running[existing][existingClassNames[i]]["students"].append(studentData) solution = True @@ -424,7 +425,7 @@ def generateScheduleV3( 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] + 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 @@ -435,7 +436,7 @@ def generateScheduleV3( }) if not newConflict( - student["Pupil #"], + student.PupilNum, "", # Student Email "Critical", # Err type "C-CR", # Err code @@ -453,9 +454,9 @@ def generateScheduleV3( 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) + 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]) @@ -466,7 +467,7 @@ def generateScheduleV3( if len(missing) > 0: data = [] - if student["gradelevel"] is None: + if student.Gradelevel is None: for obj in missing: data["missing"].append({ "CrsNo": obj["CrsNo"], @@ -482,7 +483,7 @@ def generateScheduleV3( 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 (student.Gradelevel == courseGrade) or (student.Gradelevel == 12 and courseGrade == 11): if len(running[obj['block']][cname]["students"]) < classCap: blockSolution["solutions"].append({ "CrsNo": cname, @@ -491,23 +492,23 @@ def generateScheduleV3( data.append(blockSolution) - insertConflictSolutions(student["Pupil #"], conflictLogs, data) + insertConflictSolutions(student.PupilNum, conflictLogs, data) - metSelfRequirements = True if student["classes"] == student["expectedClasses"] else False + metSelfRequirements = True if student.Classes == student.ExpectedClasses else False if not metSelfRequirements: - if (student["expectedClasses"] - 2) <= student["classes"] < student["expectedClasses"]: + 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 + if not newConflict(student.PupilNum, "", "Acceptable", "A-MC", "Missing 1-2 Classses", conflictLogs): studentsAcceptable += 1 - elif student["classes"] < (student["expectedClasses"] - 2): + elif student.Classes < (student.ExpectedClasses - 2): # Difference between classes inserted to and # expected classes is too great - if student["Pupil #"] in conflictLogs: + if student.PupilNum in conflictLogs: c_mc_count += 1 criticalCount += 1 - if not newConflict(student["Pupil #"], "", "Critical", "C-MC", "Missing too many Classses", conflictLogs): studentsCritical += 1 + if not newConflict(student.PupilNum, "", "Critical", "C-MC", "Missing too many Classses", conflictLogs): studentsCritical += 1 finalConflictLogs = { "Conflicts": conflictLogs, @@ -541,10 +542,10 @@ def generateScheduleV3( # Insert flex (spare) course codes to empty blocks for student in students: - for block in student["schedule"]: - if len(student["schedule"][block]) == 0: + 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]) + student.Schedule[block].append(flex[i]) # Read timetable and collect data on courses for block in running: @@ -559,7 +560,7 @@ def generateScheduleV3( # Update/log new student records with open(studentsDir, "w") as outfile: - json.dump(students, outfile, indent=2) + json.dump(studentsToDict(students), outfile, indent=2) # Update/log new course records with open(coursesDir, "w") as outfile: diff --git a/app/util/convertRawData.py b/app/util/convertRawData.py index 95ac3ec..8d511bb 100644 --- a/app/util/convertRawData.py +++ b/app/util/convertRawData.py @@ -44,7 +44,7 @@ def putScheduleToWord( # create an instance of a word doc. doc = docx.Document() - doc.add_heading(f'Schedule for Student {student["Pupil #"]}') + doc.add_heading(f'Schedule for Student {student.PupilNum}') # Create a table object table = doc.add_table(rows=1, cols=3) @@ -62,12 +62,12 @@ def putScheduleToWord( table.columns[2].width = Inches(0.75) for block in student['schedule']: - courseCode = student["schedule"][block][0] + courseCode = student.Schedule[block][0] if courseCode in flex: courseName = 'Study' else: courseName = courses[courseCode[:-2]]["Description"] blockNum = int(block.split('block')[1]) semester = 2 if blockNum > 5 else 1 - if blockNum > (len(student["schedule"])/2): blockNum -= (len(student["schedule"])/2) + if blockNum > (len(student.Schedule)/2): blockNum -= (len(student.Schedule)/2) row = table.add_row().cells row[0].text = f'{courseName}\n({courseCode})' row[1].text = str(blockNum) @@ -76,4 +76,4 @@ def putScheduleToWord( for row in table.rows: row.height = Inches(0.75) - doc.save(f'{output_dir}/{student["Pupil #"]}_schedule.docx') + doc.save(f'{output_dir}/{student.PupilNum}_schedule.docx') diff --git a/app/util/courses.py b/app/util/courses.py index d9b4d3e..71a8946 100644 --- a/app/util/courses.py +++ b/app/util/courses.py @@ -1,8 +1,15 @@ #!/usr/bin/env python3.11 import json import csv +from dataclasses import dataclass from app.util.estimateGrade import getGrade +@dataclass +class Request: + CrsNo: str + Description: str + Alt: bool + # Get all requested courses from data def getCourses( data_dir: str, diff --git a/app/util/estimateGrade.py b/app/util/estimateGrade.py index 5702aab..da5aafb 100644 --- a/app/util/estimateGrade.py +++ b/app/util/estimateGrade.py @@ -23,10 +23,10 @@ def extractGrade(string: str) -> int: return grade if grade is not None and grade >= 8 else None # Estimage students grade based off requests -def estimateStudentGrade(pupil: dict) -> int: +def estimateStudentGrade(pupil) -> int: grades = [] # List of all possible grades - for request in (r for r in pupil["requests"] if r not in flex): - extractedGrade = getGrade(request["CrsNo"], request["Description"]) + for request in (r for r in pupil.Requests if r not in flex): + extractedGrade = getGrade(request.CrsNo, request.Description) if extractedGrade is not None: grades.append(extractedGrade) return None if len(grades) == 0 else most_frequent(grades) # Final estimate of grade diff --git a/app/util/students.py b/app/util/students.py index 43393ef..94375d9 100644 --- a/app/util/students.py +++ b/app/util/students.py @@ -3,6 +3,26 @@ import json import csv from app.util.estimateGrade import estimateStudentGrade from app.util.globals import flex +from app.util.courses import Request +from dataclasses import dataclass, asdict + +@dataclass +class Student: + PupilNum: str + Requests: list[Request] + Schedule: dict[str: list] + ExpectedClasses: int + Classes: int + RemainingAlts: list[Request] + StudentIndex: int + Gradelevel: int = None # set to None since don't pass gradelevel + + # This function converst Student.Requests to an array of dictionaries from an array of Requests + def RequestsToDict(self) -> None: self.Requests = [asdict(r) for r in self.Requests] + +def studentsToDict(students: list[Student]) -> list[dict]: + [s.RequestsToDict for s in students] + return [asdict(s) for s in students] # sort data into usable dictionary def getStudents( @@ -12,56 +32,54 @@ def getStudents( log_dir: str = './output/raw/students.json' ) -> list[ dict[ str: any ] ]: - students: list[ dict[ str: any ] ] = [] + students: list[Student] = [] with open(data_dir, newline='') as csvfile: reader = csv.DictReader(csvfile) for row in reader: exists = False for student in students: - exists = True if student["Pupil #"] == row["Pupil #"] else False + exists = True if student.PupilNum == row["Pupil #"] else False if exists: break - alternate = True if row["Alternate?"] == 'TRUE' else False + alternate = True if str(row["Alternate?"]).upper() == 'TRUE' else False if exists: - if len(students[student["studentIndex"]]["requests"]) >= totalBlocks and not alternate and row["CrsNo"] not in flex: alternate = True - students[student["studentIndex"]]["requests"].append({ - "CrsNo": row["CrsNo"], - "Description": row["Description"], - "alt": alternate - }) - if row["CrsNo"] not in flex and not alternate and students[student["studentIndex"]]["expectedClasses"] < 10: - students[student["studentIndex"]]["expectedClasses"] += 1 + if len(students[student.StudentIndex].Requests) >= totalBlocks and not alternate and row["CrsNo"] not in flex: alternate = True + students[student.StudentIndex].Requests.append( + Request( + row["CrsNo"], + row["Description"], + alternate + )) + if row["CrsNo"] not in flex and not alternate and students[student.StudentIndex].ExpectedClasses < 10: + students[student.StudentIndex].ExpectedClasses += 1 else: - newStudent = { - "Pupil #": row["Pupil #"], - "requests": [{ - "CrsNo": row["CrsNo"], - "Description": row["Description"], - "alt": alternate - }], - "schedule": {}, - "expectedClasses": 1, - "classes": 0, - "remainingAlts": [], - "studentIndex": len(students) - } - newStudent["gradelevel"] = row.get("Grade") if row.get("Grade") is not None else row.get("grade") - for i in range(1, totalBlocks+1): newStudent["schedule"][f'block{i}'] = [] + newStudent: Student = Student( + row["Pupil #"], + [Request( + row["CrsNo"], + row["Description"], + alternate + )], + {}, 1, 0, [], + len(students) + ) + newStudent.Gradelevel = row.get("Grade") if row.get("Grade") is not None else row.get("grade") + for i in range(1, totalBlocks+1): newStudent.Schedule[f'block{i}'] = [] students.append(newStudent) # Estimate student grades for student in students: - if student["gradelevel"] is None: student["gradelevel"] = estimateStudentGrade(student) + if student.Gradelevel is None: student.Gradelevel = estimateStudentGrade(student) if log: with open(log_dir, "w") as outfile: - json.dump(students, outfile, indent=2) + json.dump(studentsToDict(students), outfile, indent=2) return students # Writes all students data to csv file def writeStudentsToCSV( - students: dict, + students: list[Student], output_dir: str = './output/raw/csv/students.csv' ) -> None: with open(output_dir, 'w') as file: @@ -71,5 +89,5 @@ def writeStudentsToCSV( writer.writerow(data) for student in students: - studentData = (student["Pupil #"], student["classes"], student["gradelevel"]) + studentData = (student.PupilNum, student.Classes, student.Gradelevel) writer.writerow(studentData) diff --git a/main.py b/main.py index 360a8ee..5922533 100755 --- a/main.py +++ b/main.py @@ -7,7 +7,7 @@ from app.util.convertRawData import putScheduleToWord, putMasterTimetable from app.generator import generateScheduleV3 from app.util.globals import Error from app.util.courses import getCourses, writeCoursesToCSV -from app.util.students import getStudents, writeStudentsToCSV +from app.util.students import getStudents, writeStudentsToCSV, Student from app.util.errorCalculator import writeErrorsToCSV from app.util.validator import validateInputData @@ -58,7 +58,7 @@ def start( eel.post_data('Collecting student information...') students = getStudents( raw_data_dir, - log = True, + log = False, totalBlocks = total_blocks, log_dir = './output/raw/json/students.json' ) @@ -89,13 +89,13 @@ def start( # Get updated students eel.post_data('Gathering latest data...') - with open(f'{raw_json_dir}/students.json', 'r') as studentFile: students = json.load(studentFile) + with open(f'{raw_json_dir}/students.json', 'r') as studentFile: + # Unpack dictionary to Student dataclass + students = [Student(**s) for s in json.load(studentFile)] + with open(f'{raw_json_dir}/courses.json', 'r') as cFile: courses = json.load(cFile) # call post-algorithm functions to present sorted data eel.post_data('Writing data to .csv files...') - # get updated raw data - with open(f'{raw_json_dir}/students.json', 'r') as sFile: students = json.load(sFile) - with open(f'{raw_json_dir}/courses.json', 'r') as cFile: courses = json.load(cFile) writeStudentsToCSV(students, output_dir='./output/raw/csv/students.csv') writeCoursesToCSV(courses, output_dir='./output/raw/csv/courses.csv') writeErrorsToCSV(len(students), conflictsDir='./output/raw/json/conflicts.json', outputDir='./output/raw/csv/error_tracker.csv')