Hello World
Build a school timetabling solver from scratch.
In this tutorial, you’ll build a school timetabling application that assigns lessons to timeslots and rooms while avoiding conflicts.
The Problem
A school needs to schedule lessons:
- Each lesson has a subject, teacher, and student group
- Available timeslots (e.g., Monday 08:30, Monday 09:30, …)
- Available rooms (Room A, Room B, Room C)
Constraints:
- Hard: No room, teacher, or student group conflicts
- Soft: Teachers prefer the same room, consecutive lessons
Project Structure
Create the following files:
hello_world/
├── domain.py # Data model
├── constraints.py # Constraint definitions
├── main.py # Entry point
└── pyproject.toml # Dependencies
Step 1: Define the Domain Model
Create domain.py with the problem facts and planning entities:
from dataclasses import dataclass, field
from datetime import time
from typing import Annotated
from solverforge_legacy.solver.domain import (
planning_entity,
planning_solution,
PlanningId,
PlanningVariable,
PlanningEntityCollectionProperty,
ProblemFactCollectionProperty,
ValueRangeProvider,
PlanningScore,
)
from solverforge_legacy.solver.score import HardSoftScore
# Problem facts (immutable data)
@dataclass
class Timeslot:
day_of_week: str
start_time: time
end_time: time
def __str__(self):
return f"{self.day_of_week} {self.start_time.strftime('%H:%M')}"
@dataclass
class Room:
name: str
def __str__(self):
return self.name
# Planning entity (modified by the solver)
@planning_entity
@dataclass
class Lesson:
id: Annotated[str, PlanningId]
subject: str
teacher: str
student_group: str
# Planning variables - assigned by the solver
timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None)
room: Annotated[Room | None, PlanningVariable] = field(default=None)
# Planning solution (container for all data)
@planning_solution
@dataclass
class Timetable:
id: str
# Problem facts with value range providers
timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty, ValueRangeProvider]
rooms: Annotated[list[Room], ProblemFactCollectionProperty, ValueRangeProvider]
# Planning entities
lessons: Annotated[list[Lesson], PlanningEntityCollectionProperty]
# Score calculated by constraints
score: Annotated[HardSoftScore, PlanningScore] = field(default=None)
Key Concepts
@planning_entitymarksLessonas something the solver will modifyPlanningVariablemarkstimeslotandroomas values to assign@planning_solutionmarksTimetableas the containerValueRangeProvidertells the solver which values are available
Step 2: Define Constraints
Create constraints.py with the scoring rules:
from solverforge_legacy.solver.score import (
constraint_provider,
ConstraintFactory,
Constraint,
Joiners,
HardSoftScore,
)
from .domain import Lesson
@constraint_provider
def define_constraints(constraint_factory: ConstraintFactory) -> list[Constraint]:
return [
# Hard constraints
room_conflict(constraint_factory),
teacher_conflict(constraint_factory),
student_group_conflict(constraint_factory),
# Soft constraints
teacher_room_stability(constraint_factory),
]
def room_conflict(constraint_factory: ConstraintFactory) -> Constraint:
"""A room can accommodate at most one lesson at the same time."""
return (
constraint_factory
.for_each_unique_pair(
Lesson,
Joiners.equal(lambda lesson: lesson.timeslot),
Joiners.equal(lambda lesson: lesson.room),
)
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("Room conflict")
)
def teacher_conflict(constraint_factory: ConstraintFactory) -> Constraint:
"""A teacher can teach at most one lesson at the same time."""
return (
constraint_factory
.for_each_unique_pair(
Lesson,
Joiners.equal(lambda lesson: lesson.timeslot),
Joiners.equal(lambda lesson: lesson.teacher),
)
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("Teacher conflict")
)
def student_group_conflict(constraint_factory: ConstraintFactory) -> Constraint:
"""A student group can attend at most one lesson at the same time."""
return (
constraint_factory
.for_each_unique_pair(
Lesson,
Joiners.equal(lambda lesson: lesson.timeslot),
Joiners.equal(lambda lesson: lesson.student_group),
)
.penalize(HardSoftScore.ONE_HARD)
.as_constraint("Student group conflict")
)
def teacher_room_stability(constraint_factory: ConstraintFactory) -> Constraint:
"""A teacher prefers to teach in a single room."""
return (
constraint_factory
.for_each_unique_pair(
Lesson,
Joiners.equal(lambda lesson: lesson.teacher),
)
.filter(lambda lesson1, lesson2: lesson1.room != lesson2.room)
.penalize(HardSoftScore.ONE_SOFT)
.as_constraint("Teacher room stability")
)
Constraint Stream Pattern
Each constraint follows this pattern:
- Select entities with
for_each()orfor_each_unique_pair() - Filter to matching cases with
Joinersor.filter() - Penalize (or reward) with a score impact
- Name the constraint with
as_constraint()
Step 3: Configure and Run the Solver
Create main.py:
from datetime import time
from solverforge_legacy.solver import SolverFactory
from solverforge_legacy.solver.config import (
SolverConfig,
ScoreDirectorFactoryConfig,
TerminationConfig,
Duration,
)
from .domain import Timetable, Timeslot, Room, Lesson
from .constraints import define_constraints
def main():
# Configure the solver
solver_config = SolverConfig(
solution_class=Timetable,
entity_class_list=[Lesson],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=define_constraints
),
termination_config=TerminationConfig(
spent_limit=Duration(seconds=5)
),
)
# Create the solver
solver_factory = SolverFactory.create(solver_config)
solver = solver_factory.build_solver()
# Generate a problem
problem = generate_demo_data()
# Solve it!
solution = solver.solve(problem)
# Print the result
print_timetable(solution)
def generate_demo_data() -> Timetable:
"""Create a small demo problem."""
timeslots = [
Timeslot("MONDAY", time(8, 30), time(9, 30)),
Timeslot("MONDAY", time(9, 30), time(10, 30)),
Timeslot("MONDAY", time(10, 30), time(11, 30)),
Timeslot("TUESDAY", time(8, 30), time(9, 30)),
Timeslot("TUESDAY", time(9, 30), time(10, 30)),
]
rooms = [
Room("Room A"),
Room("Room B"),
Room("Room C"),
]
lessons = [
Lesson("1", "Math", "A. Turing", "9th grade"),
Lesson("2", "Physics", "M. Curie", "9th grade"),
Lesson("3", "Chemistry", "M. Curie", "9th grade"),
Lesson("4", "Biology", "C. Darwin", "9th grade"),
Lesson("5", "History", "I. Jones", "9th grade"),
Lesson("6", "Math", "A. Turing", "10th grade"),
Lesson("7", "Physics", "M. Curie", "10th grade"),
Lesson("8", "Geography", "C. Darwin", "10th grade"),
]
return Timetable("demo", timeslots, rooms, lessons)
def print_timetable(timetable: Timetable) -> None:
"""Print the solution in a readable format."""
print(f"\nScore: {timetable.score}\n")
for lesson in timetable.lessons:
print(f"{lesson.subject} ({lesson.teacher}, {lesson.student_group})")
print(f" -> {lesson.timeslot} in {lesson.room}")
print()
if __name__ == "__main__":
main()
Step 4: Run It
python -m hello_world.main
You should see output like:
Score: 0hard/-3soft
Math (A. Turing, 9th grade)
-> MONDAY 08:30 in Room A
Physics (M. Curie, 9th grade)
-> MONDAY 09:30 in Room B
Chemistry (M. Curie, 9th grade)
-> TUESDAY 08:30 in Room B
...
A score of 0hard means all hard constraints are satisfied (no conflicts). The negative soft score indicates room for optimization of preferences.
Understanding the Output
- 0hard = No conflicts (feasible solution!)
- -3soft = 3 soft constraint violations (teachers using multiple rooms)
The solver found a valid timetable where:
- No room has two lessons at the same time
- No teacher teaches two lessons at the same time
- No student group has two lessons at the same time
Next Steps
- Hello World with FastAPI - Add a REST API
- Domain Modeling - Learn more about entities and variables
- Constraints - Explore the full Constraint Streams API
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.