Files
breakpilot-lehrer/timetable-solver-service/app/domain.py
T
Benjamin Admin f042f2896b Phase 5: Timefold timetable-solver-service + solution persistence
school-service additions:
  - tt_solution + tt_lesson migration. tt_lesson carries three UNIQUEs
    (solution+class, solution+teacher, solution+room per slot) so the
    DB itself rejects any double-booking the solver might emit by
    mistake.
  - Solution CRUD + GET solutions/:id/lessons endpoint with joined
    class/subject/teacher/room names for display.
  - POST /timetable/solutions creates the row then fires off the
    solver-service via HTTP (5s timeout, mark failed if unreachable).
  - SOLVER_SERVICE_URL config wired through main.go/handlers.

New service timetable-solver-service:
  - Python 3.11 + FastAPI + Timefold Solver 1.21 (Apache-2.0). Dockerfile
    bundles OpenJDK 17 since Timefold for Python is a JPype bridge.
  - app/domain.py — Timefold @planning_entity Lesson with timeslot+room
    as PlanningVariables; @planning_solution Timetable holds problem
    facts (rooms/teachers/etc.) AND rule-fact collections.
  - app/rules.py — frozen dataclasses mirroring 6 of the 15 tt_
    constraint_* tables initially.
  - app/constraints.py — ConstraintProvider with 3 universal hard
    constraints (no double-booking) + 5 DB-driven constraints
    (teacher_unavailable_day/window, teacher_excluded_room,
    room_unavailable, room_requires_type) + 1 quality soft constraint
    (subject_preferred_period). Remaining 9 constraint types ready to
    plug in via the same join pattern.
  - app/repository.py — async loaders for stammdaten + rules; builds
    one Lesson per (curriculum row × weekly_hours), skipping rows
    without a tt_assignment teacher.
  - app/runner.py — runs solver in ThreadPoolExecutor so the FastAPI
    event loop stays responsive. Updates tt_solution status
    pending→running→completed|infeasible|failed.
  - app/main.py — POST /api/v1/solve (202 Accepted, background task),
    GET /api/v1/jobs/{id}, /health. School-service polls tt_solution
    directly instead of GET /jobs for the typical case.
  - docker-compose.yml adds the service on port 8095, depending on
    core-health-check.

Tests:
  - school-service: validator test for CreateTimetableSolutionRequest
    (allows empty name).
  - solver-service: tests/test_domain.py + tests/test_rules.py cover
    construction + hashability of the planning facts. Full solve flow
    deferred to Phase 8 integration with seed data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:16:52 +02:00

135 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
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:0008: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.
"""
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)
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)