"""Timefold constraint provider for the school timetable. Categories: * universal hard — no double-booking class/teacher/room. Mandatory. * DB-driven — each tt_constraint_* table maps to two constraints here: a `_hard` variant filtering rules with is_hard=True (penalised as hard), and a `_soft` variant for is_hard=False (penalised as soft, weighted). Splitting is required because Timefold's penalize() can only emit one score axis per constraint. * Quality soft — preferred periods etc. Always soft. Hard violations use HardSoftScore.ONE_HARD (one per match). Soft violations multiply ONE_SOFT by the rule's weight (0-100). """ from timefold.solver.score import ( constraint_provider, HardSoftScore, ConstraintFactory, Constraint, Joiners, ) from .domain import Lesson from .rules import ( TeacherUnavailableDayRule, TeacherUnavailableWindowRule, TeacherExcludedRoomRule, RoomUnavailableRule, SubjectPreferredPeriodRule, RoomRequiresTypeRule, ) @constraint_provider def define_constraints(factory: ConstraintFactory) -> list[Constraint]: return [ # ---------- Universal hard ---------- _class_conflict(factory), _teacher_conflict(factory), _room_conflict(factory), # ---------- DB-driven (each split hard/soft) ---------- _teacher_unavailable_day_hard(factory), _teacher_unavailable_day_soft(factory), _teacher_unavailable_window_hard(factory), _teacher_unavailable_window_soft(factory), _teacher_excluded_room_hard(factory), _teacher_excluded_room_soft(factory), _room_unavailable_hard(factory), _room_unavailable_soft(factory), _room_requires_type_hard(factory), _room_requires_type_soft(factory), # ---------- Quality soft ---------- _subject_preferred_period(factory), ] # ========================================================================== # Universal hard constraints # ========================================================================== def _class_conflict(factory: ConstraintFactory) -> Constraint: """A class can't sit in two lessons at once.""" return ( factory.for_each_unique_pair( Lesson, Joiners.equal(lambda l: l.school_class.id), Joiners.equal(lambda l: l.timeslot.id if l.timeslot else None), ) .filter(lambda l1, l2: l1.timeslot is not None and l2.timeslot is not None) .penalize(HardSoftScore.ONE_HARD) .as_constraint("class_conflict") ) def _teacher_conflict(factory: ConstraintFactory) -> Constraint: """A teacher can't run two lessons at once.""" return ( factory.for_each_unique_pair( Lesson, Joiners.equal(lambda l: l.teacher.id), Joiners.equal(lambda l: l.timeslot.id if l.timeslot else None), ) .filter(lambda l1, l2: l1.timeslot is not None and l2.timeslot is not None) .penalize(HardSoftScore.ONE_HARD) .as_constraint("teacher_conflict") ) def _room_conflict(factory: ConstraintFactory) -> Constraint: """A room can't host two lessons at once.""" return ( factory.for_each_unique_pair( Lesson, Joiners.equal(lambda l: l.room.id if l.room else None), Joiners.equal(lambda l: l.timeslot.id if l.timeslot else None), ) .filter(lambda l1, l2: l1.room is not None and l2.room is not None and l1.timeslot is not None and l2.timeslot is not None) .penalize(HardSoftScore.ONE_HARD) .as_constraint("room_conflict") ) # ========================================================================== # DB-driven constraints — each split into hard + soft variants # ========================================================================== def _teacher_unavailable_day_hard(factory: ConstraintFactory) -> Constraint: return ( factory.for_each(Lesson) .filter(lambda l: l.timeslot is not None) .join( TeacherUnavailableDayRule, Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id), Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week), ) .filter(lambda l, r: r.is_hard) .penalize(HardSoftScore.ONE_HARD) .as_constraint("teacher_unavailable_day_hard") ) def _teacher_unavailable_day_soft(factory: ConstraintFactory) -> Constraint: return ( factory.for_each(Lesson) .filter(lambda l: l.timeslot is not None) .join( TeacherUnavailableDayRule, Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id), Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week), ) .filter(lambda l, r: not r.is_hard) .penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1)) .as_constraint("teacher_unavailable_day_soft") ) def _overlaps_window(l, r) -> bool: if l.timeslot is None: return False return l.timeslot.start_time < r.end_time and l.timeslot.end_time > r.start_time def _teacher_unavailable_window_hard(factory: ConstraintFactory) -> Constraint: return ( factory.for_each(Lesson) .filter(lambda l: l.timeslot is not None) .join( TeacherUnavailableWindowRule, Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id), Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week), ) .filter(lambda l, r: r.is_hard and _overlaps_window(l, r)) .penalize(HardSoftScore.ONE_HARD) .as_constraint("teacher_unavailable_window_hard") ) def _teacher_unavailable_window_soft(factory: ConstraintFactory) -> Constraint: return ( factory.for_each(Lesson) .filter(lambda l: l.timeslot is not None) .join( TeacherUnavailableWindowRule, Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id), Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week), ) .filter(lambda l, r: not r.is_hard and _overlaps_window(l, r)) .penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1)) .as_constraint("teacher_unavailable_window_soft") ) def _teacher_excluded_room_hard(factory: ConstraintFactory) -> Constraint: return ( factory.for_each(Lesson) .filter(lambda l: l.room is not None) .join( TeacherExcludedRoomRule, Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id), Joiners.equal(lambda l: l.room.id, lambda r: r.room_id), ) .filter(lambda l, r: r.is_hard) .penalize(HardSoftScore.ONE_HARD) .as_constraint("teacher_excluded_room_hard") ) def _teacher_excluded_room_soft(factory: ConstraintFactory) -> Constraint: return ( factory.for_each(Lesson) .filter(lambda l: l.room is not None) .join( TeacherExcludedRoomRule, Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id), Joiners.equal(lambda l: l.room.id, lambda r: r.room_id), ) .filter(lambda l, r: not r.is_hard) .penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1)) .as_constraint("teacher_excluded_room_soft") ) def _room_unavailable_hard(factory: ConstraintFactory) -> Constraint: return ( factory.for_each(Lesson) .filter(lambda l: l.room is not None and l.timeslot is not None) .join( RoomUnavailableRule, Joiners.equal(lambda l: l.room.id, lambda r: r.room_id), Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week), Joiners.equal(lambda l: l.timeslot.period_index, lambda r: r.period_index), ) .filter(lambda l, r: r.is_hard) .penalize(HardSoftScore.ONE_HARD) .as_constraint("room_unavailable_hard") ) def _room_unavailable_soft(factory: ConstraintFactory) -> Constraint: return ( factory.for_each(Lesson) .filter(lambda l: l.room is not None and l.timeslot is not None) .join( RoomUnavailableRule, Joiners.equal(lambda l: l.room.id, lambda r: r.room_id), Joiners.equal(lambda l: l.timeslot.day_of_week, lambda r: r.day_of_week), Joiners.equal(lambda l: l.timeslot.period_index, lambda r: r.period_index), ) .filter(lambda l, r: not r.is_hard) .penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1)) .as_constraint("room_unavailable_soft") ) def _room_requires_type_hard(factory: ConstraintFactory) -> Constraint: return ( factory.for_each(Lesson) .filter(lambda l: l.room is not None) .join( RoomRequiresTypeRule, Joiners.equal(lambda l: l.subject.id, lambda r: r.subject_id), ) .filter(lambda l, r: r.is_hard and l.room.room_type != r.room_type) .penalize(HardSoftScore.ONE_HARD) .as_constraint("room_requires_type_hard") ) def _room_requires_type_soft(factory: ConstraintFactory) -> Constraint: return ( factory.for_each(Lesson) .filter(lambda l: l.room is not None) .join( RoomRequiresTypeRule, Joiners.equal(lambda l: l.subject.id, lambda r: r.subject_id), ) .filter(lambda l, r: not r.is_hard and l.room.room_type != r.room_type) .penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1)) .as_constraint("room_requires_type_soft") ) # ========================================================================== # Quality soft # ========================================================================== def _subject_preferred_period(factory: ConstraintFactory) -> Constraint: """Soft penalty when a lesson lands outside the subject's preferred period range.""" return ( factory.for_each(Lesson) .filter(lambda l: l.timeslot is not None) .join( SubjectPreferredPeriodRule, Joiners.equal(lambda l: l.subject.id, lambda r: r.subject_id), ) .filter(lambda l, r: not (r.period_from <= l.timeslot.period_index <= r.period_to)) .penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1)) .as_constraint("subject_preferred_period") )