Quickstart 快速开始
总览
每个快速开始的样例都能帮助你快速熟悉OptaPlanner。选择一个最贴近你需求的案例:
-
Hello World Java
- 使用OP构建一个简单的Java 应用,来优化你们学校老师和学生的时间表。
-
Quarkus Java(推荐)
- 使用OP构建一个REST风格的应用,来优化你们学校老师和学生的时间表。
- Quarkus 是一个Java生态中的快速开发平台。其初衷就是快速开发并部署到云平台。其同时支持本地编译。由于编译时间的优化,可以为OP提供良好的性能提升。
-
SpringBoot Java (译者推荐)
- 使用OP构建一个REST风格的应用,来优化你们学校老师和学生的时间表。
- SpringBoot是Java生态中的另外一个平台。
以上所有的三个Quickstart样例都是使用OP去优化学校的时间表。
Hello world Java Quick start
以下操作指南将引导你使用OP构建一个简单的Java 应用。
1.你将构建什么样的一个应用
你将构建一个命令行模式的应用,用来优化学校老师和学生的时间表。
...
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 |------------|------------|------------|------------|
你的应用需要自动将”课程“实例分配到”时间槽“和”教室“实例,同时遵守一系列的软约束和硬约束,例如:
- 一个教室在同一时间只能有一种课程
- 一个老师在同一时间只能教授一种课程
- 一个学生只能在同一时间上一种课程
- 老师倾向于在同一个教室教授所有课程
- 老师倾向于连续授课,而不喜欢课程中有间隔
- 学生不喜欢同一个主题下的连续授课
从数学的角度讲,学校排时间表是一个NP-hard问题。这意味着它很难进行扩展。对于一个复杂的问题,如果只是简单采用遍历所有可能组合的方法,即使是使用超级计算机也可能需要花费数百万年。幸运的是一个AI约束求解器,例如OP,拥有先进的算法可以在可接受的时间范围内给出一个近最优方案。
2.解决方案的源码
你可以跟着接下来章节的指引,一步步的创建应用。或者直接查看完成版的例子:
1.完成以下任务之一:
a.Clone Git上的仓库:
````
$ git clone https://github.com/kiegroup/optaplanner-quickstarts
````
b.下载完整文档:www.optaplanner.org/download/do…
2.在hello-world目录下找到对应的方案
3.根据README文档的指导运行应用
3.前提依赖
完成本样例需要以下工具:
- JDK 11+ 并且配置好对应的环境变量
- Apache Maven 3.8.1+ 或者 Gradle4 +
- 一个IDE,例如IntelliJ IDE,VSCode 或者 Eclipse
4.build文件和依赖
创建一个Maven或者Gradle的build文件,并添加以下依赖:
- optaplanner-core(编译域)用于解决学校时间表问题。
- optaplanner-test(测试域)用于对学校排期约束进行单元测试。
- 一个logging日志实现,例如logback-classic(运行域)用于观察OP执行的动作。
如果你选择了Maven,你的pom.xml应包含如下内容:
<?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>optaplanner-hello-world-school-timetabling-quickstart</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>11</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.optaplanner</groupId>
<artifactId>optaplanner-bom</artifactId>
<version>8.31.0.Final</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.optaplanner</groupId>
<artifactId>optaplanner-core</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.optaplanner</groupId>
<artifactId>optaplanner-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.0.0</version>
<configuration>
<mainClass>org.acme.schooltimetabling.TimeTableApp</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
另一方面,如果你用的是Gradle,你的build.gradle文件应包含如下内容:
plugins {
id "java"
id "application"
}
def optaplannerVersion = "8.31.0.Final"
def logbackVersion = "1.2.9"
group = "org.acme"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
implementation platform("org.optaplanner:optaplanner-bom:${optaplannerVersion}")
implementation "org.optaplanner:optaplanner-core"
testImplementation "org.optaplanner:optaplanner-test"
runtimeOnly "ch.qos.logback:logback-classic:${logbackVersion}"
}
java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
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"
}
}
5.域对象建模
你的目标是将每个课程分配到对应的时间区间和教室。你需要创建以下类:
5.1Timeslot时间槽/时隙
时间槽Timeslot类代表用来上课的一个时间区间,例如:周一的10:30 - 11:30 或者 周二的 13:30 - 14:30。为了简单起见,所有的时间槽都具有相同的时长,并且不会出现在午餐或者其他休息时间。
时间槽本身没有日期,因为高中的时间表每周都是重复的。所以没必要进行连续规划。
创建 src/main/java/org/acme/schooltimetabling/domain/Timeslot.java 类:
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;
}
}
因为Timeslot在求解期间不会发生改变,所以一个Timeslot被称之为一个问题事实 Problem Fact。这样的类不需要OP特定注解。
注意toString()方法的输出比较短,这样方便阅读调试日志或者跟踪日志。
5.2Room 教室
Room类代表课程被教授的地方,例如A教室或B教室。为了简单起见,所有的教室都没有容量上限,并且可以安排所有的课程教学。
创建类 src/main/java/org/acme/schooltimetabling/domain/Room.java :
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;
}
}
Room实例在求解期间不会改变,所以他也是一个问题事实Problem Fact。
5.3 Lesson 课程
在授课期间,老师会向一组学生教授一个特定主题内容,例如:9年级的数学由图灵A老师,10年级的化学由B居里老师教授。如果同一个主题内容被同一个老师在每周都教授给同一组学生,系统中则会存在多个Lesson的实例,这些实例根据id进行区分。例如9年级每周有6次数学课程。
在求解的过程中,OP通过改变Lesson类中的 timeslot和room属性的方式将课程分配到一个时间槽和教室。OP会不断的改变Lesson的属性,所以这个Lesson就是一个规划实体Planning Entity:
上面图中的大多数属性都包含输入数据,除过橘色部分:一个课程的 timeslot 和 room 属性在输入数据中是未赋值的,在数出数据中则是已赋值。OP是在求解的过程中改变的这两个属性。这些属性被称之为规划变量Planning variables。为了让OP识别到这些规划变量,需要在这些属性上添加@PlanningVariable 注解。他们的容器类Lesson需要添加一个@PlanningEngity 注解。
创建 src/main/java/org/acme/schooltimetabling/domain/Lesson.java 类:
package org.acme.schooltimetabling.domain;
import org.optaplanner.core.api.domain.entity.PlanningEntity;
import org.optaplanner.core.api.domain.lookup.PlanningId;
import org.optaplanner.core.api.domain.variable.PlanningVariable;
@PlanningEntity
public class Lesson {
@PlanningId
private Long id;
private String subject;
private String teacher;
private String studentGroup;
@PlanningVariable(valueRangeProviderRefs = "timeslotRange")
private Timeslot timeslot;
@PlanningVariable(valueRangeProviderRefs = "roomRange")
private Room room;
public Lesson() {
}
public Lesson(Long id, String subject, String teacher, String studentGroup) {
this.id = id;
this.subject = subject;
this.teacher = teacher;
this.studentGroup = studentGroup;
}
public Long 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 + ")";
}
}
Lesson类有@PlanningEntity 注解,这样一来OP就知道这个类会在求解过程中发生变化,因为它包含了一到多个规划变量。
timeslot属性有@PlanningVariable注解,OP就知道这个属性的值会发生变化。为了找到这个属性关联的潜在timeslot实例,OP使用valueRangeProviderRefs属性关联到一个值域产生器value range provider,这个产生器会提供一个List 列表供timeslot从中选择。
room属性基于同样的原因,也有一个@PlanningVariable注解。
第一次指定随意的一个约束求解用例中的@PlanningVariable属性是具有挑战性的。请阅读 对象域建模指导 www.optaplanner.org/docs/optapl…以避免常见的问题/陷阱。
6.确定约束,并计算分值
分值代表了一个规划方案的质量,分值越高质量越好。OP寻求的是分值最高的方案,这个方案有可能是最优方案。
因为当前的问题同时存在硬约束和软约束,所以使用HardSoftScore类来代表分值:
- 硬约束必须不能被打破:例如一个教室同一时间只能正在教授一种课程。
- 软约束尽可能的不被打破:同一个老师倾向于在同一个教室授课。
硬约束之间可以进行加权,软约束之间同样可以进行加权;硬约束总是大于软约束,而忽略其各自的权重。
为了计算分值,你可以实现一个EasyScoreCalculator类:
public class TimeTableEasyScoreCalculator implements EasyScoreCalculator<TimeTable, HardSoftScore> {
@Override
public HardSoftScore calculateScore(TimeTable timeTable) {
List<Lesson> lessonList = timeTable.getLessonList();
int hardScore = 0;
for (Lesson a : lessonList) {
for (Lesson b : lessonList) {
if (a.getTimeslot() != null && a.getTimeslot().equals(b.getTimeslot())
&& a.getId() < b.getId()) {
//一个教室统一时刻只能正在教授一种课程
if (a.getRoom() != null && a.getRoom().equals(b.getRoom())) {
hardScore--;
}
//一个老师同一时刻只能正在教授一种课程
if (a.getTeacher().equals(b.getTeacher())) {
hardScore--;
}
//一个学生同一时刻只能正在学习一种课程
if (a.getStudentGroup().equals(b.getStudentGroup())) {
hardScore--;
}
}
}
}
int softScore = 0;
// Soft constraints are only implemented in the optaplanner-quickstarts code
return HardSoftScore.of(hardScore, softScore);
}
}
很不幸的是以上的代码扩展性不好,这个是非增量式的:每次一个课程被分配到一个时间槽和教室的时候,所有课程的分值都需要重新评估。
改进方法,创建一个 src/main/java/org/acme/schooltimetabling/solver/TimeTableConstraintProvider.java 类来实现增量计算。这就需要使用OP提供的基于Java Stream 和SQL 的ConstraintStream API:
package org.acme.schooltimetabling.solver;
import org.acme.schooltimetabling.domain.Lesson;
import org.optaplanner.core.api.score.buildin.hardsoft.HardSoftScore;
import org.optaplanner.core.api.score.stream.Constraint;
import org.optaplanner.core.api.score.stream.ConstraintFactory;
import org.optaplanner.core.api.score.stream.ConstraintProvider;
import org.optaplanner.core.api.score.stream.Joiners;
public class TimeTableConstraintProvider implements ConstraintProvider {
@Override
public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
return new Constraint[] {
// 硬约束
roomConflict(constraintFactory),
teacherConflict(constraintFactory),
studentGroupConflict(constraintFactory),
// Soft constraints are only implemented in the optaplanner-quickstarts code
};
}
private Constraint roomConflict(ConstraintFactory constraintFactory) {
// 一个教室同一时刻只能安排一种课程
// 选择一个课程....
return constraintFactory
.forEach(Lesson.class)
// ... and pair it with another lesson ...
.join(Lesson.class,
// ... 同一个时间槽 ...
Joiners.equal(Lesson::getTimeslot),
// ... 同一个教室 ...
Joiners.equal(Lesson::getRoom),
// ... and the pair is unique (different id, no reverse pairs) ...
Joiners.lessThan(Lesson::getId))
// ... then penalize each pair with a hard weight.
.penalize(HardSoftScore.ONE_HARD)
.asConstraint("Room conflict");
}
private Constraint teacherConflict(ConstraintFactory constraintFactory) {
// 一个老师同一时刻只能教授一种课程
return constraintFactory.forEach(Lesson.class)
.join(Lesson.class,
Joiners.equal(Lesson::getTimeslot),
Joiners.equal(Lesson::getTeacher),
Joiners.lessThan(Lesson::getId))
.penalize(HardSoftScore.ONE_HARD)
.asConstraint("Teacher conflict");
}
private Constraint studentGroupConflict(ConstraintFactory constraintFactory) {
// 一组学生同一时刻只能学习一种课程
return constraintFactory.forEach(Lesson.class)
.join(Lesson.class,
Joiners.equal(Lesson::getTimeslot),
Joiners.equal(Lesson::getStudentGroup),
Joiners.lessThan(Lesson::getId))
.penalize(HardSoftScore.ONE_HARD)
.asConstraint("Student group conflict");
}
}
新的ConstraintProvider在扩展性方面的复杂度是O(n) 优于EasyScoreCalculator的 O(n²)。
7.获取规划解决方案中的域对象
Timetable将所有的Timeslot、Room、Lesson实例都打包到了同一个数据集中。进一步的,它包含了所有的课程并且每个课程都具有不同的规划变量状态,所以它就代表了一个规划问题的解决方案,并且我们对他有一个评分:
- 如果有课程仍旧未被分配,则当前的方案是一个未初始化的方案,例如:一个得分情况为-4init/0 hard/0 soft的方案。
- 如果方案打破了硬约束,则是一个不可行的方案,例如:一个得分为-2 hard/-3 soft的方案。
- 如果方案遵守了硬约束,则是一个可行方案,例如:一个得分为 0 hard/-7 soft的方案。
创建 src/main/java/org/acme/schooltimetabling/domain/TimeTable.java 类:
package org.acme.schooltimetabling.domain;
import java.util.List;
import org.optaplanner.core.api.domain.solution.PlanningEntityCollectionProperty;
import org.optaplanner.core.api.domain.solution.PlanningScore;
import org.optaplanner.core.api.domain.solution.PlanningSolution;
import org.optaplanner.core.api.domain.solution.ProblemFactCollectionProperty;
import org.optaplanner.core.api.domain.valuerange.ValueRangeProvider;
import org.optaplanner.core.api.score.buildin.hardsoft.HardSoftScore;
@PlanningSolution
public class TimeTable {
@ValueRangeProvider(id = "timeslotRange")
@ProblemFactCollectionProperty
private List<Timeslot> timeslotList;
@ValueRangeProvider(id = "roomRange")
@ProblemFactCollectionProperty
private List<Room> roomList;
@PlanningEntityCollectionProperty
private List<Lesson> lessonList;
@PlanningScore
private HardSoftScore score;
public TimeTable() {
}
public TimeTable(List<Timeslot> timeslotList, List<Room> roomList, List<Lesson> lessonList) {
this.timeslotList = timeslotList;
this.roomList = roomList;
this.lessonList = lessonList;
}
public List<Timeslot> getTimeslotList() {
return timeslotList;
}
public List<Room> getRoomList() {
return roomList;
}
public List<Lesson> getLessonList() {
return lessonList;
}
public HardSoftScore getScore() {
return score;
}
}
类Timetable上有@PlanningSolution注解,所以OP就知道当前类包含所有的输入及输出数据。
当前类是规划问题的输入数据:
-
一个timeslotList 属性,包含所有的时间槽。
- 这是一个问题事实,在求解期间值不改变。
-
一个roomList 属性,包含所有教室。
- 这是一个问题事实,在求解期间值不改变。
-
一个lessonList属性,包含所有课程。
-
这是一个问题实体,在求解期间会发生变化。
-
对于每个课程:
- timeslot属性和room属性通常都是为null,也就是未赋值,它们是规划变量
- 其他属性例如 teacher、studentGroup已经赋值,它们是问题属性。
-
当前的Timeslot类也是解决方案的输出数据:
- 一个lessonList 属性,在求解之后其中的每个lesson实例的timeslot和room字段都已非空。
- 一个score属性,用来代表当前输出解决方案的质量,例如 0 hard/-5 soft。
7.1 值域提供器 value range providers
timeslotList是一个值域提供器,持有所有的timeslot实例。OP可以在设置Lesson实例中的timeslot属性时从中选择。
timeslotList属性上有@ValueRangeProvider注解,通过这个注解中的id值和Lesson类中的@PlanningVariable注解中的valueRangeProviderRefs 值进行对应,就可以实现@PlanningVarialble和@ValueRangeProvider的关联。
按照同样的逻辑,roomList属性上也有@ValueRangeProvider注解。
7.2问题事实和问题实体属性
更进一步,OP需要通过你提供的TimeTableConstraintProvider来确定当前它可以对哪个Lesson实例进行修改,以及如何检索分值计算时用到的timeslot和room实例。
timeslotList和roomList属性上有@ProblemFactCollectionProperty注解,所以TimeTableConstraintProvider可以从这些list实例中获取需要的timeslot和room实例。
lessonList属性上有@PlanningEntityCollectionProperty,所以OP知道应该修改什么,以及TimeTableConstraintProvider可以从这个list中获取要修改的实例。
8.创建应用
现在我们已经准备好了,可以将以上的所有内容组合起来创建一个Java应用。main()方法将执行以下任务:
1.创建一个SolverFactory,来为每个要处理的数据集生成对应的Solver。
2.加载数据集
3.调用Solver.solve()方法进行问题求解。
4.将得到的解决方案进行可视化展示。
通常,一个应用会建立一个SolverFactory来为每个规划求解的数据集生成对应的Solver实例。SolverFactory是线程安全的,但Solver不是。在当前场景下我们只有一个规划问题数据集,所以只有一个Solver实例。
创建 src/main/java/org/acme/schooltimetabling/TimeTableApp.java 类:
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.stream.Collectors;
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.acme.schooltimetabling.solver.TimeTableConstraintProvider;
import org.optaplanner.core.api.solver.Solver;
import org.optaplanner.core.api.solver.SolverFactory;
import org.optaplanner.core.config.solver.SolverConfig;
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)
// 当前数据集比较小,Solver只运行了5秒
// 建议至少运行5分钟 5m
.withTerminationSpentLimit(Duration.ofSeconds(10)));
// 加载问题
TimeTable problem = generateDemoData();
// 问题求解
Solver<TimeTable> solver = solverFactory.buildSolver();
TimeTable solution = solver.solve(problem);
// 结果方案的可视化
printTimetable(solution);
}
public static TimeTable generateDemoData() {
List<Timeslot> timeslotList = new ArrayList<>(10);
timeslotList.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)));
timeslotList.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 30), LocalTime.of(10, 30)));
timeslotList.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(10, 30), LocalTime.of(11, 30)));
timeslotList.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(13, 30), LocalTime.of(14, 30)));
timeslotList.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(14, 30), LocalTime.of(15, 30)));
timeslotList.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)));
timeslotList.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(9, 30), LocalTime.of(10, 30)));
timeslotList.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(10, 30), LocalTime.of(11, 30)));
timeslotList.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(13, 30), LocalTime.of(14, 30)));
timeslotList.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(14, 30), LocalTime.of(15, 30)));
List<Room> roomList = new ArrayList<>(3);
roomList.add(new Room("Room A"));
roomList.add(new Room("Room B"));
roomList.add(new Room("Room C"));
List<Lesson> lessonList = new ArrayList<>();
long id = 0;
lessonList.add(new Lesson(id++, "Math", "A. Turing", "9th grade"));
lessonList.add(new Lesson(id++, "Math", "A. Turing", "9th grade"));
lessonList.add(new Lesson(id++, "Physics", "M. Curie", "9th grade"));
lessonList.add(new Lesson(id++, "Chemistry", "M. Curie", "9th grade"));
lessonList.add(new Lesson(id++, "Biology", "C. Darwin", "9th grade"));
lessonList.add(new Lesson(id++, "History", "I. Jones", "9th grade"));
lessonList.add(new Lesson(id++, "English", "I. Jones", "9th grade"));
lessonList.add(new Lesson(id++, "English", "I. Jones", "9th grade"));
lessonList.add(new Lesson(id++, "Spanish", "P. Cruz", "9th grade"));
lessonList.add(new Lesson(id++, "Spanish", "P. Cruz", "9th grade"));
lessonList.add(new Lesson(id++, "Math", "A. Turing", "10th grade"));
lessonList.add(new Lesson(id++, "Math", "A. Turing", "10th grade"));
lessonList.add(new Lesson(id++, "Math", "A. Turing", "10th grade"));
lessonList.add(new Lesson(id++, "Physics", "M. Curie", "10th grade"));
lessonList.add(new Lesson(id++, "Chemistry", "M. Curie", "10th grade"));
lessonList.add(new Lesson(id++, "French", "M. Curie", "10th grade"));
lessonList.add(new Lesson(id++, "Geography", "C. Darwin", "10th grade"));
lessonList.add(new Lesson(id++, "History", "I. Jones", "10th grade"));
lessonList.add(new Lesson(id++, "English", "P. Cruz", "10th grade"));
lessonList.add(new Lesson(id++, "Spanish", "P. Cruz", "10th grade"));
return new TimeTable(timeslotList, roomList, lessonList);
}
private static void printTimetable(TimeTable timeTable) {
LOGGER.info("");
List<Room> roomList = timeTable.getRoomList();
List<Lesson> lessonList = timeTable.getLessonList();
Map<Timeslot, Map<Room, List<Lesson>>> lessonMap = lessonList.stream()
.filter(lesson -> lesson.getTimeslot() != null && lesson.getRoom() != null)
.collect(Collectors.groupingBy(Lesson::getTimeslot, Collectors.groupingBy(Lesson::getRoom)));
LOGGER.info("| | " + roomList.stream()
.map(room -> String.format("%-10s", room.getName())).collect(Collectors.joining(" | ")) + " |");
LOGGER.info("|" + "------------|".repeat(roomList.size() + 1));
for (Timeslot timeslot : timeTable.getTimeslotList()) {
List<List<Lesson>> cellList = roomList.stream()
.map(room -> {
Map<Room, List<Lesson>> byRoomMap = lessonMap.get(timeslot);
if (byRoomMap == null) {
return Collections.<Lesson>emptyList();
}
List<Lesson> cellLessonList = byRoomMap.get(room);
if (cellLessonList == null) {
return Collections.<Lesson>emptyList();
}
return cellLessonList;
})
.collect(Collectors.toList());
LOGGER.info("| " + String.format("%-10s",
timeslot.getDayOfWeek().toString().substring(0, 3) + " " + timeslot.getStartTime()) + " | "
+ cellList.stream().map(cellLessonList -> String.format("%-10s",
cellLessonList.stream().map(Lesson::getSubject).collect(Collectors.joining(", "))))
.collect(Collectors.joining(" | "))
+ " |");
LOGGER.info("| | "
+ cellList.stream().map(cellLessonList -> String.format("%-10s",
cellLessonList.stream().map(Lesson::getTeacher).collect(Collectors.joining(", "))))
.collect(Collectors.joining(" | "))
+ " |");
LOGGER.info("| | "
+ cellList.stream().map(cellLessonList -> String.format("%-10s",
cellLessonList.stream().map(Lesson::getStudentGroup).collect(Collectors.joining(", "))))
.collect(Collectors.joining(" | "))
+ " |");
LOGGER.info("|" + "------------|".repeat(roomList.size() + 1));
}
List<Lesson> unassignedLessons = lessonList.stream()
.filter(lesson -> lesson.getTimeslot() == null || lesson.getRoom() == null)
.collect(Collectors.toList());
if (!unassignedLessons.isEmpty()) {
LOGGER.info("");
LOGGER.info("Unassigned lessons");
for (Lesson lesson : unassignedLessons) {
LOGGER.info(" " + lesson.getSubject() + " - " + lesson.getTeacher() + " - " + lesson.getStudentGroup());
}
}
}
}
main()方法中首先创建了SolverFactory:
SolverFactory<TimeTable> solverFactory = SolverFactory.create(new SolverConfig()
.withSolutionClass(TimeTable.class)
.withEntityClasses(Lesson.class)
.withConstraintProviderClass(TimeTableConstraintProvider.class)
// 当前数据集比较小,Solver只运行了5秒
// 建议至少运行5分钟 5m
.withTerminationSpentLimit(Duration.ofSeconds(5)));
以上代码将我们之前创建的@PlanningSolution类、@PlanningEntigy类、ConstraintProvider类进行了注册。
如果没有运行时间上限设置或者terminationEarly(),我们的求解器可能会一直运行下去,为了避免这种情况出现,我们设置了求解器的最大求解时间为5秒。
5秒后,main()方法将加载规划问题,求解并打印方案结果:
// 加载问题
TimeTable problem = generateDemoData();
// 问题求解
Solver<TimeTable> solver = solverFactory.buildSolver();
TimeTable solution = solver.solve(problem);
// 结果方案的可视化
printTimetable(solution);
solve()方法不会立即返回结果,而是在运行5秒后返回再返回此时得到的最好结果。OP在给定的时间内会返回已经找到的最好的方案。但是由于NP-hard问题本身,当前获取的最好方案可能不是最优方案,尤其是针对大数据集。通过增加限定时长去尽可能的找到更好的方案。
generateDemoData()方法用于生成当前学校课程表的数据。
printTimetable()方法将会打印课程表到控制台,这样就可以很多容易的通过可视化的方式判断当前的排程的好坏。
8.1配置日志
为了在终端看到输出,需要正确的配置日志。
创建 src/main/resource/logback.xml 文件:
<?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="org.optaplanner" level="info"/>
<root level="info">
<appender-ref ref="consoleAppender" />
</root>
</configuration>
9.运行程序
9.1在IDE中运行程序
将TimeTableApp类作为主类运行:
...
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 | |
...
检查控制台的输出。是否满足所有的硬约束?如果你将TimeTableConstraintProvider中的roomConflict 注释掉会发生什么?
info级别的log会展示OP在这5秒内都做了哪些事情:
... 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), score calculation speed (459/sec), step total (4).
... Local Search phase (1) ended: time spent (5000), best score (0hard/0soft), score calculation speed (28949/sec), step total (28398).
... Solving ended: time spent (5000), best score (0hard/0soft), score calculation speed (28524/sec), phase total (2), environment mode (REPRODUCIBLE).
9.2 对应用进行测试
一个好的程序应包含覆盖测试。
9.2.1 约束测试
在单元测试中使用ConstraintVerifier对每个约束进行单独测试。这个会独立隔离式的测试每个约束的边界情况,在覆盖测试的情况下可以降低新增一个约束时的维护量。
创建 src/test/java/org/acme/schooltimetabling/solver/TimeTableConstraintProviderTest.java 类:
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 org.optaplanner.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);
}
}
以上测试验证了约束 TimeTableConstraintProvider::roomConflict 会在三个课程在同一个教室教授,并且两个课程的时间冲突的情况下,判罚出匹配值为 1 的权重。因此,违反权重为10的约束将对整体评分的影响为 -10hard。
需要注意ConstraintVerifier在测试的过程中是如何忽略约束的权重的:即使是这些约束的权重被硬编码在ConstraintProvider中,约束的权重在进入生产环境之前会经常性的变动。在这种情况下,约束权重的调整不影响单元测试。
更多内容请查看 Testing Constraint Streams www.optaplanner.org/docs/optapl…。
9.3 日志
当向ConstraintProvider添加约束时,请留意info日志中分值计算的速度,在求解了同样长的时间后评估下整体表现的影响:
... Solving ended: ..., score calculation speed (29455/sec), ...
为了理解OP是内部是如何求解规划问题,可以修改日志级别配置logback.xml文件:
<logger name="org.optaplanner" level="debug"/>
使用debug模式可以展示OP的每一步的情况:
... 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}]).
...
使用trace 日志级别可以展示每一步及每一步的动作。
9.4 创建独立运行的应用程序
为了在IDE之外运行应用程序,你需要对你的编译工具的配置进行一些修改。
9.4.1Maven中的可执行JAR
在Maven中向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>
...
同时,在src/assembly目录下创建一个名为 jar-with-dependencies-and-services.xml 的文件,内容如下:
<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>
以上配置可以启用Maven Assembly Plugin插件,并且告诉插件做以下事情:
-
将当前项目所有依赖对应的classes和资源打包到一个新的JAR中。
- 如果任意一个依赖使用了Java SPI,插件将正确的绑定所有服务描述。
- 如果任意一个依赖是多版本发布的JAR,插件也将正确的处理。
-
将JAR的main class设置为 org.acme.schooltimetabling.TimeTableApp。
-
在你的工程的构建目录下(一般是target/)生成hello-world-run.jar。
这个JAR包跟其他可运行的JAR包一样运行:
$ mvn clean install
...
$ java -jar target/hello-world-run.jar
9.4.2 在Gradle中的可运行应用
在Gradle中,在你的build.gradle文件中添加如下内容:
application {
mainClass = "org.acme.schooltimetabling.TimeTableApp"
}
在构建完成项目后,你可以在build/distributions/ 目录下找到一个具有可执行程序的文件。
10.总结
恭喜!你已经使用OP开发了一个Java应用!
如果你遇到任何问题,可以查看下 the quickstart source code github.com/kiegroup/op…
阅读下一个指导内容,来构建一个具有REST 服务并且整合了数据的网页应用。