Hello World Quick Start Guide
This guide walks you through the process of creating a simple Java, Python or Kotlin application with Timefold's constraint solving Artificial Intelligence (AI).
1. What you will build
You will build a command-line application that optimizes a school timetable for students and teachers:
... INFO Solving ended: time spent (5000), best score (0hard/9soft), ... INFO INFO | | Room A | Room B | Room C | INFO |------------|------------|------------|------------| INFO | MON 08:30 | English | Math | | INFO | | I. Jones | A. Turing | | INFO | | 9th grade | 10th grade | | INFO |------------|------------|------------|------------| INFO | MON 09:30 | History | Physics | | INFO | | I. Jones | M. Curie | | INFO | | 9th grade | 10th grade | | INFO |------------|------------|------------|------------| INFO | MON 10:30 | History | Physics | | INFO | | I. Jones | M. Curie | | INFO | | 10th grade | 9th grade | | INFO |------------|------------|------------|------------| ... INFO |------------|------------|------------|------------|
Your application will assign Lesson
instances to Timeslot
and Room
instances automatically
by using AI to adhere to hard and soft scheduling constraints, for example:
-
A room can have at most one lesson at the same time.
-
A teacher can teach at most one lesson at the same time.
-
A student can attend at most one lesson at the same time.
-
A teacher prefers to teach all lessons in the same room.
-
A teacher prefers to teach sequential lessons and dislikes gaps between lessons.
-
A student dislikes sequential lessons on the same subject.
Mathematically speaking, school timetabling is an NP-hard problem. This means it is difficult to scale. Simply brute force iterating through all possible combinations takes millions of years for a non-trivial dataset, even on a supercomputer. Fortunately, AI constraint solvers such as Timefold Solver have advanced algorithms that deliver a near-optimal solution in a reasonable amount of time.
2. Solution source code
Follow the instructions in the next sections to create the application step by step (recommended).
Alternatively, review the completed example:
-
Complete one of the following tasks:
-
Clone the Git repository:
$ git clone https://github.com/TimefoldAI/timefold-quickstarts
-
Download an archive.
-
-
Find the solution in
-
Follow the instructions in the README file to run the application.
3. Prerequisites
To complete this guide, you need:
-
Java
-
Kotlin
-
Python
-
JDK 17+ with
JAVA_HOME
configured appropriately. For example with Sdkman:$ curl -s "https://get.sdkman.io" | bash $ sdk install java
-
Apache Maven 3.9.2+ or Gradle 7+
-
An IDE, such as IntelliJ IDEA, VSCode or Eclipse
-
JDK 17+ with
JAVA_HOME
configured appropriately. For example with Sdkman:$ curl -s "https://get.sdkman.io" | bash $ sdk install java
-
Apache Maven 3.9.2+ or Gradle 7+
-
An IDE, such as IntelliJ IDEA, VSCode or Eclipse
-
JDK 17+ with
JAVA_HOME
configured appropriately -
Python 3.10+
-
An IDE, such as IntelliJ PyCharm or VSCode
4. The build file and the dependencies
Create a Maven or Gradle build file and add these dependencies:
-
timefold-solver-core
(compile scope) to solve the school timetable problem. -
timefold-solver-test
(test scope) to JUnit test the school timetabling constraints. -
A logging implementation, such as
logback-classic
(runtime scope), to see what Timefold Solver is doing.
-
Maven
-
Gradle
-
Python
Your pom.xml
file has the following content:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.acme</groupId>
<artifactId>hello-world-school-timetabling</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>ai.timefold.solver</groupId>
<artifactId>timefold-solver-bom</artifactId>
<version>SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.12</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>ai.timefold.solver</groupId>
<artifactId>timefold-solver-core</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Testing -->
<dependency>
<groupId>ai.timefold.solver</groupId>
<artifactId>timefold-solver-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<mainClass>org.acme.schooltimetabling.TimetableApp</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
Your build.gradle
file has this content:
plugins {
id "java"
id "application"
}
def timefoldSolverVersion = "SNAPSHOT"
def logbackVersion = "1.5.12"
group = "org.acme"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
implementation platform("ai.timefold.solver:timefold-solver-bom:${timefoldSolverVersion}")
implementation "ai.timefold.solver:timefold-solver-core"
testImplementation "ai.timefold.solver:timefold-solver-test"
runtimeOnly "ch.qos.logback:logback-classic:${logbackVersion}"
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
compileJava {
options.encoding = "UTF-8"
options.compilerArgs << "-parameters"
}
compileTestJava {
options.encoding = "UTF-8"
}
application {
mainClass = "org.acme.schooltimetabling.TimetableApp"
}
test {
// Log the test execution results.
testLogging {
events "passed", "skipped", "failed"
}
}
Your pyproject.toml
file has this content:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "hello_world"
version = "1.0.0"
requires-python = ">= 3.10"
dependencies = [
"timefold == SNAPSHOT",
"pytest == 8.2.2"
]
5. Model the domain objects
Your goal is to assign each lesson to a time slot and a room. You will create these classes:
5.1. Timeslot
The Timeslot
class represents a time interval when lessons are taught,
for example, Monday 10:30 - 11:30
or Tuesday 13:30 - 14:30
.
For simplicity’s sake, all time slots have the same duration
and there are no time slots during lunch or other breaks.
A time slot has no date, because a high school schedule just repeats every week. So there is no need for continuous planning.
-
Java
-
Kotlin
-
Python
Create the src/main/java/org/acme/schooltimetabling/domain/Timeslot.java
class:
package org.acme.schooltimetabling.domain;
import java.time.DayOfWeek;
import java.time.LocalTime;
public class Timeslot {
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public Timeslot() {
}
public Timeslot(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
this.dayOfWeek = dayOfWeek;
this.startTime = startTime;
this.endTime = endTime;
}
public DayOfWeek getDayOfWeek() {
return dayOfWeek;
}
public LocalTime getStartTime() {
return startTime;
}
public LocalTime getEndTime() {
return endTime;
}
@Override
public String toString() {
return dayOfWeek + " " + startTime;
}
}
Create the src/main/kotlin/org/acme/schooltimetabling/domain/Timeslot.kt
class:
package org.acme.schooltimetabling.domain
import java.time.DayOfWeek
import java.time.LocalTime
data class Timeslot(
val dayOfWeek: DayOfWeek,
val startTime: LocalTime,
val endTime: LocalTime) {
override fun toString(): String = "$dayOfWeek $startTime"
}
Create the Timeslot
class in src/hello_world/domain.py
:
from dataclasses import dataclass
from datetime import time
@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')}'
Because no Timeslot
instances change during solving, a Timeslot
is called a problem fact.
Such classes do not require any Timefold Solver specific annotations.
Notice the toString()
method keeps the output short,
so it is easier to read Timefold Solver’s DEBUG
or TRACE
log, as shown later.
5.2. Room
The Room
class represents a location where lessons are taught,
for example, Room A
or Room B
.
For simplicity’s sake, all rooms are without capacity limits
and they can accommodate all lessons.
-
Java
-
Kotlin
-
Python
Create the src/main/java/org/acme/schooltimetabling/domain/Room.java
class:
package org.acme.schooltimetabling.domain;
public class Room {
private String name;
public Room() {
}
public Room(String name) {
this.name = name;
}
public String getName() {
return name;
}
@Override
public String toString() {
return name;
}
}
Create the src/main/kotlin/org/acme/schooltimetabling/domain/Room.kt
class:
package org.acme.schooltimetabling.domain
data class Room(
val name: String) {
override fun toString(): String = name
}
Create the Room
class in src/hello_world/domain.py
:
from dataclasses import dataclass
@dataclass
class Room:
name: str
def __str__(self):
return f'{self.name}'
endif:[]
Room
instances do not change during solving, so Room
is also a problem fact.
5.3. Lesson
During a lesson, represented by the Lesson
class,
a teacher teaches a subject to a group of students,
for example, Math by A.Turing for 9th grade
or Chemistry by M.Curie for 10th grade
.
If a subject is taught multiple times per week by the same teacher to the same student group,
there are multiple Lesson
instances that are only distinguishable by id
.
For example, the 9th grade has six math lessons a week.
During solving, Timefold Solver changes the timeslot
and room
fields of the Lesson
class,
to assign each lesson to a time slot and a room.
Because Timefold Solver changes these fields, Lesson
is a planning entity:
Most of the fields in the previous diagram contain input data, except for the orange fields:
A lesson’s timeslot
and room
fields are unassigned (null
) in the input data
and assigned (not null
) in the output data.
Timefold Solver changes these fields during solving.
Such fields are called planning variables.
In order for Timefold Solver to recognize them,
both the timeslot
and room
fields require an @PlanningVariable
annotation.
Their containing class, Lesson
, requires an @PlanningEntity
annotation.
-
Java
-
Kotlin
-
Python
Create the src/main/java/org/acme/schooltimetabling/domain/Lesson.java
class:
package org.acme.schooltimetabling.domain;
import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
import ai.timefold.solver.core.api.domain.lookup.PlanningId;
import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
@PlanningEntity
public class Lesson {
@PlanningId
private String id;
private String subject;
private String teacher;
private String studentGroup;
@PlanningVariable
private Timeslot timeslot;
@PlanningVariable
private Room room;
public Lesson() {
}
public Lesson(String id, String subject, String teacher, String studentGroup) {
this.id = id;
this.subject = subject;
this.teacher = teacher;
this.studentGroup = studentGroup;
}
public String getId() {
return id;
}
public String getSubject() {
return subject;
}
public String getTeacher() {
return teacher;
}
public String getStudentGroup() {
return studentGroup;
}
public Timeslot getTimeslot() {
return timeslot;
}
public void setTimeslot(Timeslot timeslot) {
this.timeslot = timeslot;
}
public Room getRoom() {
return room;
}
public void setRoom(Room room) {
this.room = room;
}
@Override
public String toString() {
return subject + "(" + id + ")";
}
}
Create the src/main/kotlin/org/acme/schooltimetabling/domain/Lesson.kt
class:
package org.acme.schooltimetabling.domain
import ai.timefold.solver.core.api.domain.entity.PlanningEntity
import ai.timefold.solver.core.api.domain.lookup.PlanningId
import ai.timefold.solver.core.api.domain.variable.PlanningVariable
@PlanningEntity
data class Lesson (
@PlanningId
val id: String,
val subject: String,
val teacher: String,
val studentGroup: String) {
@PlanningVariable
var timeslot: Timeslot? = null
@PlanningVariable
var room: Room? = null
// No-arg constructor required for Timefold
constructor() : this("0", "", "", "")
override fun toString(): String = "$subject($id)"
}
Create the Lesson
class in src/hello_world/domain.py
:
from timefold.solver.domain import planning_entity, PlanningId, PlanningVariable
from dataclasses import dataclass, field
from typing import Annotated
@planning_entity
@dataclass
class Lesson:
id: Annotated[str, PlanningId]
subject: str
teacher: str
student_group: str
timeslot: Annotated[Timeslot | None, PlanningVariable] = field(default=None)
room: Annotated[Room | None, PlanningVariable] = field(default=None)
The Lesson
class has an @PlanningEntity
annotation,
so Timefold Solver knows that this class changes during solving
because it contains one or more planning variables.
The timeslot
field has an @PlanningVariable
annotation,
so Timefold Solver knows that it can change its value.
In order to find potential Timeslot
instances to assign to this field,
Timefold Solver uses the variable type to connect to a value range provider
that provides a List<Timeslot>
to pick from.
The room
field also has an @PlanningVariable
annotation, for the same reasons.
Determining the |
6. Define the constraints and calculate the score
A score represents the quality of a specific solution. The higher the better. Timefold Solver looks for the best solution, which is the solution with the highest score found in the available time. It might be the optimal solution.
Because this use case has hard and soft constraints,
use the HardSoftScore
class to represent the score:
-
Hard constraints must not be broken. For example: A room can have at most one lesson at the same time.
-
Soft constraints should not be broken. For example: A teacher prefers to teach in a single room.
Hard constraints are weighted against other hard constraints. Soft constraints are weighted too, against other soft constraints. Hard constraints always outweigh soft constraints, regardless of their respective weights.
To calculate the score, you could implement an EasyScoreCalculator
class:
-
Java
-
Kotlin
-
Python
public class TimetableEasyScoreCalculator implements EasyScoreCalculator<Timetable, HardSoftScore> {
@Override
public HardSoftScore calculateScore(Timetable timetable) {
List<Lesson> lessons = timetable.getLessons();
int hardScore = 0;
for (Lesson a : lessons) {
for (Lesson b : lessons) {
if (a.getTimeslot() != null && a.getTimeslot().equals(b.getTimeslot())
&& a.getId() < b.getId()) {
// A room can accommodate at most one lesson at the same time.
if (a.getRoom() != null && a.getRoom().equals(b.getRoom())) {
hardScore--;
}
// A teacher can teach at most one lesson at the same time.
if (a.getTeacher().equals(b.getTeacher())) {
hardScore--;
}
// A student can attend at most one lesson at the same time.
if (a.getStudentGroup().equals(b.getStudentGroup())) {
hardScore--;
}
}
}
}
int softScore = 0;
// Soft constraints are only implemented in the timefold-quickstarts code
return HardSoftScore.of(hardScore, softScore);
}
}
class TimetableEasyScoreCalculator : EasyScoreCalculator<Timetable, HardSoftScore> {
override fun calculateScore(solution: Timetable): HardSoftScore {
val lessons = solution.lessons
var hardScore = 0
for (a in lessons) {
for (b in lessons) {
if (a.timeslot != null && a.timeslot == b.timeslot && a.id!! < b.id!!) {
// A room can accommodate at most one lesson at the same time.
if (a.room != null && a.room == b.room) {
hardScore--
}
// A teacher can teach at most one lesson at the same time.
if (a.teacher == b.teacher) {
hardScore--
}
// A student can attend at most one lesson at the same time.
if (a.studentGroup == b.studentGroup) {
hardScore--
}
}
}
}
val softScore = 0
// Soft constraints are only implemented in the timefold-quickstarts code
return HardSoftScore.of(hardScore, softScore)
}
}
from timefold.score.score import easy_score_calculator, HardSoftScore
@easy_score_calculator
def school_timetable_constraints(solution: Timetable):
lessons = solution.lessons
hard_score = 0
for a in lessons:
for b in lessons:
if a.timeslot != null and a.timeslot == b.timeslot and a.id < b.id:
# A room can accommodate at most one lesson at the same time.
if a.room != null and a.room == b.room:
hard_score -= 1
# A teacher can teach at most one lesson at the same time.
if a.teacher == b.teacher:
hard_score -= 1
# A student can attend at most one lesson at the same time.
if a.student_group == b.student_group:
hard_score -= 1
soft_score = 0
# Soft constraints are only implemented in the timefold-quickstarts code
return HardSoftScore.of(hard_score, soft_score)
Unfortunately that does not scale well, because it is non-incremental: every time a lesson is assigned to a different time slot or room, all lessons are re-evaluated to calculate the new score.
Instead, create a TimetableConstraintProvider
class
to perform incremental score calculation.
It uses Timefold Solver’s Constraint Streams API
which is inspired by Java Streams and SQL:
-
Java
-
Kotlin
-
Python
Create a src/main/java/org/acme/schooltimetabling/solver/TimetableConstraintProvider.java
class:
package org.acme.schooltimetabling.solver;
import org.acme.schooltimetabling.domain.Lesson;
import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore;
import ai.timefold.solver.core.api.score.stream.Constraint;
import ai.timefold.solver.core.api.score.stream.ConstraintFactory;
import ai.timefold.solver.core.api.score.stream.ConstraintProvider;
import ai.timefold.solver.core.api.score.stream.Joiners;
public class TimetableConstraintProvider implements ConstraintProvider {
@Override
public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
return new Constraint[] {
// Hard constraints
roomConflict(constraintFactory),
teacherConflict(constraintFactory),
studentGroupConflict(constraintFactory),
// Soft constraints are only implemented in the timefold-quickstarts code
};
}
Constraint roomConflict(ConstraintFactory constraintFactory) {
// A room can accommodate at most one lesson at the same time.
return constraintFactory
// Select each pair of 2 different lessons ...
.forEachUniquePair(Lesson.class,
// ... in the same timeslot ...
Joiners.equal(Lesson::getTimeslot),
// ... in the same room ...
Joiners.equal(Lesson::getRoom))
// ... and penalize each pair with a hard weight.
.penalize(HardSoftScore.ONE_HARD)
.asConstraint("Room conflict");
}
Constraint teacherConflict(ConstraintFactory constraintFactory) {
// A teacher can teach at most one lesson at the same time.
return constraintFactory
.forEachUniquePair(Lesson.class,
Joiners.equal(Lesson::getTimeslot),
Joiners.equal(Lesson::getTeacher))
.penalize(HardSoftScore.ONE_HARD)
.asConstraint("Teacher conflict");
}
Constraint studentGroupConflict(ConstraintFactory constraintFactory) {
// A student can attend at most one lesson at the same time.
return constraintFactory
.forEachUniquePair(Lesson.class,
Joiners.equal(Lesson::getTimeslot),
Joiners.equal(Lesson::getStudentGroup))
.penalize(HardSoftScore.ONE_HARD)
.asConstraint("Student group conflict");
}
}
Create a src/main/kotlin/org/acme/schooltimetabling/solver/TimetableConstraintProvider.kt
class:
package org.acme.kotlin.schooltimetabling.solver
import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore
import ai.timefold.solver.core.api.score.stream.Constraint
import ai.timefold.solver.core.api.score.stream.ConstraintFactory
import ai.timefold.solver.core.api.score.stream.ConstraintProvider
import ai.timefold.solver.core.api.score.stream.Joiners
import org.acme.kotlin.schooltimetabling.domain.Lesson
import org.acme.kotlin.schooltimetabling.solver.justifications.*
import java.time.Duration
class TimeTableConstraintProvider : ConstraintProvider {
override fun defineConstraints(constraintFactory: ConstraintFactory): Array<Constraint> {
return arrayOf(
// Hard constraints
roomConflict(constraintFactory),
teacherConflict(constraintFactory),
studentGroupConflict(constraintFactory),
// Soft constraints
teacherRoomStability(constraintFactory),
teacherTimeEfficiency(constraintFactory),
studentGroupSubjectVariety(constraintFactory)
)
}
fun roomConflict(constraintFactory: ConstraintFactory): Constraint {
// A room can accommodate at most one lesson at the same time.
return constraintFactory
// Select each pair of 2 different lessons ...
.forEachUniquePair(
Lesson::class.java,
// ... in the same timeslot ...
Joiners.equal(Lesson::timeslot),
// ... in the same room ...
Joiners.equal(Lesson::room)
)
// ... and penalize each pair with a hard weight.
.penalize(HardSoftScore.ONE_HARD)
.justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
RoomConflictJustification(lesson1.room, lesson1,lesson2)}
.asConstraint("Room conflict")
}
fun teacherConflict(constraintFactory: ConstraintFactory): Constraint {
// A teacher can teach at most one lesson at the same time.
return constraintFactory
.forEachUniquePair(
Lesson::class.java,
Joiners.equal(Lesson::timeslot),
Joiners.equal(Lesson::teacher)
)
.penalize(HardSoftScore.ONE_HARD)
.justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
TeacherConflictJustification(lesson1.teacher, lesson1, lesson2)}
.asConstraint("Teacher conflict")
}
fun studentGroupConflict(constraintFactory: ConstraintFactory): Constraint {
// A student can attend at most one lesson at the same time.
return constraintFactory
.forEachUniquePair(
Lesson::class.java,
Joiners.equal(Lesson::timeslot),
Joiners.equal(Lesson::studentGroup)
)
.penalize(HardSoftScore.ONE_HARD)
.justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
StudentGroupConflictJustification(lesson1.studentGroup, lesson1, lesson2)}
.asConstraint("Student group conflict")
}
fun teacherRoomStability(constraintFactory: ConstraintFactory): Constraint {
// A teacher prefers to teach in a single room.
return constraintFactory
.forEachUniquePair(
Lesson::class.java,
Joiners.equal(Lesson::teacher)
)
.filter { lesson1: Lesson, lesson2: Lesson -> lesson1.room !== lesson2.room }
.penalize(HardSoftScore.ONE_SOFT)
.justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
TeacherRoomStabilityJustification(lesson1.teacher, lesson1, lesson2)}
.asConstraint("Teacher room stability")
}
fun teacherTimeEfficiency(constraintFactory: ConstraintFactory): Constraint {
// A teacher prefers to teach sequential lessons and dislikes gaps between lessons.
return constraintFactory
.forEach(Lesson::class.java)
.join(Lesson::class.java,
Joiners.equal(Lesson::teacher),
Joiners.equal { lesson: Lesson -> lesson.timeslot?.dayOfWeek })
.filter { lesson1: Lesson, lesson2: Lesson ->
val between = Duration.between(
lesson1.timeslot?.endTime,
lesson2.timeslot?.startTime
)
!between.isNegative && between <= Duration.ofMinutes(30)
}
.reward(HardSoftScore.ONE_SOFT)
.justifyWith{ lesson1: Lesson, lesson2: Lesson, _ ->
TeacherTimeEfficiencyJustification(lesson1.teacher, lesson1, lesson2)}
.asConstraint("Teacher time efficiency")
}
fun studentGroupSubjectVariety(constraintFactory: ConstraintFactory): Constraint {
// A student group dislikes sequential lessons on the same subject.
return constraintFactory
.forEach(Lesson::class.java)
.join(Lesson::class.java,
Joiners.equal(Lesson::subject),
Joiners.equal(Lesson::studentGroup),
Joiners.equal { lesson: Lesson -> lesson.timeslot?.dayOfWeek })
.filter { lesson1: Lesson, lesson2: Lesson ->
val between = Duration.between(
lesson1.timeslot?.endTime,
lesson2.timeslot?.startTime
)
!between.isNegative && between <= Duration.ofMinutes(30)
}
.penalize(HardSoftScore.ONE_SOFT)
.justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
StudentGroupSubjectVarietyJustification(lesson1.studentGroup, lesson1, lesson2)}
.asConstraint("Student group subject variety")
}
}
Create a school_timetabling_constraints
function in src/hello_world/constraints.py
:
from timefold.solver.score import (constraint_provider, HardSoftScore, Joiners,
ConstraintFactory, Constraint)
from .domain import Lesson
@constraint_provider
def define_constraints(constraint_factory: ConstraintFactory):
return [
room_conflict(constraint_factory),
teacher_conflict(constraint_factory),
student_group_conflict(constraint_factory),
# Soft constraints are only implemented in the timefold-quickstarts code
]
def room_conflict(constraint_factory: ConstraintFactory) -> Constraint:
# A room can accommodate at most one lesson at the same time.
return (constraint_factory
# Select each pair of 2 different lessons ...
.for_each_unique_pair(Lesson,
# ... in the same timeslot ...
Joiners.equal(lambda lesson: lesson.timeslot),
# ... in the same room ...
Joiners.equal(lambda lesson: lesson.room))
# ... and penalize each pair with a hard weight.
.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 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"))
The ConstraintProvider
scales an order of magnitude better than the EasyScoreCalculator
: O(n) instead of O(n²).
7. Gather the domain objects in a planning solution
A Timetable
wraps all Timeslot
, Room
, and Lesson
instances of a single dataset.
Furthermore, because it contains all lessons, each with a specific planning variable state,
it is a planning solution and it has a score:
-
If lessons are still unassigned, then it is an uninitialized solution, for example, a solution with the score
-4init/0hard/0soft
. -
If it breaks hard constraints, then it is an infeasible solution, for example, a solution with the score
-2hard/-3soft
. -
If it adheres to all hard constraints, then it is a feasible solution, for example, a solution with the score
0hard/-7soft
.
-
Java
-
Kotlin
-
Python
Create the src/main/java/org/acme/schooltimetabling/domain/Timetable.java
class:
package org.acme.schooltimetabling.domain;
import java.util.List;
import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
import ai.timefold.solver.core.api.domain.solution.PlanningScore;
import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty;
import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore;
@PlanningSolution
public class Timetable {
@ValueRangeProvider
@ProblemFactCollectionProperty
private List<Timeslot> timeslots;
@ValueRangeProvider
@ProblemFactCollectionProperty
private List<Room> rooms;
@PlanningEntityCollectionProperty
private List<Lesson> lessons;
@PlanningScore
private HardSoftScore score;
public Timetable() {
}
public Timetable(List<Timeslot> timeslots, List<Room> rooms, List<Lesson> lessons) {
this.timeslots = timeslots;
this.rooms = rooms;
this.lessons = lessons;
}
public List<Timeslot> getTimeslots() {
return timeslots;
}
public List<Room> getRooms() {
return rooms;
}
public List<Lesson> getLessons() {
return lessons;
}
public HardSoftScore getScore() {
return score;
}
}
Create the src/main/kotlin/org/acme/schooltimetabling/TimetableApp.kt
class:
package org.acme.schooltimetabling.domain
import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty
import ai.timefold.solver.core.api.domain.solution.PlanningScore
import ai.timefold.solver.core.api.domain.solution.PlanningSolution
import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty
import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider
import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore
import ai.timefold.solver.core.api.solver.SolverStatus
@PlanningSolution
data class Timetable (
@ProblemFactCollectionProperty
@ValueRangeProvider
val timeslots: List<Timeslot>,
@ProblemFactCollectionProperty
@ValueRangeProvider
val rooms: List<Room>,
@PlanningEntityCollectionProperty
val lessons: List<Lesson>,
@PlanningScore
var score: HardSoftScore? = null) {
// No-arg constructor required for Timefold
constructor() : this(emptyList(), emptyList(), emptyList())
}
Create the Solution
class in src/hello_world/domain.py
:
from timefold.solver.domain import (planning_solution, PlanningEntityCollectionProperty,
ProblemFactCollectionProperty, ValueRangeProvider,
PlanningScore)
from timefold.solver.score import HardSoftScore
from dataclasses import dataclass, field
from typing import Annotated
@planning_solution
@dataclass
class Timetable:
id: str
timeslots: Annotated[list[Timeslot],
ProblemFactCollectionProperty,
ValueRangeProvider]
rooms: Annotated[list[Room],
ProblemFactCollectionProperty,
ValueRangeProvider]
lessons: Annotated[list[Lesson],
PlanningEntityCollectionProperty]
score: Annotated[HardSoftScore, PlanningScore] = field(default=None)
This content is for GitHub only.
The Timetable
class has an @PlanningSolution
annotation,
so Timefold Solver knows that this class contains all of the input and output data.
Specifically, these classes are the input of the problem:
-
The
timeslots
field with all time slots-
This is a list of problem facts, because they do not change during solving.
-
-
The
rooms
field with all rooms-
This is a list of problem facts, because they do not change during solving.
-
-
The
lessons
field with all lessons-
This is a list of planning entities, because they change during solving.
-
Of each
Lesson
:-
The values of the
timeslot
androom
fields are typically stillnull
, so unassigned. They are planning variables. -
The other fields, such as
subject
,teacher
andstudentGroup
, are filled in. These fields are problem properties.
-
-
However, this class is also the output of the solution:
-
The
lessons
field for which eachLesson
instance has non-nulltimeslot
androom
fields after solving. -
The
score
field that represents the quality of the output solution, for example,0hard/-5soft
.
7.1. The value range providers
The timeslots
field is a value range provider.
It holds the Timeslot
instances which Timefold Solver can pick from to assign to the timeslot
field of Lesson
instances.
The timeslots
field has an @ValueRangeProvider
annotation to connect the @PlanningVariable
with the @ValueRangeProvider
,
by matching the type of the planning variable with the type returned by the value range provider.
Following the same logic, the rooms
field also has an @ValueRangeProvider
annotation.
7.2. The problem fact and planning entity properties
Furthermore, Timefold Solver needs to know which Lesson
instances it can change
as well as how to retrieve the Timeslot
and Room
instances used for score calculation
by your TimetableConstraintProvider
.
The timeslots
and rooms
fields have an @ProblemFactCollectionProperty
annotation,
so your TimetableConstraintProvider
can select from those instances.
The lessons
has an @PlanningEntityCollectionProperty
annotation,
so Timefold Solver can change them during solving
and your TimetableConstraintProvider
can select from those too.
8. Create the application
Now you are ready to put everything together and create a Java application.
The main()
method performs the following tasks:
-
Creates the
SolverFactory
to build aSolver
per dataset. -
Loads a dataset.
-
Solves it with
Solver.solve()
. -
Visualizes the solution for that dataset.
Typically, an application has a single SolverFactory
to build a new Solver
instance for each problem dataset to solve.
A SolverFactory
is thread-safe, but a Solver
is not.
In this case, there is only one dataset, so only one Solver
instance.
-
Java
-
Kotlin
-
Python
Create the src/main/java/org/acme/schooltimetabling/TimetableApp.java
class:
package org.acme.schooltimetabling;
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import ai.timefold.solver.core.api.solver.Solver;
import ai.timefold.solver.core.api.solver.SolverFactory;
import ai.timefold.solver.core.config.solver.SolverConfig;
import org.acme.schooltimetabling.domain.Lesson;
import org.acme.schooltimetabling.domain.Room;
import org.acme.schooltimetabling.domain.Timeslot;
import org.acme.schooltimetabling.domain.Timetable;
import org.acme.schooltimetabling.solver.TimetableConstraintProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class TimetableApp {
private static final Logger LOGGER = LoggerFactory.getLogger(TimetableApp.class);
public static void main(String[] args) {
SolverFactory<Timetable> solverFactory = SolverFactory.create(new SolverConfig()
.withSolutionClass(Timetable.class)
.withEntityClasses(Lesson.class)
.withConstraintProviderClass(TimetableConstraintProvider.class)
// The solver runs only for 5 seconds on this small dataset.
// It's recommended to run for at least 5 minutes ("5m") otherwise.
.withTerminationSpentLimit(Duration.ofSeconds(5)));
// Load the problem
Timetable problem = generateDemoData();
// Solve the problem
Solver<Timetable> solver = solverFactory.buildSolver();
Timetable solution = solver.solve(problem);
// Visualize the solution
printTimetable(solution);
}
public static Timetable generateDemoData() {
List<Timeslot> timeslots = new ArrayList<>(10);
timeslots.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)));
timeslots.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 30), LocalTime.of(10, 30)));
timeslots.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(10, 30), LocalTime.of(11, 30)));
timeslots.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(13, 30), LocalTime.of(14, 30)));
timeslots.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(14, 30), LocalTime.of(15, 30)));
timeslots.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)));
timeslots.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(9, 30), LocalTime.of(10, 30)));
timeslots.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(10, 30), LocalTime.of(11, 30)));
timeslots.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(13, 30), LocalTime.of(14, 30)));
timeslots.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(14, 30), LocalTime.of(15, 30)));
List<Room> rooms = new ArrayList<>(3);
rooms.add(new Room("Room A"));
rooms.add(new Room("Room B"));
rooms.add(new Room("Room C"));
List<Lesson> lessons = new ArrayList<>();
long nextLessonId = 0L;
lessons.add(new Lesson(Long.toString(nextLessonId++), "Math", "A. Turing", "9th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "Math", "A. Turing", "9th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "Physics", "M. Curie", "9th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "Chemistry", "M. Curie", "9th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "Biology", "C. Darwin", "9th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "History", "I. Jones", "9th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "English", "I. Jones", "9th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "English", "I. Jones", "9th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "Spanish", "P. Cruz", "9th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "Spanish", "P. Cruz", "9th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "Math", "A. Turing", "10th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "Math", "A. Turing", "10th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "Math", "A. Turing", "10th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "Physics", "M. Curie", "10th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "Chemistry", "M. Curie", "10th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "French", "M. Curie", "10th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "Geography", "C. Darwin", "10th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "History", "I. Jones", "10th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId++), "English", "P. Cruz", "10th grade"));
lessons.add(new Lesson(Long.toString(nextLessonId), "Spanish", "P. Cruz", "10th grade"));
return new Timetable(timeslots, rooms, lessons);
}
private static void printTimetable(Timetable timetable) {
LOGGER.info("");
List<Room> rooms = timetable.getRooms();
List<Lesson> lessons = timetable.getLessons();
Map<Timeslot, Map<Room, List<Lesson>>> lessonMap = lessons.stream()
.filter(lesson -> lesson.getTimeslot() != null && lesson.getRoom() != null)
.collect(Collectors.groupingBy(Lesson::getTimeslot, Collectors.groupingBy(Lesson::getRoom)));
LOGGER.info("| | " + rooms.stream()
.map(room -> String.format("%-10s", room.getName())).collect(Collectors.joining(" | ")) + " |");
LOGGER.info("|" + "------------|".repeat(rooms.size() + 1));
for (Timeslot timeslot : timetable.getTimeslots()) {
List<List<Lesson>> cells = rooms.stream()
.map(room -> {
Map<Room, List<Lesson>> byRoomMap = lessonMap.get(timeslot);
if (byRoomMap == null) {
return Collections.<Lesson>emptyList();
}
List<Lesson> cellLessons = byRoomMap.get(room);
return Objects.requireNonNullElse(cellLessons, Collections.<Lesson>emptyList());
}).toList();
LOGGER.info("| " + String.format("%-10s",
timeslot.getDayOfWeek().toString().substring(0, 3) + " " + timeslot.getStartTime()) + " | "
+ cells.stream().map(cellLessons -> String.format("%-10s",
cellLessons.stream().map(Lesson::getSubject).collect(Collectors.joining(", "))))
.collect(Collectors.joining(" | "))
+ " |");
LOGGER.info("| | "
+ cells.stream().map(cellLessons -> String.format("%-10s",
cellLessons.stream().map(Lesson::getTeacher).collect(Collectors.joining(", "))))
.collect(Collectors.joining(" | "))
+ " |");
LOGGER.info("| | "
+ cells.stream().map(cellLessons -> String.format("%-10s",
cellLessons.stream().map(Lesson::getStudentGroup).collect(Collectors.joining(", "))))
.collect(Collectors.joining(" | "))
+ " |");
LOGGER.info("|" + "------------|".repeat(rooms.size() + 1));
}
List<Lesson> unassignedLessons = lessons.stream()
.filter(lesson -> lesson.getTimeslot() == null || lesson.getRoom() == null)
.toList();
if (!unassignedLessons.isEmpty()) {
LOGGER.info("");
LOGGER.info("Unassigned lessons");
for (Lesson lesson : unassignedLessons) {
LOGGER.info(" " + lesson.getSubject() + " - " + lesson.getTeacher() + " - " + lesson.getStudentGroup());
}
}
}
}
Create the src/main/kotlin/org/acme/schooltimetabling/TimetableApp.kt
class:
package org.acme.schooltimetabling
import ai.timefold.solver.core.api.solver.SolverFactory
import ai.timefold.solver.core.config.solver.SolverConfig
import org.acme.schooltimetabling.domain.Lesson
import org.acme.schooltimetabling.domain.Room
import org.acme.schooltimetabling.domain.Timeslot
import org.acme.schooltimetabling.domain.Timetable
import org.acme.schooltimetabling.solver.TimetableConstraintProvider
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.time.DayOfWeek
import java.time.Duration
import java.time.LocalTime
import java.util.Objects
import java.util.function.Function
import java.util.stream.Collectors
object TimetableApp {
private val LOGGER: Logger = LoggerFactory.getLogger(TimetableApp::class.java)
@JvmStatic
fun main(args: Array<String>) {
val solverFactory = SolverFactory.create<Timetable>(
SolverConfig()
.withSolutionClass(Timetable::class.java)
.withEntityClasses(Lesson::class.java)
.withConstraintProviderClass(TimetableConstraintProvider::class.java)
// The solver runs only for 5 seconds on this small dataset.
// It's recommended to run for at least 5 minutes ("5m") otherwise.
.withTerminationSpentLimit(Duration.ofSeconds(5))
)
// Load the problem
val problem = generateDemoData(DemoData.SMALL)
// Solve the problem
val solver = solverFactory.buildSolver()
val solution = solver.solve(problem)
// Visualize the solution
printTimetable(solution)
}
fun generateDemoData(): Timetable {
val timeslots: MutableList<Timeslot> = ArrayList(10)
timeslots.add(Timeslot(DayOfWeek.MONDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)))
timeslots.add(Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 30), LocalTime.of(10, 30)))
timeslots.add(Timeslot(DayOfWeek.MONDAY, LocalTime.of(10, 30), LocalTime.of(11, 30)))
timeslots.add(Timeslot(DayOfWeek.MONDAY, LocalTime.of(13, 30), LocalTime.of(14, 30)))
timeslots.add(Timeslot(DayOfWeek.MONDAY, LocalTime.of(14, 30), LocalTime.of(15, 30)))
timeslots.add(Timeslot(DayOfWeek.TUESDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)))
timeslots.add(Timeslot(DayOfWeek.TUESDAY, LocalTime.of(9, 30), LocalTime.of(10, 30)))
timeslots.add(Timeslot(DayOfWeek.TUESDAY, LocalTime.of(10, 30), LocalTime.of(11, 30)))
timeslots.add(Timeslot(DayOfWeek.TUESDAY, LocalTime.of(13, 30), LocalTime.of(14, 30)))
timeslots.add(Timeslot(DayOfWeek.TUESDAY, LocalTime.of(14, 30), LocalTime.of(15, 30)))
val rooms: MutableList<Room> = ArrayList(3)
rooms.add(Room("Room A"))
rooms.add(Room("Room B"))
rooms.add(Room("Room C"))
val lessons: MutableList<Lesson> = ArrayList()
var nextLessonId = 0L
lessons.add(Lesson(nextLessonId++.toString(), "Math", "A. Turing", "9th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "Math", "A. Turing", "9th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "Physics", "M. Curie", "9th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "Chemistry", "M. Curie", "9th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "Biology", "C. Darwin", "9th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "History", "I. Jones", "9th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "English", "I. Jones", "9th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "English", "I. Jones", "9th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "Spanish", "P. Cruz", "9th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "Spanish", "P. Cruz", "9th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "Math", "A. Turing", "10th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "Math", "A. Turing", "10th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "Math", "A. Turing", "10th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "Physics", "M. Curie", "10th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "Chemistry", "M. Curie", "10th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "French", "M. Curie", "10th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "Geography", "C. Darwin", "10th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "History", "I. Jones", "10th grade"))
lessons.add(Lesson(nextLessonId++.toString(), "English", "P. Cruz", "10th grade"))
lessons.add(Lesson(nextLessonId.toString(), "Spanish", "P. Cruz", "10th grade"))
return Timetable(timeslots, rooms, lessons)
}
private fun printTimetable(timeTable: Timetable) {
LOGGER.info("")
val rooms = timeTable.rooms
val lessons = timeTable.lessons
val lessonMap = lessons.stream()
.filter { lesson: Lesson -> lesson.timeslot != null && lesson.room != null }
.collect(
Collectors.groupingBy<Lesson, Timeslot, Any, Map<Room, List<Lesson>>>(
Function { obj: Lesson -> obj.timeslot }, Collectors.groupingBy(
Function { obj: Lesson -> obj.room })
)
)
LOGGER.info("| | " + rooms.stream()
.map { room: Room -> String.format("%-10s", room.name) }.collect(Collectors.joining(" | ")) + " |"
)
LOGGER.info("|" + "------------|".repeat(rooms.size + 1))
for (timeslot in timeTable.timeslots) {
val cells = rooms.stream()
.map { room: Room ->
val byRoomMap = lessonMap[timeslot]
?: return@map emptyList<Lesson>()
val cellLessons = byRoomMap[room]!!
Objects.requireNonNullElse(cellLessons, emptyList())
}.toList()
LOGGER.info("| " + String.format(
"%-10s",
timeslot.dayOfWeek.toString().substring(0, 3) + " " + timeslot.startTime
) + " | "
+ cells.stream().map { cellLessons: List<Lesson> ->
String.format("%-10s",
cellLessons.stream().map { obj: Lesson -> obj.subject }.collect(Collectors.joining(", "))
)
}
.collect(Collectors.joining(" | "))
+ " |")
LOGGER.info("| | "
+ cells.stream().map { cellLessons: List<Lesson> ->
String.format("%-10s",
cellLessons.stream().map { obj: Lesson -> obj.teacher }.collect(Collectors.joining(", "))
)
}
.collect(Collectors.joining(" | "))
+ " |")
LOGGER.info("| | "
+ cells.stream().map { cellLessons: List<Lesson> ->
String.format("%-10s",
cellLessons.stream().map { obj: Lesson -> obj.studentGroup }
.collect(Collectors.joining(", ")))
}
.collect(Collectors.joining(" | "))
+ " |")
LOGGER.info("|" + "------------|".repeat(rooms.size + 1))
}
val unassignedLessons = lessons.stream()
.filter { lesson: Lesson -> lesson.timeslot == null || lesson.room == null }
.toList()
if (!unassignedLessons.isEmpty()) {
LOGGER.info("")
LOGGER.info("Unassigned lessons")
for (lesson in unassignedLessons) {
LOGGER.info(" " + lesson.subject + " - " + lesson.teacher + " - " + lesson.studentGroup)
}
}
}
}
Create src/hello_world/main.py
with the following contents:
from timefold.solver.config import (SolverConfig, ScoreDirectorFactoryConfig,
TerminationConfig, Duration)
from timefold.solver import SolverFactory
from datetime import time
import logging
import logging.config
from .domain import *
from .constraints import define_constraints
logging.config.fileConfig('logging.conf')
LOGGER = logging.getLogger('app')
def main():
solver_factory = SolverFactory.create(
SolverConfig(
solution_class=Timetable,
entity_class_list=[Lesson],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=define_constraints
),
termination_config=TerminationConfig(
# The solver runs only for 5 seconds on this small dataset.
# It's recommended to run for at least 5 minutes ("5m") otherwise.
spent_limit=Duration(seconds=5)
)
))
# Load the problem
problem = generate_demo_data()
# Solve the problem
solver = solver_factory.build_solver()
solution = solver.solve(problem)
# Visualize the solution
print_timetable(solution)
def generate_demo_data() -> Timetable:
days = ('MONDAY', 'TUESDAY')
timeslots = [
Timeslot(day, start, start.replace(hour=start.hour + 1))
for day in days
for start in (time(8, 30), time(9, 30), time(10, 30), time(13, 30), time(14, 30))
]
room_ids = ('A', 'B', 'C')
rooms = [Room(f'Room {name}') for name in room_ids]
lessons = []
def id_generator():
current = 0
while True:
yield str(current)
current += 1
ids = id_generator()
lessons.append(Lesson(next(ids), "Math", "A. Turing", "9th grade"))
lessons.append(Lesson(next(ids), "Math", "A. Turing", "9th grade"))
lessons.append(Lesson(next(ids), "Physics", "M. Curie", "9th grade"))
lessons.append(Lesson(next(ids), "Chemistry", "M. Curie", "9th grade"))
lessons.append(Lesson(next(ids), "Biology", "C. Darwin", "9th grade"))
lessons.append(Lesson(next(ids), "History", "I. Jones", "9th grade"))
lessons.append(Lesson(next(ids), "English", "I. Jones", "9th grade"))
lessons.append(Lesson(next(ids), "English", "I. Jones", "9th grade"))
lessons.append(Lesson(next(ids), "Spanish", "P. Cruz", "9th grade"))
lessons.append(Lesson(next(ids), "Spanish", "P. Cruz", "9th grade"))
lessons.append(Lesson(next(ids), "Math", "A. Turing", "10th grade"))
lessons.append(Lesson(next(ids), "Math", "A. Turing", "10th grade"))
lessons.append(Lesson(next(ids), "Math", "A. Turing", "10th grade"))
lessons.append(Lesson(next(ids), "Physics", "M. Curie", "10th grade"))
lessons.append(Lesson(next(ids), "Chemistry", "M. Curie", "10th grade"))
lessons.append(Lesson(next(ids), "French", "M. Curie", "10th grade"))
lessons.append(Lesson(next(ids), "Geography", "C. Darwin", "10th grade"))
lessons.append(Lesson(next(ids), "History", "I. Jones", "10th grade"))
lessons.append(Lesson(next(ids), "English", "P. Cruz", "10th grade"))
lessons.append(Lesson(next(ids), "Spanish", "P. Cruz", "10th grade"))
return Timetable(demo_data.name, timeslots, rooms, lessons)
def print_timetable(time_table: Timetable) -> None:
LOGGER.info("")
column_width = 18
rooms = time_table.rooms
timeslots = time_table.timeslots
lessons = time_table.lessons
lesson_map = {
(lesson.room.name, lesson.timeslot.day_of_week, lesson.timeslot.start_time): lesson
for lesson in lessons
if lesson.room is not None and lesson.timeslot is not None
}
row_format = ("|{:<" + str(column_width) + "}") * (len(rooms) + 1) + "|"
sep_format = "+" + ((("-" * column_width) + "+") * (len(rooms) + 1))
LOGGER.info(sep_format)
LOGGER.info(row_format.format('', *[room.name for room in rooms]))
LOGGER.info(sep_format)
for timeslot in timeslots:
def get_row_lessons():
for room in rooms:
yield lesson_map.get((room.name, timeslot.day_of_week, timeslot.start_time),
Lesson('', '', '', ''))
row_lessons = [*get_row_lessons()]
LOGGER.info(row_format.format(str(timeslot), *[lesson.subject for lesson in row_lessons]))
LOGGER.info(row_format.format('', *[lesson.teacher for lesson in row_lessons]))
LOGGER.info(row_format.format('', *[lesson.student_group for lesson in row_lessons]))
LOGGER.info(sep_format)
unassigned_lessons = [lesson for lesson in lessons if lesson.room is None or lesson.timeslot is None]
if len(unassigned_lessons) > 0:
LOGGER.info("")
LOGGER.info("Unassigned lessons")
for lesson in unassigned_lessons:
LOGGER.info(f' {lesson.subject} - {lesson.teacher} - {lesson.student_group}')
if __name__ == '__main__':
main()
The main()
method first creates the SolverFactory
:
-
Java
-
Kotlin
-
Python
SolverFactory<Timetable> solverFactory = SolverFactory.create(
new SolverConfig()
.withSolutionClass(Timetable.class)
.withEntityClasses(Lesson.class)
.withConstraintProviderClass(TimetableConstraintProvider.class)
// The solver runs only for 5 seconds on this small dataset.
// It's recommended to run for at least 5 minutes ("5m") otherwise.
.withTerminationSpentLimit(Duration.ofSeconds(5)));
val solverFactory = SolverFactory.create<Timetable>(
SolverConfig()
.withSolutionClass(Timetable::class.java)
.withEntityClasses(Lesson::class.java)
.withConstraintProviderClass(TimetableConstraintProvider::class.java)
// The solver runs only for 5 seconds on this small dataset.
// It's recommended to run for at least 5 minutes ("5m") otherwise.
.withTerminationSpentLimit(Duration.ofSeconds(5)))
solver_factory = SolverFactory.create(
SolverConfig(
solution_class=Timetable,
entity_class_list=[Lesson],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=define_constraints
),
termination_config=TerminationConfig(
# The solver runs only for 5 seconds on this small dataset.
# It's recommended to run for at least 5 minutes ("5m") otherwise.
spent_limit=Duration(seconds=5)
)
))
This registers the @PlanningSolution
class, the @PlanningEntity
classes,
and the ConstraintProvider
class, all of which you created earlier.
Without a termination setting or a terminationEarly()
event, the solver runs forever.
To avoid that, the solver limits the solving time to five seconds.
The main()
method loads the problem, solves it, and prints the solution after just over five seconds.
-
Java
-
Kotlin
-
Python
// Load the problem
Timetable problem = generateDemoData();
// Solve the problem
Solver<Timetable> solver = solverFactory.buildSolver();
Timetable solution = solver.solve(problem);
// Visualize the solution
printTimetable(solution);
// Load the problem
val problem = generateDemoData(DemoData.SMALL)
// Solve the problem
val solver = solverFactory.buildSolver()
val solution = solver.solve(problem)
// Visualize the solution
printTimetable(solution)
# Load the problem
problem = generate_demo_data(DemoData.SMALL)
# Solve the problem
solver = solver_factory.build_solver()
solution = solver.solve(problem)
# Visualize the solution
print_timetable(solution)
The solve()
method doesn’t return instantly.
It runs for five seconds before returning the best solution.
Timefold Solver returns the best solution found in the available termination time. Due to the nature of NP-hard problems, the best solution might not be optimal, especially for larger datasets. Increase the termination time to potentially find a better solution.
The generateDemoData()
method generates the school timetable problem to solve.
The printTimetable()
method pretty prints the timetable to the console,
allowing to determine visually whether it’s a good schedule.
8.1. Configure logging
To see any output in the console, logging must be configured properly.
-
Java
-
Kotlin
-
Python
Create the src/main/resource/logback.xml
file:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="consoleAppender" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%-12.12t] %-5p %m%n</pattern>
</encoder>
</appender>
<logger name="ai.timefold.solver" level="info"/>
<root level="info">
<appender-ref ref="consoleAppender" />
</root>
</configuration>
Create the src/main/resource/logback.xml
file:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="consoleAppender" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%-12.12t] %-5p %m%n</pattern>
</encoder>
</appender>
<logger name="ai.timefold.solver" level="info"/>
<root level="info">
<appender-ref ref="consoleAppender" />
</root>
</configuration>
Create the logging.conf
file:
[loggers]
keys=root,timefold_solver
[handlers]
keys=consoleHandler
[formatters]
keys=simpleFormatter
[logger_root]
level=INFO
handlers=consoleHandler
[logger_timefold_solver]
level=INFO
qualname=timefold.solver
handlers=consoleHandler
propagate=0
[handler_consoleHandler]
class=StreamHandler
level=INFO
formatter=simpleFormatter
args=(sys.stdout,)
[formatter_simpleFormatter]
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
9. Run the application
9.1. Run the application in IDE
-
Java
-
Kotlin
-
Python
Run the TimetableApp
class as the main class of a normal Java application:
Run the TimetableApp
class as the main class of a normal Java application:
Run src/hello_world/main.py
:
... INFO | | Room A | Room B | Room C | INFO |------------|------------|------------|------------| INFO | MON 08:30 | English | Math | | INFO | | I. Jones | A. Turing | | INFO | | 9th grade | 10th grade | | INFO |------------|------------|------------|------------| INFO | MON 09:30 | History | Physics | | INFO | | I. Jones | M. Curie | | INFO | | 9th grade | 10th grade | | ...
Verify the console output. Does it conform to all hard constraints?
What happens if you comment out the roomConflict
constraint in TimetableConstraintProvider
?
The info
log shows what Timefold Solver did in those five seconds:
... Solving started: time spent (33), best score (-8init/0hard/0soft), environment mode (REPRODUCIBLE), random (JDK with seed 0).
... Construction Heuristic phase (0) ended: time spent (73), best score (0hard/0soft), move evaluation speed (459/sec), step total (4).
... Local Search phase (1) ended: time spent (5000), best score (0hard/0soft), move evaluation speed (28949/sec), step total (28398).
... Solving ended: time spent (5000), best score (0hard/0soft), move evaluation speed (28524/sec), phase total (2), environment mode (REPRODUCIBLE).
9.2. Test the application
A good application includes test coverage.
9.2.1. Test the constraints
To test each constraint in isolation, use a ConstraintVerifier
in unit tests.
This tests each constraint’s corner cases in isolation from the other tests,
which lowers maintenance when adding a new constraint with proper test coverage.
-
Java
-
Kotlin
-
Python
Create the src/test/java/org/acme/schooltimetabling/solver/TimetableConstraintProviderTest.java
class:
package org.acme.schooltimetabling.solver;
import java.time.DayOfWeek;
import java.time.LocalTime;
import org.acme.schooltimetabling.domain.Lesson;
import org.acme.schooltimetabling.domain.Room;
import org.acme.schooltimetabling.domain.Timetable;
import org.acme.schooltimetabling.domain.Timeslot;
import org.junit.jupiter.api.Test;
import ai.timefold.solver.test.api.score.stream.ConstraintVerifier;
class TimetableConstraintProviderTest {
private static final Room ROOM1 = new Room("Room1");
private static final Timeslot TIMESLOT1 = new Timeslot(DayOfWeek.MONDAY, LocalTime.NOON);
private static final Timeslot TIMESLOT2 = new Timeslot(DayOfWeek.TUESDAY, LocalTime.NOON);
ConstraintVerifier<TimetableConstraintProvider, Timetable> constraintVerifier = ConstraintVerifier.build(
new TimetableConstraintProvider(), Timetable.class, Lesson.class);
@Test
void roomConflict() {
Lesson firstLesson = new Lesson(1, "Subject1", "Teacher1", "Group1", TIMESLOT1, ROOM1);
Lesson conflictingLesson = new Lesson(2, "Subject2", "Teacher2", "Group2", TIMESLOT1, ROOM1);
Lesson nonConflictingLesson = new Lesson(3, "Subject3", "Teacher3", "Group3", TIMESLOT2, ROOM1);
constraintVerifier.verifyThat(TimetableConstraintProvider::roomConflict)
.given(firstLesson, conflictingLesson, nonConflictingLesson)
.penalizesBy(1);
}
}
Create the src/test/kotlin/org/acme/schooltimetabling/solver/TimetableConstraintProviderTest.kt
class:
package org.acme.schooltimetabling.solver
import ai.timefold.solver.core.api.score.stream.ConstraintFactory
import ai.timefold.solver.test.api.score.stream.ConstraintVerifier
import org.acme.schooltimetabling.domain.Lesson
import org.acme.schooltimetabling.domain.Room
import org.acme.schooltimetabling.domain.Timeslot
import org.acme.schooltimetabling.domain.Timetable
import org.junit.jupiter.api.Test
import java.time.DayOfWeek
import java.time.LocalTime
internal class TimetableConstraintProviderTest {
var constraintVerifier: ConstraintVerifier<TimetableConstraintProvider, Timetable> = ConstraintVerifier.build(
TimetableConstraintProvider(), Timetable::class.java, Lesson::class.java
)
@Test
fun roomConflict() {
val firstLesson = Lesson(1, "Subject1", "Teacher1", "Group1", TIMESLOT1, ROOM1)
val conflictingLesson = Lesson(2, "Subject2", "Teacher2", "Group2", TIMESLOT1, ROOM1)
val nonConflictingLesson = Lesson(3, "Subject3", "Teacher3", "Group3", TIMESLOT2, ROOM1)
constraintVerifier.verifyThat { obj: TimetableConstraintProvider, constraintFactory: ConstraintFactory? ->
obj.roomConflict(
constraintFactory
)
}
.given(firstLesson, conflictingLesson, nonConflictingLesson)
.penalizesBy(1)
}
companion object {
private val ROOM1 = Room("Room1")
private val TIMESLOT1 = Timeslot(DayOfWeek.MONDAY, LocalTime.NOON)
private val TIMESLOT2 = Timeslot(DayOfWeek.TUESDAY, LocalTime.NOON)
}
}
Create the tests/test_constraints.py
file with the following contents:
from timefold.solver.test import ConstraintVerifier
from datetime import time
from hello_world.domain import *
from hello_world.constraints import *
ROOM1 = Room("Room1")
ROOM2 = Room("Room2")
TIMESLOT1 = Timeslot("MONDAY", time(12, 0), time(13, 0))
TIMESLOT2 = Timeslot("TUESDAY", time(12, 0), time(13, 0))
TIMESLOT3 = Timeslot("TUESDAY", time(13, 0), time(14, 0))
TIMESLOT4 = Timeslot("TUESDAY", time(15, 0), time(16, 0))
constraint_verifier = ConstraintVerifier.build(define_constraints, Timetable, Lesson)
def test_room_conflict():
first_lesson = Lesson("1", "Subject1", "Teacher1", "Group1", TIMESLOT1, ROOM1)
conflicting_lesson = Lesson("2", "Subject2", "Teacher2", "Group2", TIMESLOT1, ROOM1)
non_conflicting_lesson = Lesson("3", "Subject3", "Teacher3", "Group3", TIMESLOT2, ROOM1)
(constraint_verifier.verify_that(room_conflict)
.given(first_lesson, conflicting_lesson, non_conflicting_lesson)
.penalizes_by(1))
This test verifies that the constraint TimetableConstraintProvider::roomConflict
penalizes with a match weight of 1
when given three lessons in the same room, where two lessons have the same timeslot.
Therefore, a constraint weight of 10hard
would reduce the score by -10hard
.
Notice how ConstraintVerifier
ignores the constraint weight during testing - even
if those constraint weights are hard coded in the ConstraintProvider
- because
constraints weights change regularly before going into production.
This way, constraint weight tweaking does not break the unit tests.
For more, see Testing Constraint Streams.
9.3. Logging
When adding constraints in your ConstraintProvider
,
keep an eye on the move evaluation speed in the info
log,
after solving for the same amount of time, to assess the performance impact:
... Solving ended: ..., move evaluation speed (29455/sec), ...
To understand how Timefold Solver is solving your problem internally:
-
Java
-
Kotlin
-
Python
Change the logging in the logback.xml
file:
<logger name="ai.timefold.solver" level="debug"/>
Change the logging in the logback.xml
file:
<logger name="ai.timefold.solver" level="debug"/>
Change the logging in the logging.conf
file:
[logger_timefold_solver]
level=DEBUG
qualname=timefold.solver
handlers=consoleHandler
propagate=0
[handler_consoleHandler]
class=StreamHandler
level=DEBUG
formatter=simpleFormatter
args=(sys.stdout,)
Use debug
logging to show every step:
... Solving started: time spent (67), best score (-20init/0hard/0soft), environment mode (REPRODUCIBLE), random (JDK with seed 0).
... CH step (0), time spent (128), score (-18init/0hard/0soft), selected move count (15), picked move ([Math(101) {null -> Room A}, Math(101) {null -> MONDAY 08:30}]).
... CH step (1), time spent (145), score (-16init/0hard/0soft), selected move count (15), picked move ([Physics(102) {null -> Room A}, Physics(102) {null -> MONDAY 09:30}]).
...
Use trace
logging to show every step and every move per step.
9.4. Make a standalone application
In order to run the application outside an IDE easily, you will need to make some changes to the configuration of your build tool.
-
Maven
-
Gradle
-
Python
In Maven, add the following to your pom.xml
:
...
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>${version.assembly.plugin}</version>
<configuration>
<finalName>hello-world-run</finalName>
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<descriptor>src/assembly/jar-with-dependencies-and-services.xml</descriptor>
</descriptors>
<archive>
<manifestEntries>
<Main-Class>org.acme.schooltimetabling.TimetableApp</Main-Class>
<Multi-Release>true</Multi-Release>
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
...
</plugins>
...
</build>
...
Also, create a new file in src/assembly
directory called jar-with-dependencies-and-services.xml
with the following contents:
<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.1.0 http://maven.apache.org/xsd/assembly-2.1.0.xsd">
<id>jar-with-dependencies-and-services</id>
<formats>
<format>jar</format>
</formats>
<containerDescriptorHandlers>
<containerDescriptorHandler>
<handlerName>metaInf-services</handlerName>
</containerDescriptorHandler>
</containerDescriptorHandlers>
<includeBaseDirectory>false</includeBaseDirectory>
<dependencySets>
<dependencySet>
<outputDirectory>/</outputDirectory>
<useProjectArtifact>true</useProjectArtifact>
<unpack>true</unpack>
<scope>runtime</scope>
</dependencySet>
</dependencySets>
</assembly>
This enables the Maven Assembly Plugin and tells it to do the following:
-
Take all dependencies of your project and put their classes and resources into a new JAR.
-
If any of the dependencies use Java SPI, it properly bundles all the service descriptors.
-
If any of the dependencies are multi-release JARs, it takes that into account.
-
-
Set that JAR’s main class to be
org.acme.schooltimetabling.TimetableApp
. -
Make that JAR available as
hello-world-run.jar
in your project’s build directory, most likelytarget/
.
This executable JAR can be run like any other JAR:
$ mvn clean install
...
$ java -jar target/hello-world-run.jar
In Gradle, add the following to your build.gradle
:
application {
mainClass = "org.acme.schooltimetabling.TimetableApp"
}
After building the project,
you can find an archive with a runnable application inside the build/libs/
directory.
$ gradle build
...
$ java -jar build/libs/hello-world-1.0-SNAPSHOT.jar
For Python, add the following to your pyproject.toml
:
[project.scripts]
run-app = "hello_world:main"
After installing the project,
run-app
will be available as an executable that can be run from the command line:
$ pip install .
...
$ run-app
10. Summary
Congratulations! You have just developed a Java, Python or Kotlin application with Timefold!
If you ran into any issues, take a look at the Java quickstart source code or the Python quickstart source code.
Read the next guide to build a pretty web application for school timetabling with a REST service and database integration, by leveraging Quarkus.