"""Timefold planning domain for school timetables. Lessons are the planning entities; their `timeslot` and `room` are the variables the solver picks. Class/subject/teacher come from the assignment (`tt_assignment`) and stay fixed for a given Lesson instance. Note on equality: Timefold compares facts by identity by default, so we use frozen dataclasses with id-based equality where needed. """ from dataclasses import dataclass, field from typing import Annotated, Optional from timefold.solver.domain import ( planning_entity, planning_solution, PlanningVariable, PlanningPin, PlanningId, PlanningEntityCollectionProperty, ProblemFactCollectionProperty, ValueRangeProvider, PlanningScore, ) from timefold.solver.score import HardSoftScore @dataclass(frozen=True) class Timeslot: """A single weekday + lesson period (e.g. Monday 1st hour, 08:00–08:45).""" id: Annotated[str, PlanningId] day_of_week: int # 1..7 period_index: int # 1..N start_time: str # HH:MM end_time: str # HH:MM def __str__(self) -> str: return f"D{self.day_of_week}P{self.period_index}" @dataclass(frozen=True) class Room: id: Annotated[str, PlanningId] name: str room_type: str = "" def __str__(self) -> str: return self.name @dataclass(frozen=True) class Teacher: id: Annotated[str, PlanningId] last_name: str first_name: str short_code: str def __str__(self) -> str: return f"{self.last_name}, {self.first_name}" @dataclass(frozen=True) class SchoolClass: id: Annotated[str, PlanningId] name: str grade_level: int def __str__(self) -> str: return self.name @dataclass(frozen=True) class Subject: id: Annotated[str, PlanningId] name: str short_code: str required_room_type: str = "" def __str__(self) -> str: return self.short_code @planning_entity @dataclass class Lesson: """One scheduled class-subject pairing. Curriculum says "5a needs 4 hours of Mathe per week" → 4 Lesson instances with school_class=5a, subject=Mathe, teacher fixed (from tt_assignment). The solver assigns timeslot + room. pinned (Phase 7): when True, this Lesson's timeslot + room have been pre-assigned and the solver must not move it. Used for plan versioning so re-solves keep the parent solution's locked cells in place. """ id: Annotated[str, PlanningId] school_class: SchoolClass subject: Subject teacher: Teacher timeslot: Annotated[Optional[Timeslot], PlanningVariable] = field(default=None) room: Annotated[Optional[Room], PlanningVariable] = field(default=None) pinned: Annotated[bool, PlanningPin] = field(default=False) def __str__(self) -> str: return f"{self.school_class}-{self.subject}#{self.id[:8]}" from .rules import ( TeacherUnavailableDayRule, TeacherUnavailableWindowRule, TeacherExcludedRoomRule, RoomUnavailableRule, SubjectPreferredPeriodRule, RoomRequiresTypeRule, ) @planning_solution @dataclass class Timetable: """The solver works on one Timetable instance: shuffles `lessons[*].timeslot` and `lessons[*].room` to satisfy the constraints. Constraint-rule facts are pulled from the DB at solve time and passed here so the constraint provider can join against them. """ timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty, ValueRangeProvider] rooms: Annotated[list[Room], ProblemFactCollectionProperty, ValueRangeProvider] teachers: Annotated[list[Teacher], ProblemFactCollectionProperty] classes: Annotated[list[SchoolClass], ProblemFactCollectionProperty] subjects: Annotated[list[Subject], ProblemFactCollectionProperty] teacher_unavailable_days: Annotated[list[TeacherUnavailableDayRule], ProblemFactCollectionProperty] teacher_unavailable_windows: Annotated[list[TeacherUnavailableWindowRule], ProblemFactCollectionProperty] teacher_excluded_rooms: Annotated[list[TeacherExcludedRoomRule], ProblemFactCollectionProperty] room_unavailables: Annotated[list[RoomUnavailableRule], ProblemFactCollectionProperty] subject_preferred_periods: Annotated[list[SubjectPreferredPeriodRule], ProblemFactCollectionProperty] room_requires_types: Annotated[list[RoomRequiresTypeRule], ProblemFactCollectionProperty] lessons: Annotated[list[Lesson], PlanningEntityCollectionProperty] score: Annotated[Optional[HardSoftScore], PlanningScore] = field(default=None)