1. Overview
1.概述
1.1. What Is Timefold Solver?
1.1.什么是时间折叠求解器?
Timefold Solver is a pure Java planning solver AI. Timefold optimizes planning problems, such as the vehicle routing problem (VRP), maintenance scheduling, job shop scheduling, and school timetabling. It generates logistics plans that heavily reduce costs, improve service quality, and decrease the environmental footprint – often by as much as 25% – for complex, real-world scheduling operations.
Timefold Solver 是一款纯 Java 的人工智能规划求解器。Timefold 可优化规划问题,如车辆路由问题 (VRP)、维护调度、工作车间调度和学校时间表编制。它生成的物流计划可大幅降低成本、提高服务质量并减少对环境的影响(通常可减少 25%),适用于复杂的实际调度操作。
Timefold is the continuation of OptaPlanner. It’s a form of mathematical optimization (in the broader Operations Research and Artificial Intelligence spaces) that supports constraints written as code.
Timefold 是OptaPlanner的延续。它是数学优化的一种形式(在更广泛的运筹学和人工智能领域),支持以代码形式编写的约束条件。
1.2. What We Will Build
1.2.我们将建设什么
In this tutorial, let’s use Timefold Solver to optimize a simplified employee shift scheduling problem.
在本教程中,让我们使用时间折叠求解器来优化一个简化的员工班次安排问题。
We’ll assign shifts to employees automatically, such that:
我们将自动为员工分配班次,以便:
- No employee has two shifts on the same day
- Every shift is assigned to an employee who has the appropriate skill
Specifically, we will assign these five shifts:
具体来说,我们将分配这五个班次:
2030-04-01 06:00 - 14:00 (waiter)
2030-04-01 09:00 - 17:00 (bartender)
2030-04-01 14:00 - 22:00 (bartender)
2030-04-02 06:00 - 14:00 (waiter)
2030-04-02 14:00 - 22:00 (bartender)
To these three employees:
敬这三位员工
Ann (bartender)
Beth (waiter, bartender)
Carl (waiter)
This is harder than it looks. Give it a try on paper.
这比看上去要难。在纸上试试看。
2. Dependencies
2.依赖关系
The Timefold Solver artifacts on Maven Central are released under the Apache License. Let’s use them:
Maven Central 上的 Timefold Solver 构件是根据 Apache 许可发布的。让我们使用它们:
2.1. Plain Java
2.1.普通 Java
We add a dependency on timefold-solver-core and a test dependency on timefold-solver-test in Maven or Gradle:
我们在 Maven 或 Gradle 中添加对 timefold-solver-core 的依赖,并添加对 timefold-solver-test 的测试依赖:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>ai.timefold.solver</groupId>
<artifactId>timefold-solver-bom</artifactId>
<version>...</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>ai.timefold.solver</groupId>
<artifactId>timefold-solver-core</artifactId>
</dependency>
<dependency>
<groupId>ai.timefold.solver</groupId>
<artifactId>timefold-solver-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
2.2. Spring Boot
2.2 Spring Boot
In Spring Boot, we use the timefold-solver-spring-boot-starter dependency instead. It handles most of the solver configuration automatically, as we’ll see later, and allows configuring solver time and other properties in application.properties.
在 Spring Boot 中,我们使用 timefold-solver-spring-boot-starter 依赖关系来代替。它将自动处理大部分求解器配置(稍后我们将看到),并允许在 application.properties 中配置求解器时间和其他属性。
- Go to start.spring.io
- Click Add dependencies to add the Timefold Solver dependency
- Generate a project and open it in your favorite IDE
2.3. Quarkus
2.3 夸库
In Quarkus, similarly, we use the timefold-solver-quarkus dependency in code.quarkus.io for automatic solver configuration and application.properties support.
同样,在 Quarkus 中,我们使用 timefold-solver-quarkus 中的 code.quarkus.io 依赖项来实现自动求解器配置和 application.properties 支持。
3. Domain Classes
3.领域类别
The domain classes represent both the input data and output data. We create Employee and Shift classes, as well as a ShiftSchedule that contains the list of employees and shifts for a particular dataset.
域类代表输入数据和输出数据。我们创建了 Employee 和 Shift 类,以及包含特定数据集的员工和班次列表的 ShiftSchedule 类。
3.1. Employee
3.1.雇员</em
An employee is a person we can assign to shifts. Each employee has a name and one or more skills.
员工是我们可以分配轮班的人。每个员工都有一个名字和一项或多项技能。
The Employee class doesn’t need any Timefold annotation because it does not change during solving:
Employee 类不需要任何 Timefold 注解,因为它在求解过程中不会发生变化:
public class Employee {
private String name;
private Set<String> skills;
public Employee(String name, Set<String> skills) {
this.name = name;
this.skills = skills;
}
@Override
public String toString() {
return name;
}
// Getters and setters
}
3.2. Shift
3.2 轮班
A shift is a job assignment for exactly one employee on a specific date from a start time to an end time. There can be two shifts at the same time. Each shift has one required skill.
轮班是指在某一特定日期,从开始时间到结束时间正好为一名员工分配的工作。同一时间可以有两个班次。每个班次都有一项必备技能。
Shift objects change during solving: Each shift is assigned to an employee. Timefold needs to know that. Only the employee field changes during solving. Therefore, we annotate the class with @PlanningEntity and the employee field with @PlanningVariable so Timefold knows it should fill in the employee for each shift:
班次对象在求解过程中会发生变化:每个班次都分配给一名员工。Timefold 需要知道这一点。在求解过程中,只有 employee 字段会发生变化。因此,我们用 @PlanningEntity 对该类进行注解,并用 @PlanningVariable 对 employee 字段进行注解,这样 Timefold 就知道它应该为每个班次填写雇员:
@PlanningEntity
public class Shift {
private LocalDateTime start;
private LocalDateTime end;
private String requiredSkill;
@PlanningVariable
private Employee employee;
// A no-arg constructor is required for @PlanningEntity annotated classes
public Shift() {
}
public Shift(LocalDateTime start, LocalDateTime end, String requiredSkill) {
this(start, end, requiredSkill, null);
}
public Shift(LocalDateTime start, LocalDateTime end, String requiredSkill, Employee employee) {
this.start = start;
this.end = end;
this.requiredSkill = requiredSkill;
this.employee = employee;
}
@Override
public String toString() {
return start + " - " + end;
}
// Getters and setters
}
3.3. ShiftSchedule
3.3.轮班计划</em
A schedule represents a single dataset of employees and shifts. It is both the input and output for Timefold:
排班表代表员工和班次的单一数据集。它既是 Timefold 的输入,也是输出:
- We annotate the ShiftSchedule class with @PlanningSolution so Timefold knows it represents the input and output.
- We annotate the employees field with @ValueRangeProvider to tell Timefold it contains the list of employees from which it can pick instances to assign to Shift.employee.
- We annotate the shifts field with @PlanningEntityCollectionProperty so Timefold finds all Shift instances to assign to an employee.
- We include a score field with a @PlanningScore annotation. Timefold will fill this in for us. Let’s use a HardSoftScore so we can differentiate between hard and soft constraints later.
Now, let’s have a look at our class:
现在,让我们来看看我们的班级:
@PlanningSolution
public class ShiftSchedule {
@ValueRangeProvider
private List<Employee> employees;
@PlanningEntityCollectionProperty
private List<Shift> shifts;
@PlanningScore
private HardSoftScore score;
// A no-arg constructor is required for @PlanningSolution annotated classes
public ShiftSchedule() {
}
public ShiftSchedule(List<Employee> employees, List<Shift> shifts) {
this.employees = employees;
this.shifts = shifts;
}
// Getters and setters
}
4. Constraints
4.制约因素
Without constraints, Timefold would assign all shifts to the first employee. That’s not a feasible schedule.
如果没有限制条件,Timefold 会将所有班次分配给第一位员工。这不是一个可行的时间表。
To teach it how to distinguish good and bad schedules, let’s add two hard constraints:
为了教会它如何区分好的和坏的时间表,让我们添加 两个硬约束:
- The atMostOneShiftPerDay() constraint checks if two shifts on the same date are assigned to the same employee. If that’s the case, it penalizes the score by 1 hard point.
- The requiredSkill() constraint checks if a shift is assigned to an employee for which the shift’s required skill is part of the employee’s skill set. If it’s not, it penalizes the score by 1 hard point.
A single hard constraint takes priority over all soft constraints. Typically, hard constraints are impossible to break, either physically or legally. Soft constraints, on the other hand, can be broken, but we want to minimize that. Those typically represent financial costs, service quality, or employee happiness. Hard and soft constraints are implemented with the same API.
单个硬约束优先于所有软约束。通常情况下,硬约束是不可能被打破的,无论是物理上还是法律上。另一方面,软约束可以被打破,但我们希望将其最小化。软约束通常代表财务成本、服务质量或员工幸福感。硬约束和软约束使用相同的应用程序接口来实现。
4.1. ConstraintProvider
4.1.约束提供程序</em
First, we create a ConstraintProvider for our constraint implementations:
首先,我们为约束实现创建一个 ConstraintProvider :
public class ShiftScheduleConstraintProvider implements ConstraintProvider {
@Override
public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
return new Constraint[] {
atMostOneShiftPerDay(constraintFactory),
requiredSkill(constraintFactory)
};
}
// Constraint implementations
}
4.2. Unit Test the ConstraintProvider
4.2.对 ConstraintProvider 进行单元测试
If it isn’t tested, it doesn’t work — especially for constraints. Let’s create a test class to test each constraint of our ConstraintProvider.
如果不进行测试,就无法运行,尤其是对于约束条件。让我们创建一个测试类来测试 ConstraintProvider 的每个约束。
The test-scoped timefold-solver-test dependency contains ConstraintVerifier, a helper to test each constraint in isolation. This improves maintenance — we can refactor a single constraint without breaking tests of other constraints:
测试作用域的 timefold-solver-test 依赖关系包含 ConstraintVerifier,这是一个用于隔离测试每个约束的辅助工具。这改进了维护工作–我们可以重构单个约束,而不会破坏其他约束的测试:
public class ShiftScheduleConstraintProviderTest {
private static final LocalDate MONDAY = LocalDate.of(2030, 4, 1);
private static final LocalDate TUESDAY = LocalDate.of(2030, 4, 2);
ConstraintVerifier<ShiftScheduleConstraintProvider, ShiftSchedule> constraintVerifier
= ConstraintVerifier.build(new ShiftScheduleConstraintProvider(), ShiftSchedule.class, Shift.class);
// Tests for each constraint
}
We’ve also prepared two dates to reuse in our tests below. Let’s add the actual constraints next.
我们还准备了两个日期,以便在下面的测试中重复使用。接下来让我们添加实际的约束条件。
4.3. Hard Constraint: at Most One Shift per Day
4.3.硬约束:每天最多一个班次
Following TDD (Test Driven Design), let’s write the tests for our new constraint in our test class first:
按照 TDD(测试驱动设计)原则,让我们先在测试类中编写新约束条件的测试:
@Test
void whenTwoShiftsOnOneDay_thenPenalize() {
Employee ann = new Employee("Ann", null);
constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::atMostOneShiftPerDay)
.given(
new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), null, ann),
new Shift(MONDAY.atTime(14, 0), MONDAY.atTime(22, 0), null, ann))
// Penalizes by 2 because both {shiftA, shiftB} and {shiftB, shiftA} match.
// To avoid that, use forEachUniquePair() in the constraint instead of forEach().join() in the implementation.
.penalizesBy(2);
}
@Test
void whenTwoShiftsOnDifferentDays_thenDoNotPenalize() {
Employee ann = new Employee("Ann", null);
constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::atMostOneShiftPerDay)
.given(
new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), null, ann),
new Shift(TUESDAY.atTime(14, 0), TUESDAY.atTime(22, 0), null, ann))
.penalizesBy(0);
}
Then, we implement it in our ConstraintProvider:
然后,我们在 ConstraintProvider 中实现它:
public Constraint atMostOneShiftPerDay(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.join(Shift.class,
equal(shift -> shift.getStart().toLocalDate()),
equal(Shift::getEmployee))
.filter((shift1, shift2) -> shift1 != shift2)
.penalize(HardSoftScore.ONE_HARD)
.asConstraint("At most one shift per day");
}
To implement constraints, we use the ConstraintStreams API: a Stream/SQL-like API that provides incremental score calculation (deltas) and indexed hashtable lookups under the hood. This approach scales to datasets with hundreds of thousands of shifts in a single schedule.
为了实现约束,我们使用了 ConstraintStreams API:这是一个类似于 Stream/SQL 的 API,在引擎盖下提供增量分数计算(deltas)和索引 hashtable 查找。这种方法可扩展至单个日程表中包含数十万个班次的数据集。
Let’s run the tests and verify they are green.
让我们运行测试并验证它们是绿色的。
4.4. Hard Constraint: Required Skill
4.4.硬约束:所需技能
Let’s write the tests in our test class:
让我们在测试类中编写测试:
@Test
void whenEmployeeLacksRequiredSkill_thenPenalize() {
Employee ann = new Employee("Ann", Set.of("Waiter"));
constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::requiredSkill)
.given(
new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), "Cook", ann))
.penalizesBy(1);
}
@Test
void whenEmployeeHasRequiredSkill_thenDoNotPenalize() {
Employee ann = new Employee("Ann", Set.of("Waiter"));
constraintVerifier.verifyThat(ShiftScheduleConstraintProvider::requiredSkill)
.given(
new Shift(MONDAY.atTime(6, 0), MONDAY.atTime(14, 0), "Waiter", ann))
.penalizesBy(0);
}
Then, let’s implement the new constraint in our ConstraintProvider:
然后,让我们在 ConstraintProvider 中实现新的约束:
public Constraint requiredSkill(ConstraintFactory constraintFactory) {
return constraintFactory.forEach(Shift.class)
.filter(shift -> !shift.getEmployee().getSkills()
.contains(shift.getRequiredSkill()))
.penalize(HardSoftScore.ONE_HARD)
.asConstraint("Required skill");
}
Let’s run the tests again. They are still green.
让我们再次进行测试。它们仍然是绿色的。
To make this a soft constraint, we would change penalize(HardSoftScore.ONE_HARD) into penalize(HardSoftScore.ONE_SOFT). To turn that into a dynamic decision by the input dataset, we could use penalizeConfigurable() and @ConstraintWeight instead.
要使其成为软约束,我们可以将 penalize(HardSoftScore.ONE_HARD) 更改为 penalize(HardSoftScore.ONE_SOFT) 。要将其转化为输入数据集的动态决定,我们可以使用 penalizeConfigurable() 和 @ConstraintWeight 来代替。
5. Application
5.应用
We’re ready to put our application together.
我们已经准备好提交申请了。
5.1. Solve It
5.1 解决它
To solve a schedule, we create a SolverFactory from our @PlanningSolution, @PlanningEntity, and ConstraintProvider classes. A SolverFactory is a long-lived object. Typically, there’s only one instance per application.
要解决计划问题,我们要从 @PlanningSolution、@PlanningEntity 和 ConstraintProvider 类中创建一个 SolverFactory 。SolverFactory 是一个长期存在的对象。通常,每个应用程序只有一个实例。
We also need to configure how long we want a solver to run. For large datasets, with thousands of shifts and far more constraints, it’s impossible to find the optimal solution in a reasonable timeframe (due to the exponential nature of NP-hard problems). Instead, we want to find the best possible solution in the amount of time available. Let’s limit that to two seconds for now:
我们还需要设置求解器的运行时间。对于具有成千上万移位和更多约束条件的大型数据集,我们不可能在合理的时间范围内找到最优解(由于 NP-hard 问题的指数性质)。相反,我们希望在可用时间内找到最佳解决方案。我们暂且将时间限制为两秒钟:
SolverFactory<ShiftSchedule> solverFactory = SolverFactory.create(new SolverConfig()
.withSolutionClass(ShiftSchedule.class)
.withEntityClasses(Shift.class)
.withConstraintProviderClass(ShiftScheduleConstraintProvider.class)
// The solver runs only for 2 seconds on this tiny dataset.
// It's recommended to run for at least 5 minutes ("5m") on large datasets.
.withTerminationSpentLimit(Duration.ofSeconds(2)));
We use the SolverFactory to create a Solver instance, one per dataset. Then, we call Solver.solve() to solve a dataset:
我们使用 SolverFactory 创建一个 Solver 实例,每个数据集一个。然后,我们调用 Solver.solve() 来求解数据集:
Solver<ShiftSchedule> solver = solverFactory.buildSolver();
ShiftSchedule problem = loadProblem();
ShiftSchedule solution = solver.solve(problem);
printSolution(solution);
In Spring Boot, the SolverFactory is built automatically and injected into an @Autowired field:
在 Spring Boot 中,SolverFactory 会自动构建并注入到 @Autowired 字段中:
@Autowired
SolverFactory<ShiftSchedule> solverFactory;
And we configure the solver time in application.properties:
我们在 application.properties 中配置求解器时间:
timefold.solver.termination.spent-limit=5s
In Quarkus, similarly, the SolverFactory is also built automatically and injected in an @Inject field. The solver time is also configured in application.properties.
同样,在 Quarkus 中,SolverFactory 也会自动构建并注入到 @Inject 字段中。求解器时间也在 application.properties 中配置。
To solve asynchronously, to avoid hogging the current thread when calling Solver.solve(), we would inject and use a SolverManager instead.
为了异步求解,避免在调用 Solver.solve() 时占用当前线程,我们将注入并使用 SolverManager 来代替。
5.2. Test Data
5.2 测试数据
Let’s generate a tiny dataset of five shifts and three employees as the input problem:
让我们生成一个包含五个班次和三名员工的小数据集作为输入问题:
private ShiftSchedule loadProblem() {
LocalDate monday = LocalDate.of(2030, 4, 1);
LocalDate tuesday = LocalDate.of(2030, 4, 2);
return new ShiftSchedule(List.of(
new Employee("Ann", Set.of("Bartender")),
new Employee("Beth", Set.of("Waiter", "Bartender")),
new Employee("Carl", Set.of("Waiter"))
), List.of(
new Shift(monday.atTime(6, 0), monday.atTime(14, 0), "Waiter"),
new Shift(monday.atTime(9, 0), monday.atTime(17, 0), "Bartender"),
new Shift(monday.atTime(14, 0), monday.atTime(22, 0), "Bartender"),
new Shift(tuesday.atTime(6, 0), tuesday.atTime(14, 0), "Waiter"),
new Shift(tuesday.atTime(14, 0), tuesday.atTime(22, 0), "Bartender")
));
}
5.3. Result
5.3 结果
After we run the test data through our solver, we’ll print the output solution to System.out:
通过求解器运行测试数据后,我们将把输出解决方案打印到 System.out 中:
private void printSolution(ShiftSchedule solution) {
logger.info("Shift assignments");
for (Shift shift : solution.getShifts()) {
logger.info(" " + shift.getStart().toLocalDate()
+ " " + shift.getStart().toLocalTime()
+ " - " + shift.getEnd().toLocalTime()
+ ": " + shift.getEmployee().getName());
}
}
Here’s the result for our dataset:
下面是我们数据集的结果:
Shift assignments
2030-04-01 06:00 - 14:00: Carl
2030-04-01 09:00 - 17:00: Ann
2030-04-01 14:00 - 22:00: Beth
2030-04-02 06:00 - 14:00: Beth
2030-04-02 14:00 - 22:00: Ann
Ann wasn’t assigned to the first shift because she didn’t have the waiter skill. But why wasn’t Beth assigned to the first shift? She has the waiter skill.
安没有被分配到第一班,因为她没有服务员技能。但是为什么贝丝没有被分配到第一班?她有服务员技能。
If Beth had been assigned to the first shift, it would then be impossible to assign both the second and third shifts. Those both need a bartender, so Carl can’t do them. Only when Carl is assigned to the first shift is a feasible solution possible. In large, real-world datasets, these kinds of intricacies become a lot more complex. Let the Solver worry about them.
如果贝丝被分配到第一班,那么就不可能同时分配到第二班和第三班。这两个班次都需要一名酒保,所以卡尔无法胜任。只有当卡尔被分配到第一班时,才有可能找到可行的解决方案。在现实世界的大型数据集中,这类错综复杂的问题会变得更加复杂。让求解器来解决这些问题吧。
6. Conclusion
6.结论
The Timefold Solver framework provides developers with a powerful tool to solve constraint satisfaction problems such as scheduling and resource allocation. It supports writing custom constraints in code (instead of mathematical equations), which makes it maintenance-friendly. Under the hood, it supports various Artificial Intelligence optimization algorithms that can be power-tweaked, but a typical user doesn’t need to do so.
Timefold Solver 框架为开发人员提供了解决调度和资源分配等约束满足问题的强大工具。它支持在代码中编写自定义约束(而不是数学公式),这使得它易于维护。在引擎盖下,它支持各种人工智能优化算法,可以进行功能调整,但一般用户并不需要这样做。
For more information, see the Timefold Solver documentation. As always, the source code for this tutorial is over on GitHub.
有关详细信息,请参阅 时间折叠求解器文档。与往常一样,本教程的源代码在 GitHub 上。