diff --git a/examples/contrib/school_scheduling_sat.py b/examples/contrib/school_scheduling_sat.py old mode 100644 new mode 100755 index 6f29e8c8fe..7b4e6d5fff --- a/examples/contrib/school_scheduling_sat.py +++ b/examples/contrib/school_scheduling_sat.py @@ -1,174 +1,330 @@ +#!/usr/bin/env python3 +'''Solve a School Scheduling Problem''' from ortools.sat.python import cp_model -class SchoolSchedulingProblem(object): +class SchoolSchedulingProblem(): + '''Data of the problem.''' - def __init__(self, subjects, teachers, curriculum, specialties, working_days, - periods, levels, sections, teacher_work_hours): - self.subjects = subjects - self.teachers = teachers - self.curriculum = curriculum - self.specialties = specialties - self.working_days = working_days - self.periods = periods - self.levels = levels - self.sections = sections - self.teacher_work_hours = teacher_work_hours + def __init__(self, levels, sections, subjects, curriculum, teachers, + specialties, time_slots): + self._levels = levels + self._sections = sections + self._subjects = subjects + self._curriculum = curriculum + assert len(self._curriculum) == len(self._levels) * len( + self._subjects), 'Some curriculum are missing' + for (lvl, sub) in self._curriculum.keys(): + assert lvl in self._levels, f'{lvl} not in LEVELS' + assert sub in self._subjects, f'{sub} not in SUBJECTS' + + self._teachers = teachers + self._specialties = specialties + assert len(self._specialties) == len( + self._subjects), 'Missing some rows' + for s, ts in self._specialties.items(): + assert s in self._subjects, f'{s} is not in SUBJECTS' + for t in ts: + assert t in self._teachers, f'{t} is not in TEACHERS' + + self._time_slots = time_slots + + @property + def levels(self): + return self._levels + + @property + def sections(self): + return self._sections + + @property + def subjects(self): + return self._subjects + + @property + def curriculum(self): + return self._curriculum + + @property + def teachers(self): + return self._teachers + + def teacher_name(self, teacher_idx): + assert 0 <= teacher_idx < len(self._teachers) + return list(self._teachers.keys())[teacher_idx] + + def teacher_max_hours(self, teacher_idx): + assert 0 <= teacher_idx < len(self._teachers) + return list(self._teachers.values())[teacher_idx] + + @property + def specialties(self): + return self._specialties + + def specialtie_teachers(self, subject): + assert subject in self._subjects, f'{subject} not in SUBJECTS' + return self._specialties[subject] + + @property + def time_slots(self): + return self._time_slots + + def slot_duration(self, slot_idx): + assert 0 <= slot_idx < len(self._time_slots) + return list(self._time_slots.values())[slot_idx] -class SchoolSchedulingSatSolver(object): +class SchoolSchedulingSatSolver(): + '''Solver instance.''' - def __init__(self, problem): - # Problem - self.problem = problem + def __init__(self, problem: SchoolSchedulingProblem): + # Problem + self._problem = problem - # Utilities - self.timeslots = [ - '{0:10} {1:6}'.format(x, y) - for x in problem.working_days - for y in problem.periods - ] - self.num_days = len(problem.working_days) - self.num_periods = len(problem.periods) - self.num_slots = len(self.timeslots) - self.num_teachers = len(problem.teachers) - self.num_subjects = len(problem.subjects) - self.num_levels = len(problem.levels) - self.num_sections = len(problem.sections) - self.courses = [ - x * self.num_levels + y - for x in problem.levels - for y in problem.sections - ] - self.num_courses = self.num_levels * self.num_sections + # Utilities + num_levels = len(self._problem.levels) + self._all_levels = range(num_levels) + num_sections = len(self._problem.sections) + self._all_sections = range(num_sections) + num_subjects = len(self._problem.subjects) + self._all_subjects = range(num_subjects) + num_teachers = len(self._problem.teachers) + self._all_teachers = range(num_teachers) + num_slots = len(self._problem.time_slots) + self._all_slots = range(num_slots) - all_courses = range(self.num_courses) - all_teachers = range(self.num_teachers) - all_slots = range(self.num_slots) - all_sections = range(self.num_sections) - all_subjects = range(self.num_subjects) - all_levels = range(self.num_levels) + # Create Model + self._model = cp_model.CpModel() - self.model = cp_model.CpModel() + # Create Variables + self._assignment = {} + for lvl_idx, level in enumerate(self._problem.levels): + for sec_idx, section in enumerate(self._problem.sections): + for sub_idx, subject in enumerate(self._problem.subjects): + for tch_idx, teacher in enumerate(self._problem.teachers): + for slt_idx, slot in enumerate(self._problem.time_slots): + key = (lvl_idx, sec_idx, sub_idx, tch_idx, slt_idx) + name = f'{level}-{section} S:{subject} T:{teacher} Slot:{slot}' + #print(name) + if teacher in self._problem.specialtie_teachers(subject): + self._assignment[key] = self._model.NewBoolVar(name) + else: + name = 'NO DISP ' + name + self._assignment[key] = self._model.NewIntVar(0, 0, name) - self.assignment = {} - for c in all_courses: - for s in all_subjects: - for t in all_teachers: - for slot in all_slots: - if t in self.problem.specialties[s]: - name = 'C:{%i} S:{%i} T:{%i} Slot:{%i}' % (c, s, t, slot) - self.assignment[c, s, t, slot] = self.model.NewBoolVar(name) - else: - name = 'NO DISP C:{%i} S:{%i} T:{%i} Slot:{%i}' % (c, s, t, slot) - self.assignment[c, s, t, slot] = self.model.NewIntVar(0, 0, name) + # Constraints + # Each Level-Section must have the quantity of classes per Subject specified in the Curriculum + for lvl_idx, level in enumerate(self._problem.levels): + for sec_idx in self._all_sections: + for sub_idx, subject in enumerate(self._problem.subjects): + required_duration = self._problem.curriculum[level, subject] + #print(f'L:{level} S:{subject} duration:{required_duration}h') + self._model.Add( + sum(self._assignment[lvl_idx, sec_idx, sub_idx, tch_idx, slt_idx] * + int(self._problem.slot_duration(slt_idx) * 10) + for tch_idx in self._all_teachers + for slt_idx in self._all_slots) == int(required_duration * 10)) - # Constraints + # Each Level-Section can do at most one class at a time + for lvl_idx in self._all_levels: + for sec_idx in self._all_sections: + for slt_idx in self._all_slots: + self._model.Add( + sum([ + self._assignment[lvl_idx, sec_idx, sub_idx, tch_idx, slt_idx] + for sub_idx in self._all_subjects + for tch_idx in self._all_teachers + ]) <= 1) - # Each course must have the quantity of classes specified in the curriculum - for level in all_levels: - for section in all_sections: - course = level * self.num_sections + section - for subject in all_subjects: - required_slots = self.problem.curriculum[ - self.problem.levels[level], self.problem.subjects[subject]] - self.model.Add( - sum(self.assignment[course, subject, teacher, slot] - for slot in all_slots - for teacher in all_teachers) == required_slots) + # Teacher can do at most one class at a time + for tch_idx in self._all_teachers: + for slt_idx in self._all_slots: + self._model.Add( + sum([ + self._assignment[lvl_idx, sec_idx, sub_idx, tch_idx, slt_idx] + for lvl_idx in self._all_levels + for sec_idx in self._all_sections + for sub_idx in self._all_subjects + ]) <= 1) - # Teacher can do at most one class at a time - for teacher in all_teachers: - for slot in all_slots: - self.model.Add( - sum([ - self.assignment[c, s, teacher, slot] - for c in all_courses - for s in all_subjects - ]) <= 1) + # Maximum work hours for each teacher + for tch_idx in self._all_teachers: + self._model.Add( + sum([ + self._assignment[lvl_idx, sec_idx, sub_idx, tch_idx, slt_idx] * + int(self._problem.slot_duration(slt_idx) * 10) + for lvl_idx in self._all_levels + for sec_idx in self._all_sections + for sub_idx in self._all_subjects + for slt_idx in self._all_slots + ]) <= int(self._problem.teacher_max_hours(tch_idx) * 10)) - # Maximum work hours for each teacher - for teacher in all_teachers: - self.model.Add( - sum([ - self.assignment[c, s, teacher, slot] for c in all_courses - for s in all_subjects for slot in all_slots - ]) <= self.problem.teacher_work_hours[teacher]) + # Teacher makes all the classes of a subject's course + teacher_courses = {} + for lvl_idx, level in enumerate(self._problem.levels): + for sec_idx, section in enumerate(self._problem.sections): + for sub_idx, subject in enumerate(self._problem.subjects): + for tch_idx, teacher in enumerate(self._problem.teachers): + name = f'{level}-{section} S:{subject} T:{teacher}' + teacher_courses[lvl_idx, sec_idx, sub_idx, tch_idx] = self._model.NewBoolVar(name) + temp_array = [ + self._assignment[lvl_idx, sec_idx, sub_idx, tch_idx, slt_idx] + for slt_idx in self._all_slots + ] + self._model.AddMaxEquality( + teacher_courses[lvl_idx, sec_idx, sub_idx, tch_idx], temp_array) + self._model.Add( + sum([teacher_courses[lvl_idx, sec_idx, sub_idx, tch_idx] + for tch_idx in self._all_teachers + ]) == 1) - # Teacher makes all the classes of a subject's course - teacher_courses = {} - for level in all_levels: - for section in all_sections: - course = level * self.num_sections + section - for subject in all_subjects: - for t in all_teachers: - name = 'C:{%i} S:{%i} T:{%i}' % (course, subject, teacher) - teacher_courses[course, subject, t] = self.model.NewBoolVar(name) - temp_array = [ - self.assignment[course, subject, t, slot] for slot in all_slots - ] - self.model.AddMaxEquality(teacher_courses[course, subject, t], - temp_array) - self.model.Add( - sum(teacher_courses[course, subject, t] - for t in all_teachers) == 1) - def solve(self): - print('Solving') - solver = cp_model.CpSolver() - solution_printer = SchoolSchedulingSatSolutionPrinter() - status = solver.Solve(self.model, solution_printer) - print() - print('status', status) - print('Branches', solver.NumBranches()) - print('Conflicts', solver.NumConflicts()) - print('WallTime', solver.WallTime()) + def print_teacher_schedule(self, tch_idx): + teacher_name = self._problem.teacher_name(tch_idx) + print(f'Teacher: {teacher_name}') + total_working_hours = 0 + for slt_idx, slot in enumerate(self._problem.time_slots): + for lvl_idx, level in enumerate(self._problem.levels): + for sec_idx, section in enumerate(self._problem.sections): + for sub_idx, subject in enumerate(self._problem.subjects): + key = (lvl_idx, sec_idx, sub_idx, tch_idx, slt_idx) + if self._solver.BooleanValue(self._assignment[key]): + total_working_hours += self._problem.slot_duration(slt_idx) + print(f'{slot}: C:{level}-{section} S:{subject}') + print(f'Total working hours: {total_working_hours}h') + + + def print_class_schedule(self, lvl_idx, sec_idx): + level = self._problem.levels[lvl_idx] + section = self._problem.sections[sec_idx] + print(f'Class: {level}-{section}') + total_working_hours = {} + for sub in self._problem.subjects: + total_working_hours[sub] = 0 + for slt_idx, slot in enumerate(self._problem.time_slots): + for tch_idx, teacher in enumerate(self._problem.teachers): + for sub_idx, subject in enumerate(self._problem.subjects): + key = (lvl_idx, sec_idx, sub_idx, tch_idx, slt_idx) + if self._solver.BooleanValue(self._assignment[key]): + total_working_hours[subject] += self._problem.slot_duration(slt_idx) + print(f'{slot}: S:{subject} T:{teacher}') + for (subject, hours) in total_working_hours.items(): + print(f'Total working hours for {subject}: {hours}h') + + + def solve(self): + print('Solving') + # Create Solver + self._solver = cp_model.CpSolver() + + solution_printer = SchoolSchedulingSatSolutionPrinter() + status = self._solver.Solve(self._model, solution_printer) + print('Status: ', self._solver.StatusName(status)) + + if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE: + print('\n# Teachers') + for teacher_idx in self._all_teachers: + self.print_teacher_schedule(teacher_idx) + + print('\n# Classes') + for level_idx in self._all_levels: + for section_idx in self._all_sections: + self.print_class_schedule(level_idx, section_idx) + + print('Branches: ', self._solver.NumBranches()) + print('Conflicts: ', self._solver.NumConflicts()) + print('WallTime: ', self._solver.WallTime()) class SchoolSchedulingSatSolutionPrinter(cp_model.CpSolverSolutionCallback): - def __init__(self): - cp_model.CpSolverSolutionCallback.__init__(self) - self.__solution_count = 0 + def __init__(self): + cp_model.CpSolverSolutionCallback.__init__(self) + self.__solution_count = 0 - def OnSolutionCallback(self): - print('Found Solution!') + def OnSolutionCallback(self): + print( + f'Solution #{self.__solution_count}, objective: {self.ObjectiveValue()}' + ) + self.__solution_count += 1 def main(): - # DATA - subjects = ['English', 'Math', 'History'] - levels = ['1-', '2-', '3-'] - sections = ['A'] - teachers = ['Mario', 'Elvis', 'Donald', 'Ian'] - teachers_work_hours = [18, 12, 12, 18] - working_days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'] - periods = ['08:00-09:30', '09:45-11:15', '11:30-13:00'] - curriculum = { - ('1-', 'English'): 3, - ('1-', 'Math'): 3, - ('1-', 'History'): 2, - ('2-', 'English'): 4, - ('2-', 'Math'): 2, - ('2-', 'History'): 2, - ('3-', 'English'): 2, - ('3-', 'Math'): 4, - ('3-', 'History'): 2 - } + # DATA + ## Classes + LEVELS = [ + '1', + '2', + '3', + ] + SECTIONS = [ + 'A', + 'B', + ] + SUBJECTS = [ + 'English', + 'Math', + #'Science', + 'History', + ] + CURRICULUM = { + ('1', 'English'): 3, + ('1', 'Math'): 3, + ('1', 'History'): 2, + ('2', 'English'): 4, + ('2', 'Math'): 2, + ('2', 'History'): 2, + ('3', 'English'): 2, + ('3', 'Math'): 4, + ('3', 'History'): 2, + } - # Subject -> List of teachers who can teach it - specialties_idx_inverse = [ - [1, 3], # English -> Elvis & Ian - [0, 3], # Math -> Mario & Ian - [2, 3] # History -> Donald & Ian - ] + ## Teachers + TEACHERS = { # name, max_work_hours + 'Mario': 18, + 'Elvis': 12, + 'Donald': 12, + 'Ian': 18, + } + # Subject -> List of teachers who can teach it + SPECIALTIES = { + 'English': ['Elvis', 'Ian'], + 'Math': ['Mario', 'Ian'], + 'History': ['Donald', 'Ian'], + } - problem = SchoolSchedulingProblem( - subjects, teachers, curriculum, specialties_idx_inverse, working_days, - periods, levels, sections, teachers_work_hours) - solver = SchoolSchedulingSatSolver(problem) - solver.solve() + ## Schedule + TIME_SLOTS = { + ('Monday', '08:00-09:30'): 1.5, + ('Monday', '09:45-11:15'): 1.5, + ('Monday', '11:30-12:30'): 1, + ('Monday', '13:30-15:30'): 2, + ('Monday', '15:45-17:15'): 1.5, + ('Tuesday', '08:00-09:30'): 1.5, + ('Tuesday', '09:45-11:15'): 1.5, + ('Tuesday', '11:30-12:30'): 1, + ('Tuesday', '13:30-15:30'): 2, + ('Tuesday', '15:45-17:15'): 1.5, + ('Wednesday', '08:00-09:30'): 1.5, + ('Wednesday', '09:45-11:15'): 1.5, + ('Wednesday', '11:30-12:30'): 1, + ('Thursday', '08:00-09:30'): 1.5, + ('Thursday', '09:45-11:15'): 1.5, + ('Thursday', '11:30-12:30'): 1, + ('Thursday', '13:30-15:30'): 2, + ('Thursday', '15:45-17:15'): 1.5, + ('Friday', '08:00-09:30'): 1.5, + ('Friday', '09:45-11:15'): 1.5, + ('Friday', '11:30-12:30'): 1, + ('Friday', '13:30-15:30'): 2, + ('Friday', '15:45-17:15'): 1.5, + } + + problem = SchoolSchedulingProblem(LEVELS, SECTIONS, SUBJECTS, CURRICULUM, + TEACHERS, SPECIALTIES, TIME_SLOTS) + solver = SchoolSchedulingSatSolver(problem) + solver.solve() if __name__ == '__main__': - main() + main()