diff --git a/timetable-solver-service/app/constraints.py b/timetable-solver-service/app/constraints.py index abf0296..e50ff8c 100644 --- a/timetable-solver-service/app/constraints.py +++ b/timetable-solver-service/app/constraints.py @@ -1,16 +1,16 @@ """Timefold constraint provider for the school timetable. -Three categories: - * universal hard — no double-booking class/teacher/room. These can't be - turned off; the school can't physically run lessons that overlap. - * DB-driven hard — soft-fallback if is_hard=False. Each constraint joins - Lesson against a rule-fact collection from the corresponding tt_ - constraint_* table. - * Quality soft — preferred periods, etc. +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. -Scoring uses HardSoftScore. Hard violations are weighted by 1; soft -violations use the rule's stored `weight` (0-100). The UI rejects any -solution where hard_score < 0. +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 ( @@ -36,12 +36,17 @@ def define_constraints(factory: ConstraintFactory) -> list[Constraint]: _teacher_conflict(factory), _room_conflict(factory), - # ---------- DB-driven hard or soft ---------- - _teacher_unavailable_day(factory), - _teacher_unavailable_window(factory), - _teacher_excluded_room(factory), - _room_unavailable(factory), - _room_requires_type(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), @@ -96,17 +101,10 @@ def _room_conflict(factory: ConstraintFactory) -> Constraint: # ========================================================================== -# DB-driven constraints +# DB-driven constraints — each split into hard + soft variants # ========================================================================== -def _score_for(rule, *, hard_per_violation: int = 1) -> HardSoftScore: - """Pick HardSoftScore from a rule's is_hard + weight.""" - if rule.is_hard: - return HardSoftScore.of(hard_per_violation, 0) - return HardSoftScore.of(0, max(rule.weight, 1)) - - -def _teacher_unavailable_day(factory: ConstraintFactory) -> Constraint: +def _teacher_unavailable_day_hard(factory: ConstraintFactory) -> Constraint: return ( factory.for_each(Lesson) .filter(lambda l: l.timeslot is not None) @@ -115,19 +113,34 @@ def _teacher_unavailable_day(factory: ConstraintFactory) -> Constraint: 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), ) - .penalize(HardSoftScore.ONE_HARD, lambda l, r: 1 if r.is_hard else 0) - .penalize(HardSoftScore.ONE_SOFT, lambda l, r: r.weight if not r.is_hard else 0) - .as_constraint("teacher_unavailable_day") + .filter(lambda l, r: r.is_hard) + .penalize(HardSoftScore.ONE_HARD) + .as_constraint("teacher_unavailable_day_hard") ) -def _teacher_unavailable_window(factory: ConstraintFactory) -> Constraint: - def overlaps(l: Lesson, r: TeacherUnavailableWindowRule) -> bool: - if l.timeslot is None: - return False - # Compare HH:MM strings — they sort correctly when zero-padded. - return l.timeslot.start_time < r.end_time and l.timeslot.end_time > r.start_time +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) @@ -136,14 +149,28 @@ def _teacher_unavailable_window(factory: ConstraintFactory) -> Constraint: 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(overlaps) - .penalize(HardSoftScore.ONE_HARD, lambda l, r: 1 if r.is_hard else 0) - .penalize(HardSoftScore.ONE_SOFT, lambda l, r: r.weight if not r.is_hard else 0) - .as_constraint("teacher_unavailable_window") + .filter(lambda l, r: r.is_hard and _overlaps_window(l, r)) + .penalize(HardSoftScore.ONE_HARD) + .as_constraint("teacher_unavailable_window_hard") ) -def _teacher_excluded_room(factory: ConstraintFactory) -> Constraint: +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) @@ -152,13 +179,28 @@ def _teacher_excluded_room(factory: ConstraintFactory) -> Constraint: Joiners.equal(lambda l: l.teacher.id, lambda r: r.teacher_id), Joiners.equal(lambda l: l.room.id, lambda r: r.room_id), ) - .penalize(HardSoftScore.ONE_HARD, lambda l, r: 1 if r.is_hard else 0) - .penalize(HardSoftScore.ONE_SOFT, lambda l, r: r.weight if not r.is_hard else 0) - .as_constraint("teacher_excluded_room") + .filter(lambda l, r: r.is_hard) + .penalize(HardSoftScore.ONE_HARD) + .as_constraint("teacher_excluded_room_hard") ) -def _room_unavailable(factory: ConstraintFactory) -> Constraint: +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) @@ -168,14 +210,29 @@ def _room_unavailable(factory: ConstraintFactory) -> Constraint: 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), ) - .penalize(HardSoftScore.ONE_HARD, lambda l, r: 1 if r.is_hard else 0) - .penalize(HardSoftScore.ONE_SOFT, lambda l, r: r.weight if not r.is_hard else 0) - .as_constraint("room_unavailable") + .filter(lambda l, r: r.is_hard) + .penalize(HardSoftScore.ONE_HARD) + .as_constraint("room_unavailable_hard") ) -def _room_requires_type(factory: ConstraintFactory) -> Constraint: - """If a subject requires a specific room type, the assigned room must match.""" +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) @@ -183,10 +240,23 @@ def _room_requires_type(factory: ConstraintFactory) -> Constraint: RoomRequiresTypeRule, Joiners.equal(lambda l: l.subject.id, lambda r: r.subject_id), ) - .filter(lambda l, r: l.room.room_type != r.room_type) - .penalize(HardSoftScore.ONE_HARD, lambda l, r: 1 if r.is_hard else 0) - .penalize(HardSoftScore.ONE_SOFT, lambda l, r: r.weight if not r.is_hard else 0) - .as_constraint("room_requires_type") + .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") ) @@ -204,6 +274,6 @@ def _subject_preferred_period(factory: ConstraintFactory) -> Constraint: 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: r.weight) + .penalize(HardSoftScore.ONE_SOFT, lambda l, r: max(r.weight, 1)) .as_constraint("subject_preferred_period") )