本指南将引导您完成使用OptaPlanner的约束解决人工智能(AI)创建Spring Boot应用程序的过程。
您将构建一个REST应用程序,为学生和教师优化学校时间表:
您的服务将通过使用AI来坚持硬和软调度约束,自动将Lesson实例分配给Timeslot和Room实例,例如以下示例:
- 一个教室最多只能同时上一节课。
- 一个老师一次最多只能教一节课。
- 一个学生一次最多只能上一节课。
- 老师喜欢在同一个教室里教所有的课。
- 老师喜欢循序渐进地上课,不喜欢课间的间隔。
- 学生不喜欢同一学科上连续的课。
从数学上讲,学校课程表是一个np困难问题。这意味着它很难扩展。对于一个重要的数据集,即使是在超级计算机上,简单地用蛮力迭代所有可能的组合也需要数百万年的时间。幸运的是,OptaPlanner等人工智能约束求解器拥有先进的算法,可以在合理的时间内提供近乎最优的解决方案。
按照下面几节中的说明逐步创建应用程序(推荐)。
或者,你也可以直接跳到完整的例子:
- 克隆Git存储库:
$ git clone https://github.com/kiegroup/optaplanner-quickstarts
或者下载存档文件。
- 在技术目录中找到解决方案并运行它(请参阅其README文件)。
要完成本指南,您需要:
- JDK 11+与JAVA_HOME配置适当
- Apache Maven 3.8.1+或Gradle 4+
- 一个IDE,如IntelliJ IDEA, VSCode或Eclipse
用下列依赖项创建一个Spring Boot应用:
- Spring Web (Spring -boot-start - Web)
- OptaPlanner (optaplanner-spring-boot-starter)
如果选择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>
<jar.with.dependencies.name>hello-world-run</jar.with.dependencies.name>
<version.org.optaplanner>9.38.0.Final</version.org.optaplanner>
<version.org.assertj>3.24.2</version.org.assertj>
<version.org.junit.jupiter>5.9.0</version.org.junit.jupiter>
<version.org.logback>1.2.11</version.org.logback>
<version.compiler.plugin>3.10.1</version.compiler.plugin>
<version.surefire.plugin>3.0.0-M8</version.surefire.plugin>
<version.assembly.plugin>3.4.2</version.assembly.plugin>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.optaplanner</groupId>
<artifactId>optaplanner-bom</artifactId>
<version>${version.org.optaplanner}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${version.org.junit.jupiter}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${version.org.logback}</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>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${version.org.assertj}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${version.compiler.plugin}</version>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${version.surefire.plugin}</version>
</plugin>
<plugin> <!-- For the purposes of integration testing only. -->
<artifactId>maven-failsafe-plugin</artifactId>
<version>${version.surefire.plugin}</version>
<configuration>
<systemPropertyVariables>
<artifactName>${jar.with.dependencies.name}</artifactName>
</systemPropertyVariables>
</configuration>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>${version.assembly.plugin}</version>
<configuration>
<finalName>${jar.with.dependencies.name}</finalName>
<appendAssemblyId>false</appendAssemblyId>
<descriptors> <!-- Builds a JAR with dependencies that correctly merges META-INF/service 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> <!-- Some of our dependencies are multi-release JARs. -->
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
你的目标是将每节课分配到一个时间段和一个房间。你将创建这些类:
Timeslot类表示授课的时间间隔,例如,周一10:30 - 11:30或周二13:30 - 14:30。为了简单起见,所有时间段都有相同的持续时间,并且在午餐或其他休息时间没有时间段。
时间段没有日期,因为高中的时间表只是每周重复一次。所以不需要持续的计划。
创建src/main/java/ org/acme/schooltimesabling/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(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
this.dayOfWeek = dayOfWeek;
this.startTime = startTime;
this.endTime = endTime;
}
public Timeslot(DayOfWeek dayOfWeek, LocalTime startTime) {
this(dayOfWeek, startTime, startTime.plusMinutes(50));
}
@Override
public String toString() {
return dayOfWeek + " " + startTime;
}
// ************************************************************************
// Getters and setters
// ************************************************************************
public DayOfWeek getDayOfWeek() {
return dayOfWeek;
}
public LocalTime getStartTime() {
return startTime;
}
public LocalTime getEndTime() {
return endTime;
}
}
因为在求解过程中没有Timeslot实例改变,所以Timeslot被称为问题事实。这样的类不需要任何OptaPlanner特定的注释。
注意,toString()方法使输出保持简短,因此更容易读取OptaPlanner的DEBUG或TRACE日志,如下所示。
Room类表示授课的位置,例如教室a或教室b。为了简单起见,所有的房间都没有容量限制,可以容纳所有的课程。
创建src/main/java/org/acme/ schooltimesabling/domain/room .java类:
package org.acme.schooltimetabling.domain;
public class Room {
private String name;
public Room(String name) {
this.name = name;
}
@Override
public String toString() {
return name;
}
// ************************************************************************
// Getters and setters
// ************************************************************************
public String getName() {
return name;
}
}
房间实例在解决过程中不会改变,所以房间也是一个问题事实。
2.4.5.3. 课程
在一节课中,老师向一群学生教授一门学科,例如,九年级的图灵教授数学,十年级的居里教授化学。如果同一科目每周由同一老师向同一学生组教授多次,则存在多个只能通过id区分的Lesson实例。例如,九年级每周有六节数学课。
在求解过程中,OptaPlanner更改了Lesson类的时隙和房间字段,将每节课分配到一个时隙和一个房间。因为OptaPlanner更改了这些字段,所以Lesson是一个规划实体:
前面图表中的大多数字段都包含输入数据,除了橙色字段:课程的时间段和房间字段在输入数据中是未分配的(null),而在输出数据中是分配的(非null)。OptaPlanner在求解过程中更改这些字段。这样的字段称为规划变量。为了让OptaPlanner识别它们,时隙和房间字段都需要一个@PlanningVariable注释。包含它们的类,Lesson,需要一个@PlanningEntity注释。
创建src/main/java/ org/acme/schooltimesabling/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
private Timeslot timeslot;
@PlanningVariable
private Room room;
// No-arg constructor required for OptaPlanner
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 Lesson(long id, String subject, String teacher, String studentGroup, Timeslot timeslot, Room room) {
this(id, subject, teacher, studentGroup);
this.timeslot = timeslot;
this.room = room;
}
@Override
public String toString() {
return subject + "(" + id + ")";
}
// ************************************************************************
// Getters and setters
// ************************************************************************
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;
}
}
Lesson类有一个@PlanningEntity注释,所以OptaPlanner知道这个类在求解过程中发生了变化,因为它包含一个或多个规划变量。
时隙字段有一个@PlanningVariable注释,所以OptaPlanner知道它可以改变它的值。为了找到潜在的时隙实例来分配给这个字段,OptaPlanner使用变量类型连接到一个值范围提供程序,该提供程序提供了一个List <Timeslot> 供选择。
出于同样的原因,room字段也有一个@PlanningVariable注释。
第一次为任意约束解决用例确定@PlanningVariable字段通常是 具有挑战性的。阅读领域建模指南以避免常见的陷阱。 |
分数代表特定解决方案的质量。越高越好。OptaPlanner寻找最佳解决方案,即在可用时间内找到的得分最高的解决方案。这可能是最优的解决方案。
因为这个用例有硬约束和软约束,所以使用HardSoftScore类来表示分数:
- 硬约束不能被打破。一个教室最多只能同时上一节课。
- 不应打破软约束。老师喜欢在一个单独的房间里教书。
硬约束对其他硬约束进行加权。相对于其他软约束,软约束也被加权。硬约束总是大于软约束,不管它们各自的权重如何。
为了计算分数,你可以实现一个EasyScoreCalculator类:
不幸的是,这种方法不能很好地扩展,因为它是非增量的:每次一节课被分配到不同的时间段或教室时,所有的课都要重新评估,以计算新的分数。
相反,创建一个src/main/java/org/acme/ schooltimesabling /solver/ timetableconstraintprovider .java类来执行增量分数计算。它使用OptaPlanner的ConstraintStream API,灵感来自Java Streams和SQL:
package org.acme.schooltimetabling.solver;
import java.time.Duration;
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[] {
// Hard constraints
roomConflict(constraintFactory),
teacherConflict(constraintFactory),
studentGroupConflict(constraintFactory),
// Soft constraints
teacherRoomStability(constraintFactory),
teacherTimeEfficiency(constraintFactory),
studentGroupSubjectVariety(constraintFactory)
};
}
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");
}
Constraint teacherRoomStability(ConstraintFactory constraintFactory) {
// A teacher prefers to teach in a single room.
return constraintFactory
.forEachUniquePair(Lesson.class,
Joiners.equal(Lesson::getTeacher))
.filter((lesson1, lesson2) -> lesson1.getRoom() != lesson2.getRoom())
.penalize(HardSoftScore.ONE_SOFT)
.asConstraint("Teacher room stability");
}
Constraint teacherTimeEfficiency(ConstraintFactory constraintFactory) {
// A teacher prefers to teach sequential lessons and dislikes gaps between lessons.
return constraintFactory
.forEach(Lesson.class)
.join(Lesson.class, Joiners.equal(Lesson::getTeacher),
Joiners.equal((lesson) -> lesson.getTimeslot().getDayOfWeek()))
.filter((lesson1, lesson2) -> {
Duration between = Duration.between(lesson1.getTimeslot().getEndTime(),
lesson2.getTimeslot().getStartTime());
return !between.isNegative() && between.compareTo(Duration.ofMinutes(30)) <= 0;
})
.reward(HardSoftScore.ONE_SOFT)
.asConstraint("Teacher time efficiency");
}
Constraint studentGroupSubjectVariety(ConstraintFactory constraintFactory) {
// A student group dislikes sequential lessons on the same subject.
return constraintFactory
.forEach(Lesson.class)
.join(Lesson.class,
Joiners.equal(Lesson::getSubject),
Joiners.equal(Lesson::getStudentGroup),
Joiners.equal((lesson) -> lesson.getTimeslot().getDayOfWeek()))
.filter((lesson1, lesson2) -> {
Duration between = Duration.between(lesson1.getTimeslot().getEndTime(),
lesson2.getTimeslot().getStartTime());
return !between.isNegative() && between.compareTo(Duration.ofMinutes(30)) <= 0;
})
.penalize(HardSoftScore.ONE_SOFT)
.asConstraint("Student group subject variety");
}
}
ConstraintProvider比EasyScoreCalculator伸缩性好一个数量级:O(n)而不是O(n²)。
TimeTable包含单个数据集的所有Timeslot、Room和实例。Lesson此外,因为它包含所有课程,每个课程都有一个特定的规划变量状态,所以它是一个规划解决方案并且有一个分数:
- 如果课程仍然未分配,那么它是一个未初始化的解决方案,例如,分数为-4init/0hard/0soft的解决方案。
- 如果它打破了硬约束,那么它就是一个不可行的解决方案,例如,一个得分为-2硬/-3软的解决方案。
- 如果它遵循所有硬约束,那么它就是一个可行的解决方案,例如,得分为0硬/-7软的解决方案。
创建src/main/java/ org/acme/schooltimesabling/domain/timesable.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 {
@ProblemFactCollectionProperty
@ValueRangeProvider
private List<Timeslot> timeslotList;
@ProblemFactCollectionProperty
@ValueRangeProvider
private List<Room> roomList;
@PlanningEntityCollectionProperty
private List<Lesson> lessonList;
@PlanningScore
private HardSoftScore score;
// No-arg constructor required for OptaPlanner
public TimeTable() {
}
public TimeTable(List<Timeslot> timeslotList, List<Room> roomList, List<Lesson> lessonList) {
this.timeslotList = timeslotList;
this.roomList = roomList;
this.lessonList = lessonList;
}
// ************************************************************************
// Getters and setters
// ************************************************************************
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注释,所以OptaPlanner知道这个类包含所有的输入和输出数据。
具体来说,这个类是问题的输入:
- 包含所有时隙的timeslotList字段
- 这是一个问题事实列表,因为它们在解决过程中不会改变。
- 包含所有房间的roomList字段
- 这是一个问题事实列表,因为它们在解决过程中不会改变。
- 包含所有课程的lessonList字段
- 这是一个规划实体列表,因为它们在求解过程中会发生变化。
- 每节课:
- 时隙和房间字段的值通常仍然为空,因此未分配。它们是计划变量。
- 填充其他字段,如subject、teacher和studentGroup。这些字段是问题属性。
然而,这个类也是解决方案的输出:
- 一个lessonList字段,其中每个Lesson实例在求解后具有非空的时间段和房间字段
- 表示输出解决方案质量的score字段,例如,0hard/-5soft
timeslotList字段是一个值范围提供程序。它保存着时隙实例,OptaPlanner可以从中挑选并将其分配给Lesson实例的时隙字段。timeslotList字段有一个@ValueRangeProvider注释,通过将规划变量的类型与值范围提供程序返回的类型进行匹配,将@PlanningVariable与@ValueRangeProvider连接起来。
按照相同的逻辑,roomList字段也有一个@ValueRangeProvider注释。
此外,OptaPlanner需要知道它可以更改哪些课程实例,以及如何检索TimeTableConstraintProvider用于计算分数的Timeslot和Room实例。
timeslotList和roomList字段有一个@ProblemFactCollectionProperty注释,所以你的TimeTableConstraintProvider可以从这些实例中进行选择。
lessonList有一个@PlanningEntityCollectionProperty注释,所以OptaPlanner可以在求解过程中改变它们,你的TimeTableConstraintProvider也可以从中选择。
现在您已经准备好将所有内容放在一起并创建REST服务。但是在REST线程上解决计划问题会导致HTTP超时问题。因此,Spring Boot启动器注入了一个SolverManager实例,该实例在单独的线程池中运行求解器,并且可以并行地求解多个数据集。
创建src/main/java/org/acme/ schooltimesabling/rest/timesablecontroller .java类:
为简单起见,这个初始实现等待求解器完成,这仍然可能导致HTTP超时。完整的实现更优雅地避免了HTTP超时。
如果没有终止设置或terminationEarly()事件,求解器将永远运行。为了避免这种情况,将解决问题的时间限制在5秒内。这段时间足够短,可以避免HTTP超时。
创建src/main/resources/application。属性文件:
OptaPlanner返回在可用终止时间内找到的最佳解决方案。由于np困难问题的性质,最佳解决方案可能不是最优的,特别是对于较大的数据集。增加终止时间可能会找到更好的解决方案。
将所有内容打包到一个由标准Java main()方法驱动的可执行JAR文件中:
将Spring Initializr创建的DemoApplication.java类替换为src/main/java/org/acme/ schooltimesabling / timetablespringbootapp .java类:
将TimeTableSpringBootApp类作为普通Java应用程序的主类运行。
既然应用程序正在运行,您就可以测试REST服务了。您可以使用任何您希望使用的REST客户端。使用Linux命令curl发送POST请求的示例如下:
大约5秒后,根据应用程序中定义的终止所花费的时间。属性,该服务返回类似于以下示例的输出:
注意,您的应用程序将所有四节课分配到两个时间段中的一个和两个房间中的一个。还要注意,它符合所有硬约束。例如,居里的两节课是在不同的时间段。
在服务器端,信息日志显示OptaPlanner在这五秒钟内做了什么:
.. 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).
一个好的应用程序包括测试覆盖。
要单独测试每个约束,请在单元测试中使用ConstraintVerifier。它在与其他测试隔离的情况下测试每个约束的边缘用例,这降低了在添加具有适当测试覆盖率的新约束时的维护。
在pom.xml中添加optaplanner-test依赖项:
创建src/test/java/org/acme/ schooltimesabling /solver/ timesableconstraintprovidertest .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 Room ROOM2 = new Room("Room2");
private static final Timeslot TIMESLOT1 = new Timeslot(DayOfWeek.MONDAY, LocalTime.NOON);
private static final Timeslot TIMESLOT2 = new Timeslot(DayOfWeek.TUESDAY, LocalTime.NOON);
private static final Timeslot TIMESLOT3 = new Timeslot(DayOfWeek.TUESDAY, LocalTime.NOON.plusHours(1));
private static final Timeslot TIMESLOT4 = new Timeslot(DayOfWeek.TUESDAY, LocalTime.NOON.plusHours(3));
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);
}
@Test
void teacherConflict() {
String conflictingTeacher = "Teacher1";
Lesson firstLesson = new Lesson(1, "Subject1", conflictingTeacher, "Group1", TIMESLOT1, ROOM1);
Lesson conflictingLesson = new Lesson(2, "Subject2", conflictingTeacher, "Group2", TIMESLOT1, ROOM2);
Lesson nonConflictingLesson = new Lesson(3, "Subject3", "Teacher2", "Group3", TIMESLOT2, ROOM1);
constraintVerifier.verifyThat(TimeTableConstraintProvider::teacherConflict)
.given(firstLesson, conflictingLesson, nonConflictingLesson)
.penalizesBy(1);
}
@Test
void studentGroupConflict() {
String conflictingGroup = "Group1";
Lesson firstLesson = new Lesson(1, "Subject1", "Teacher1", conflictingGroup, TIMESLOT1, ROOM1);
Lesson conflictingLesson = new Lesson(2, "Subject2", "Teacher2", conflictingGroup, TIMESLOT1, ROOM2);
Lesson nonConflictingLesson = new Lesson(3, "Subject3", "Teacher3", "Group3", TIMESLOT2, ROOM1);
constraintVerifier.verifyThat(TimeTableConstraintProvider::studentGroupConflict)
.given(firstLesson, conflictingLesson, nonConflictingLesson)
.penalizesBy(1);
}
@Test
void teacherRoomStability() {
String teacher = "Teacher1";
Lesson lessonInFirstRoom = new Lesson(1, "Subject1", teacher, "Group1", TIMESLOT1, ROOM1);
Lesson lessonInSameRoom = new Lesson(2, "Subject2", teacher, "Group2", TIMESLOT1, ROOM1);
Lesson lessonInDifferentRoom = new Lesson(3, "Subject3", teacher, "Group3", TIMESLOT1, ROOM2);
constraintVerifier.verifyThat(TimeTableConstraintProvider::teacherRoomStability)
.given(lessonInFirstRoom, lessonInDifferentRoom, lessonInSameRoom)
.penalizesBy(2);
}
@Test
void teacherTimeEfficiency() {
String teacher = "Teacher1";
Lesson singleLessonOnMonday = new Lesson(1, "Subject1", teacher, "Group1", TIMESLOT1, ROOM1);
Lesson firstTuesdayLesson = new Lesson(2, "Subject2", teacher, "Group2", TIMESLOT2, ROOM1);
Lesson secondTuesdayLesson = new Lesson(3, "Subject3", teacher, "Group3", TIMESLOT3, ROOM1);
Lesson thirdTuesdayLessonWithGap = new Lesson(4, "Subject4", teacher, "Group4", TIMESLOT4, ROOM1);
constraintVerifier.verifyThat(TimeTableConstraintProvider::teacherTimeEfficiency)
.given(singleLessonOnMonday, firstTuesdayLesson, secondTuesdayLesson, thirdTuesdayLessonWithGap)
.rewardsWith(1); // Second tuesday lesson immediately follows the first.
}
@Test
void studentGroupSubjectVariety() {
String studentGroup = "Group1";
String repeatedSubject = "Subject1";
Lesson mondayLesson = new Lesson(1, repeatedSubject, "Teacher1", studentGroup, TIMESLOT1, ROOM1);
Lesson firstTuesdayLesson = new Lesson(2, repeatedSubject, "Teacher2", studentGroup, TIMESLOT2, ROOM1);
Lesson secondTuesdayLesson = new Lesson(3, repeatedSubject, "Teacher3", studentGroup, TIMESLOT3, ROOM1);
Lesson thirdTuesdayLessonWithDifferentSubject = new Lesson(4, "Subject2", "Teacher4", studentGroup, TIMESLOT4, ROOM1);
Lesson lessonInAnotherGroup = new Lesson(5, repeatedSubject, "Teacher5", "Group2", TIMESLOT1, ROOM1);
constraintVerifier.verifyThat(TimeTableConstraintProvider::studentGroupSubjectVariety)
.given(mondayLesson, firstTuesdayLesson, secondTuesdayLesson, thirdTuesdayLessonWithDifferentSubject,
lessonInAnotherGroup)
.penalizesBy(1); // Second tuesday lesson immediately follows the first.
}
}
此测试验证约束TimeTableConstraintProvider::roomConflict,当给定同一房间中的三节课,其中两节课具有相同的时间段时,它会以匹配权重为1进行惩罚。所以如果约束权重为10hard,那么分数就会减少-10hard。
请注意,ConstraintVerifier在测试期间是如何忽略约束权重的——即使这些约束权重是硬编码在ConstraintProvider中——因为约束权重在投入生产之前会定期变化。这样,约束权重调整就不会破坏单元测试。
在JUnit测试中,生成一个测试数据集并将其发送到timeablecontroller进行求解。
创建一个类:src/test/java/ org/acme/schooltimesabling/rest/timetablecontrolertest .java:
package org.acme.schooltimetabling.rest;
import java.time.DayOfWeek;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
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.junit.jupiter.api.Timeout;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(properties = {
// Effectively disable spent-time termination in favor of the best-score-limit
"optaplanner.solver.termination.spent-limit=1h",
"optaplanner.solver.termination.best-score-limit=0hard/*soft"})
public class TimeTableControllerTest {
@Autowired
private TimeTableController timeTableController;
@Test
@Timeout(600_000)
public void solve() {
TimeTable problem = generateProblem();
TimeTable solution = timeTableController.solve(problem);
assertFalse(solution.getLessonList().isEmpty());
for (Lesson lesson : solution.getLessonList()) {
assertNotNull(lesson.getTimeslot());
assertNotNull(lesson.getRoom());
}
assertTrue(solution.getScore().isFeasible());
}
private TimeTable generateProblem() {
List<Timeslot> timeslotList = new ArrayList<>();
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)));
List<Room> roomList = new ArrayList<>();
roomList.add(new Room("Room A"));
roomList.add(new Room("Room B"));
roomList.add(new Room("Room C"));
List<Lesson> lessonList = new ArrayList<>();
lessonList.add(new Lesson(101L, "Math", "B. May", "9th grade"));
lessonList.add(new Lesson(102L, "Physics", "M. Curie", "9th grade"));
lessonList.add(new Lesson(103L, "Geography", "M. Polo", "9th grade"));
lessonList.add(new Lesson(104L, "English", "I. Jones", "9th grade"));
lessonList.add(new Lesson(105L, "Spanish", "P. Cruz", "9th grade"));
lessonList.add(new Lesson(201L, "Math", "B. May", "10th grade"));
lessonList.add(new Lesson(202L, "Chemistry", "M. Curie", "10th grade"));
lessonList.add(new Lesson(203L, "History", "I. Jones", "10th grade"));
lessonList.add(new Lesson(204L, "English", "P. Cruz", "10th grade"));
lessonList.add(new Lesson(205L, "French", "M. Curie", "10th grade"));
return new TimeTable(timeslotList, roomList, lessonList);
}
}
这个测试验证在解决后,所有的课程都被分配到一个时间段和一个房间。它还验证它找到了一个可行的解决方案(没有打破硬约束)。
通常,求解器在不到200毫秒的时间内找到一个可行的解。注意@SpringBootTest注释的properties属性如何在测试期间覆盖求解器终止,以便在找到可行的解决方案(硬/软)时立即终止。这避免了硬编码求解器的时间,因为单元测试可能在任意硬件上运行。这种方法确保测试运行足够长的时间以找到可行的解决方案,即使在速度较慢的机器上也是如此。但是,即使在速度很快的机器上,它也不会比严格要求的时间多运行一毫秒。
当在ConstraintProvider中添加约束时,在解决相同的时间后,密切关注信息日志中的分数计算速度,以评估性能影响:
要了解OptaPlanner如何在内部解决您的问题,请更改文件中的日志记录application.properties
使用调试日志来显示每一步:
... 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}]).
...使用跟踪日志记录来显示每一步和每一步的每一步移动。
恭喜你!您刚刚使用OptaPlanner开发了一个Spring应用程序!
现在尝试添加数据库和UI集成:
- 为Timeslot, Room和Lesson创建JPA存储库。
- 通过REST公开它们。
- 构建一个timetableerepository facade,以便在单个事务中读写一个时间表实例。
- 相应地调整timeablecontroller:
package org.acme.schooltimetabling.rest;
import org.acme.schooltimetabling.domain.TimeTable;
import org.acme.schooltimetabling.persistence.TimeTableRepository;
import org.optaplanner.core.api.solver.SolutionManager;
import org.optaplanner.core.api.solver.SolverManager;
import org.optaplanner.core.api.solver.SolverStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/timeTable")
public class TimeTableController {
@Autowired
private TimeTableRepository timeTableRepository;
@Autowired
private SolverManager<TimeTable, Long> solverManager;
@Autowired
private SolutionManager<TimeTable, HardSoftScore> solutionManager;
// To try, GET http://localhost:8080/timeTable
@GetMapping()
public TimeTable getTimeTable() {
// Get the solver status before loading the solution
// to avoid the race condition that the solver terminates between them
SolverStatus solverStatus = getSolverStatus();
TimeTable solution = timeTableRepository.findById(TimeTableRepository.SINGLETON_TIME_TABLE_ID);
solutionManager.update(solution); // Sets the score
solution.setSolverStatus(solverStatus);
return solution;
}
@PostMapping("/solve")
public void solve() {
solverManager.solveAndListen(TimeTableRepository.SINGLETON_TIME_TABLE_ID,
timeTableRepository::findById,
timeTableRepository::save);
}
public SolverStatus getSolverStatus() {
return solverManager.getSolverStatus(TimeTableRepository.SINGLETON_TIME_TABLE_ID);
}
@PostMapping("/stopSolving")
public void stopSolving() {
solverManager.terminateEarly(TimeTableRepository.SINGLETON_TIME_TABLE_ID);
}
为简单起见,这段代码只处理一个时间表实例,但是很容易启用多租户并并行处理不同高中的多个时间表实例。
get时间表()方法从数据库返回最新的时间表。它使用SolutionManager(自动注入的)来计算时间表的分数,因此UI可以显示分数。
solve()方法启动一个作业来求解当前时间表,并将时间段和房间分配存储在数据库中。它使用SolverManager.solveAndListen()方法来侦听中间的最佳解决方案并相应地更新数据库。这使UI能够在后端仍在解决问题时显示进度。
5. 现在solve()方法立即返回,相应地调整TimeTableControllerTest实例。轮询最新的解,直到求解器完成求解:
package org.acme.schooltimetabling.rest;
import org.acme.schooltimetabling.domain.Lesson;
import org.acme.schooltimetabling.domain.TimeTable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.optaplanner.core.api.solver.SolverStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SpringBootTest(properties = {
"optaplanner.solver.termination.spent-limit=1h", // Effectively disable this termination in favor of the best-score-limit
"optaplanner.solver.termination.best-score-limit=0hard/*soft"})
public class TimeTableControllerTest {
@Autowired
private TimeTableController timeTableController;
@Test
@Timeout(600_000)
public void solveDemoDataUntilFeasible() throws InterruptedException {
timeTableController.solve();
TimeTable timeTable = timeTableController.getTimeTable();
while (timeTable.getSolverStatus() != SolverStatus.NOT_SOLVING) {
// Quick polling (not a Test Thread Sleep anti-pattern)
// Test is still fast on fast machines and doesn't randomly fail on slow machines.
Thread.sleep(20L);
timeTable = timeTableController.getTimeTable();
}
assertFalse(timeTable.getLessonList().isEmpty());
for (Lesson lesson : timeTable.getLessonList()) {
assertNotNull(lesson.getTimeslot());
assertNotNull(lesson.getRoom());
}
assertTrue(timeTable.getScore().isFeasible());
}
}
6. 在这些REST方法之上构建一个有吸引力的web UI来可视化时间表。