diff --git a/docs/src/antora.yml b/docs/src/antora.yml
index 3f2a450dff9..e1e99d89f37 100644
--- a/docs/src/antora.yml
+++ b/docs/src/antora.yml
@@ -14,6 +14,9 @@ asciidoc:
spring-boot-version: ${version.org.springframework.boot}
logback-version: ${version.ch.qos.logback}
exec-maven-plugin-version: ${version.exec.plugin}
+ assembly-plugin-version: ${version.assembly.plugin}
+ compiler-plugin-version: ${version.compiler.plugin}
+ junit-version: ${version.org.junit.jupiter}
rewrite-maven-plugin-version: ${version.rewrite.plugin}
nav:
- modules/ROOT/nav.adoc
diff --git a/docs/src/modules/ROOT/pages/_attributes.adoc b/docs/src/modules/ROOT/pages/_attributes.adoc
index f5ec1b01a2a..f823896d5c7 100644
--- a/docs/src/modules/ROOT/pages/_attributes.adoc
+++ b/docs/src/modules/ROOT/pages/_attributes.adoc
@@ -3,6 +3,7 @@
:quickstarts-archive-url: https://github.com/TimefoldAI/timefold-quickstarts/archive/refs/heads/stable.tar.gz
:hello-world-java-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/hello-world
:spring-boot-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/spring-boot-integration
-:quarkus-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/school-timetabling
-:vrp-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/vehicle-routing
+:quarkus-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/quarkus-integration
+:quarkus-kotlin-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/quarkus-integration-kotlin
+:vrp-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/use-cases/vehicle-routing
:service-quickstart-url: https://github.com/TimefoldAI/timefold-quickstarts/tree/stable/getting-started/service
diff --git a/docs/src/modules/ROOT/pages/quickstart/hello-world/hello-world-quickstart.adoc b/docs/src/modules/ROOT/pages/quickstart/hello-world/hello-world-quickstart.adoc
index 44d5d92a838..528bb75535c 100644
--- a/docs/src/modules/ROOT/pages/quickstart/hello-world/hello-world-quickstart.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/hello-world/hello-world-quickstart.adoc
@@ -97,6 +97,7 @@ Create a Maven or Gradle build file and add these dependencies:
* `timefold-solver-core` (compile scope) to solve the school timetable problem.
* A xref:running-timefold-solver/solver-diagnostics.adoc#logging[logging] implementation, such as `logback-classic` (runtime scope), to see what Timefold Solver is doing.
+* `junit-jupiter` (test scope) to test the constraints.
[tabs]
====
@@ -135,6 +136,11 @@ Your `pom.xml` file has the following content:
logback-classic
{logback-version}
+
+ org.junit.jupiter
+ junit-jupiter
+ {junit-version}
+
@@ -148,6 +154,11 @@ Your `pom.xml` file has the following content:
logback-classic
runtime
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
@@ -180,6 +191,7 @@ plugins {
def timefoldSolverVersion = "{timefold-solver-version}"
def logbackVersion = "{logback-version}"
+def junitVersion = "{junit-version}"
group = "org.acme"
version = "1.0-SNAPSHOT"
@@ -193,6 +205,10 @@ dependencies {
implementation "ai.timefold.solver:timefold-solver-core"
runtimeOnly "ch.qos.logback:logback-classic:${logbackVersion}"
+
+ testImplementation platform("org.junit:junit-bom:${junitVersion}")
+ testImplementation "org.junit.jupiter:junit-jupiter"
+ testRuntimeOnly "org.junit.platform:junit-platform-launcher"
}
java {
@@ -214,6 +230,7 @@ application {
}
test {
+ useJUnitPlatform()
// Log the test execution results.
testLogging {
events "passed", "skipped", "failed"
@@ -421,8 +438,6 @@ 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
@@ -443,7 +458,7 @@ object TimetableApp {
)
// Load the problem
- val problem = generateDemoData(DemoData.SMALL)
+ val problem = generateDemoData()
// Solve the problem
val solver = solverFactory.buildSolver()
@@ -503,26 +518,16 @@ object TimetableApp {
LOGGER.info("")
val rooms = timeTable.rooms
val lessons = timeTable.lessons
- val lessonMap = lessons.stream()
+ val lessonMap: Map>> = lessons
.filter { lesson: Lesson -> lesson.timeslot != null && lesson.room != null }
- .collect(
- Collectors.groupingBy>>(
- Function { obj: Lesson -> obj.timeslot }, Collectors.groupingBy(
- Function { obj: Lesson -> obj.room })
- )
- )
+ .groupBy { lesson: Lesson -> lesson.timeslot!! }
+ .mapValues { (_, timeslotLessons) -> timeslotLessons.groupBy { lesson: Lesson -> lesson.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()
- val cellLessons = byRoomMap[room]!!
- Objects.requireNonNullElse(cellLessons, emptyList())
- }.toList()
+ val cells = rooms.map { room: Room -> lessonMap[timeslot]?.get(room) ?: emptyList() }
LOGGER.info("| " + String.format(
"%-10s",
@@ -641,7 +646,7 @@ Kotlin::
[source,kotlin]
----
// Load the problem
-val problem = generateDemoData(DemoData.SMALL)
+val problem = generateDemoData()
// Solve the problem
val solver = solverFactory.buildSolver()
@@ -751,17 +756,17 @@ import ai.timefold.solver.core.api.score.stream.test.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);
+ private static final Timeslot TIMESLOT1 = new Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.NOON);
+ private static final Timeslot TIMESLOT2 = new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(9, 0), LocalTime.NOON);
ConstraintVerifier 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);
+ 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);
@@ -781,8 +786,7 @@ Create the `src/test/kotlin/org/acme/schooltimetabling/solver/TimetableConstrain
----
package org.acme.schooltimetabling.solver
-import ai.timefold.solver.core.api.score.stream.ConstraintFactory
-import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier;
+import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier
import org.acme.schooltimetabling.domain.Lesson
import org.acme.schooltimetabling.domain.Room
import org.acme.schooltimetabling.domain.Timeslot
@@ -798,22 +802,18 @@ internal class TimetableConstraintProviderTest {
@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
- )
- }
+ val firstLesson = Lesson("1", "Subject1", "Teacher1", "Group1").apply { timeslot = TIMESLOT1; room = ROOM1 }
+ val conflictingLesson = Lesson("2", "Subject2", "Teacher2", "Group2").apply { timeslot = TIMESLOT1; room = ROOM1 }
+ val nonConflictingLesson = Lesson("3", "Subject3", "Teacher3", "Group3").apply { timeslot = TIMESLOT2; room = ROOM1 }
+ constraintVerifier.verifyThat(TimetableConstraintProvider::roomConflict)
.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)
+ private val TIMESLOT1 = Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.NOON)
+ private val TIMESLOT2 = Timeslot(DayOfWeek.TUESDAY, LocalTime.of(9, 0), LocalTime.NOON)
}
}
----
@@ -882,7 +882,7 @@ Maven::
--
In Maven, add the following to your `pom.xml`:
-[source,xml,options="nowrap"]
+[source,xml,options="nowrap",subs=attributes+]
----
...
@@ -890,7 +890,7 @@ In Maven, add the following to your `pom.xml`:
org.apache.maven.plugins
maven-assembly-plugin
- ${version.assembly.plugin}
+ {assembly-plugin-version}
hello-world-run
false
diff --git a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/quarkus-vehicle-routing-quickstart.adoc b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/quarkus-vehicle-routing-quickstart.adoc
index d2059da4ee5..9f16535fa63 100644
--- a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/quarkus-vehicle-routing-quickstart.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/quarkus-vehicle-routing-quickstart.adoc
@@ -96,11 +96,12 @@ Java::
1.0-SNAPSHOT
- 11
+ {java-version}
UTF-8
{quarkus-version}
{timefold-solver-version}
+ {compiler-plugin-version}
@@ -189,11 +190,13 @@ Kotlin::
1.0-SNAPSHOT
- 11
+ {java-version}
UTF-8
{quarkus-version}
{timefold-solver-version}
+ {compiler-plugin-version}
+ 2.4.10
@@ -236,7 +239,6 @@ Kotlin::
org.jetbrains.kotlin
kotlin-stdlib
- 1.9.22
@@ -355,14 +357,14 @@ import org.acme.vehiclerouting.domain.VehicleRoutePlan;
@Path("route-plans")
public class VehicleRoutePlanResource {
- private final SolverManager solverManager;
+ private final SolverManager solverManager;
public VehicleRoutePlanResource() {
this.solverManager = null;
}
@Inject
- public VehicleRoutePlanResource(SolverManager solverManager) {
+ public VehicleRoutePlanResource(SolverManager solverManager) {
this.solverManager = solverManager;
}
@@ -370,8 +372,10 @@ public class VehicleRoutePlanResource {
@Consumes({ MediaType.APPLICATION_JSON })
@Produces(MediaType.APPLICATION_JSON)
public VehicleRoutePlan solve(VehicleRoutePlan problem) {
+ // Jackson skips the constructor that calculates the driving times
+ problem.initDrivingTimeMaps();
String jobId = UUID.randomUUID().toString();
- SolverJob solverJob = solverManager.solveBuilder()
+ SolverJob solverJob = solverManager.solveBuilder()
.withProblemId(jobId)
.withProblem(problem)
.run();
@@ -412,14 +416,14 @@ import org.acme.vehiclerouting.domain.VehicleRoutePlan
@Path("route-plans")
class VehicleRoutePlanResource {
- private val solverManager: SolverManager?
+ private val solverManager: SolverManager?
constructor() {
this.solverManager = null
}
@Inject
- constructor(solverManager: SolverManager?) {
+ constructor(solverManager: SolverManager?) {
this.solverManager = solverManager
}
@@ -427,6 +431,8 @@ class VehicleRoutePlanResource {
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
fun solve(problem: VehicleRoutePlan): VehicleRoutePlan {
+ // Jackson skips the constructor that calculates the driving times
+ problem.initDrivingTimeMaps()
val jobId = UUID.randomUUID().toString()
val solverJob = solverManager!!.solveBuilder()
.withProblemId(jobId)
@@ -619,7 +625,7 @@ class VehicleRoutingConstraintProviderTest {
Kotlin::
+
--
-Create the `src/test/kotlin/org/acme/schooltimetabling/solver/TimetableConstraintProviderTest.kt` class:
+Create the `src/test/kotlin/org/acme/vehiclerouting/solver/VehicleRoutingConstraintProviderTest.kt` class:
[source,kotlin]
----
@@ -633,7 +639,6 @@ import java.util.Arrays
import jakarta.inject.Inject
-import ai.timefold.solver.core.api.score.stream.ConstraintFactory
import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier
import org.acme.vehiclerouting.domain.Location
@@ -659,15 +664,11 @@ internal class VehicleRoutingConstraintProviderTest {
val tomorrow_10_00 = LocalDateTime.of(TOMORROW, LocalTime.of(10, 0))
val vehicleA = Vehicle("1", 100, LOCATION_1, tomorrow_07_00)
val visit1 = Visit("2", "John", LOCATION_2, 80, tomorrow_08_00, tomorrow_10_00, Duration.ofMinutes(30L))
- vehicleA.visits!!.add(visit1)
+ vehicleA.visits.add(visit1)
val visit2 = Visit("3", "Paul", LOCATION_3, 40, tomorrow_08_00, tomorrow_10_00, Duration.ofMinutes(30L))
- vehicleA.visits!!.add(visit2)
+ vehicleA.visits.add(visit2)
- constraintVerifier!!.verifyThat { obj: VehicleRoutingConstraintProvider, factory: ConstraintFactory? ->
- obj.vehicleCapacity(
- factory!!
- )
- }
+ constraintVerifier.verifyThat(VehicleRoutingConstraintProvider::vehicleCapacity)
.given(vehicleA, visit1, visit2)
.penalizesBy(20)
}
@@ -709,9 +710,9 @@ This way, constraint weight tweaking does not break the unit tests.
==== Test the solver
-In a JUnit test, generate a test dataset and send it to the `VehicleRoutePlanResource` to solve.
+In a JUnit test, send a test dataset to the `VehicleRoutePlanResource` to solve.
-Add some dependencies in your `pom.xml`:
+Add the subsequent dependency in your `pom.xml`:
[source,xml]
----
@@ -719,13 +720,10 @@ Add some dependencies in your `pom.xml`:
rest-assured
test
-
- org.awaitility
- awaitility
- test
-
----
+Save the JSON request body of the earlier `curl` command as the `src/test/resources/testing-route-plan.json` file.
+
Then create the test itself:
[tabs]
@@ -739,61 +737,36 @@ Create the `src/test/java/org/acme/vehiclerouting/rest/VehicleRoutePlanResourceT
----
package org.acme.vehiclerouting.rest;
-import static io.restassured.RestAssured.get;
import static io.restassured.RestAssured.given;
-import static org.awaitility.Awaitility.await;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import java.time.Duration;
+import java.io.File;
-import ai.timefold.solver.core.api.solver.SolverStatus;
+import ai.timefold.solver.core.api.score.HardSoftScore;
-import org.acme.vehiclerouting.domain.VehicleRoutePlan;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
@QuarkusTest
-public class VehicleRoutePlanResourceTest {
+class VehicleRoutePlanResourceTest {
@Test
- public void solveDemoDataUntilFeasible() {
- VehicleRoutePlan vehicleRoutePlan = given()
- .when().get("/demo-data/FIRENZE")
- .then()
- .statusCode(200)
- .extract()
- .as(VehicleRoutePlan.class);
-
- String jobId = given()
+ void solveUntilFeasible() {
+ String score = given()
.contentType(ContentType.JSON)
- .body(vehicleRoutePlan)
- .expect().contentType(ContentType.TEXT)
+ .body(new File("src/test/resources/testing-route-plan.json"))
+ .expect().contentType(ContentType.JSON)
.when().post("/route-plans")
.then()
.statusCode(200)
.extract()
- .asString();
-
- await()
- .atMost(Duration.ofMinutes(1))
- .pollInterval(Duration.ofMillis(500L))
- .until(() -> SolverStatus.NOT_SOLVING.name().equals(
- get("/route-plans/" + jobId + "/status")
- .jsonPath().get("solverStatus")));
-
- VehicleRoutePlan solution = get("/route-plans/" + jobId).then().extract().as(VehicleRoutePlan.class);
- assertEquals(solution.getSolverStatus(), SolverStatus.NOT_SOLVING);
- assertNotNull(solution.getVehicles());
- assertNotNull(solution.getVisits());
- assertNotNull(solution.getVehicles().get(0).getVisits());
- assertTrue(solution.getScore().isFeasible());
+ .path("score");
+
+ assertTrue(HardSoftScore.parseScore(score).isFeasible());
}
}
-
----
--
Kotlin::
@@ -805,60 +778,32 @@ Create the `src/test/kotlin/org/acme/vehiclerouting/rest/VehicleRoutePlanResourc
----
package org.acme.vehiclerouting.rest
-import java.time.Duration
+import java.io.File
-import ai.timefold.solver.core.api.solver.SolverStatus
+import ai.timefold.solver.core.api.score.HardSoftScore
-import org.acme.vehiclerouting.domain.VehicleRoutePlan
+import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import io.quarkus.test.junit.QuarkusTest
-import io.restassured.RestAssured
+import io.restassured.RestAssured.given
import io.restassured.http.ContentType
-import org.awaitility.Awaitility
-
-import org.junit.jupiter.api.Assertions.assertEquals
-import org.junit.jupiter.api.Assertions.assertNotNull
-import org.junit.jupiter.api.Assertions.assertTrue
-
@QuarkusTest
class VehicleRoutePlanResourceTest {
@Test
- fun solveDemoDataUntilFeasible() {
- val vehicleRoutePlan = RestAssured.given()
- .`when`()["/demo-data/FIRENZE"]
- .then()
- .statusCode(200)
- .extract()
- .`as`(VehicleRoutePlan::class.java)
-
- val jobId = RestAssured.given()
+ fun solveUntilFeasible() {
+ val score: String = given()
.contentType(ContentType.JSON)
- .body(vehicleRoutePlan)
- .expect().contentType(ContentType.TEXT)
+ .body(File("src/test/resources/testing-route-plan.json"))
+ .expect().contentType(ContentType.JSON)
.`when`().post("/route-plans")
.then()
.statusCode(200)
.extract()
- .asString()
-
- Awaitility.await()
- .atMost(Duration.ofMinutes(1))
- .pollInterval(Duration.ofMillis(500L))
- .until {
- SolverStatus.NOT_SOLVING.name == RestAssured.get("/route-plans/$jobId/status")
- .jsonPath().get("solverStatus")
- }
-
- val solution = RestAssured.get("/route-plans/$jobId").then().extract().`as`(
- VehicleRoutePlan::class.java
- )
- assertEquals(solution.solverStatus, SolverStatus.NOT_SOLVING)
- assertNotNull(solution.vehicles)
- assertNotNull(solution.visits)
- assertNotNull(solution.vehicles!!.get(0).visits)
- assertTrue(solution.score!!.isFeasible())
+ .path("score")
+
+ assertTrue(HardSoftScore.parseScore(score).isFeasible)
}
}
----
diff --git a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-constraints.adoc b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-constraints.adoc
index 9549061b6e7..b8a8367361d 100644
--- a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-constraints.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-constraints.adoc
@@ -60,7 +60,7 @@ public class VehicleRoutingConstraintProvider implements ConstraintProvider {
protected Constraint vehicleCapacity(ConstraintFactory factory) {
return factory.forEach(Vehicle.class)
.filter(vehicle -> vehicle.getTotalDemand() > vehicle.getCapacity())
- .penalizeLong(HardSoftScore.ONE_HARD,
+ .penalize(HardSoftScore.ONE_HARD,
vehicle -> vehicle.getTotalDemand() - vehicle.getCapacity())
.asConstraint(VEHICLE_CAPACITY);
}
@@ -68,14 +68,14 @@ public class VehicleRoutingConstraintProvider implements ConstraintProvider {
protected Constraint serviceFinishedAfterMaxEndTime(ConstraintFactory factory) {
return factory.forEach(Visit.class)
.filter(Visit::isServiceFinishedAfterMaxEndTime)
- .penalizeLong(HardSoftScore.ONE_HARD,
+ .penalize(HardSoftScore.ONE_HARD,
Visit::getServiceFinishedDelayInMinutes)
.asConstraint(SERVICE_FINISHED_AFTER_MAX_END_TIME);
}
protected Constraint minimizeTravelTime(ConstraintFactory factory) {
return factory.forEach(Vehicle.class)
- .penalizeLong(HardSoftScore.ONE_SOFT,
+ .penalize(HardSoftScore.ONE_SOFT,
Vehicle::getTotalDrivingTimeSeconds)
.asConstraint(MINIMIZE_TRAVEL_TIME);
}
@@ -110,26 +110,26 @@ class VehicleRoutingConstraintProvider : ConstraintProvider {
)
}
- protected fun vehicleCapacity(factory: ConstraintFactory): Constraint {
+ fun vehicleCapacity(factory: ConstraintFactory): Constraint {
return factory.forEach(Vehicle::class.java)
.filter({ vehicle: Vehicle -> vehicle.totalDemand > vehicle.capacity })
- .penalizeLong(
+ .penalize(
HardSoftScore.ONE_HARD
) { vehicle: Vehicle -> vehicle.totalDemand - vehicle.capacity }
.asConstraint(VEHICLE_CAPACITY)
}
- protected fun serviceFinishedAfterMaxEndTime(factory: ConstraintFactory): Constraint {
+ fun serviceFinishedAfterMaxEndTime(factory: ConstraintFactory): Constraint {
return factory.forEach(Visit::class.java)
.filter({ obj: Visit -> obj.isServiceFinishedAfterMaxEndTime })
- .penalizeLong(HardSoftScore.ONE_HARD,
+ .penalize(HardSoftScore.ONE_HARD,
{ obj: Visit -> obj.serviceFinishedDelayInMinutes })
.asConstraint(SERVICE_FINISHED_AFTER_MAX_END_TIME)
}
- protected fun minimizeTravelTime(factory: ConstraintFactory): Constraint {
+ fun minimizeTravelTime(factory: ConstraintFactory): Constraint {
return factory.forEach(Vehicle::class.java)
- .penalizeLong(HardSoftScore.ONE_SOFT,
+ .penalize(HardSoftScore.ONE_SOFT,
{ obj: Vehicle -> obj.totalDrivingTimeSeconds })
.asConstraint(MINIMIZE_TRAVEL_TIME)
}
diff --git a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-model.adoc b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-model.adoc
index 8f305fa201d..e75d87694ca 100644
--- a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-model.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-model.adoc
@@ -12,6 +12,7 @@ image::quickstart/vehicle-routing/vehicleRoutingClassDiagramPure.png[]
The `Location` class is used to represent the destination for deliveries or the home location for vehicles.
The `drivingTimeSeconds` map contains the time required to drive from `this` location to any other location.
This field will be initialized later.
+In JSON, a location is a `[latitude, longitude]` array, and the `drivingTimeSeconds` map is left out.
[tabs]
====
@@ -26,14 +27,24 @@ package org.acme.vehiclerouting.domain;
import java.util.Map;
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+
+@JsonFormat(shape = JsonFormat.Shape.ARRAY)
+@JsonPropertyOrder({ "latitude", "longitude" })
public class Location {
private double latitude;
private double longitude;
+ @JsonIgnore
private Map drivingTimeSeconds;
- public Location(double latitude, double longitude) {
+ @JsonCreator
+ public Location(@JsonProperty("latitude") double latitude, @JsonProperty("longitude") double longitude) {
this.latitude = latitude;
this.longitude = longitude;
}
@@ -70,7 +81,19 @@ Create the `src/main/kotlin/org/acme/vehiclerouting/domain/Location.kt` class:
----
package org.acme.vehiclerouting.domain
-class Location @JsonCreator constructor(val latitude: Double, val longitude: Double) {
+import com.fasterxml.jackson.annotation.JsonCreator
+import com.fasterxml.jackson.annotation.JsonFormat
+import com.fasterxml.jackson.annotation.JsonIgnore
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.annotation.JsonPropertyOrder
+
+@JsonFormat(shape = JsonFormat.Shape.ARRAY)
+@JsonPropertyOrder("latitude", "longitude")
+class Location @JsonCreator constructor(
+ @JsonProperty("latitude") val latitude: Double,
+ @JsonProperty("longitude") val longitude: Double) {
+
+ @JsonIgnore
var drivingTimeSeconds: Map? = null
fun getDrivingTimeTo(location: Location): Long {
@@ -124,6 +147,11 @@ import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
import ai.timefold.solver.core.api.domain.common.PlanningId;
import ai.timefold.solver.core.api.domain.variable.PlanningListVariable;
+import com.fasterxml.jackson.annotation.JsonIdentityInfo;
+import com.fasterxml.jackson.annotation.JsonIdentityReference;
+import com.fasterxml.jackson.annotation.ObjectIdGenerators;
+
+@JsonIdentityInfo(scope = Vehicle.class, generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
@PlanningEntity
public class Vehicle {
@@ -134,8 +162,9 @@ public class Vehicle {
private LocalDateTime departureTime;
+ @JsonIdentityReference(alwaysAsId = true)
@PlanningListVariable
- private List visits;
+ private List visits = new ArrayList<>();
public Vehicle() {
}
@@ -145,30 +174,33 @@ public class Vehicle {
this.capacity = capacity;
this.homeLocation = homeLocation;
this.departureTime = departureTime;
- this.visits = new ArrayList<>();
}
// Getters and Setters excluded
public int getTotalDemand() {
- if (visits.isEmpty()) {
- return 0;
+ int totalDemand = 0;
+ for (Visit visit : visits) {
+ totalDemand += visit.getDemand();
}
- Visit lastVisit = visits.get(visits.size() - 1);
- Integer cumulativeDemand = lastVisit.getCumulativeDemand();
- return cumulativeDemand == null ? 0 : cumulativeDemand;
+ return totalDemand;
}
public long getTotalDrivingTimeSeconds() {
if (visits.isEmpty()) {
return 0;
}
- Visit lastVisit = visits.get(visits.size() - 1);
- Long cumulativeDrivingTime = lastVisit.getCumulativeDrivingTimeSeconds();
- if (cumulativeDrivingTime == null) {
- return 0;
+
+ long totalDrivingTime = 0;
+ Location previousLocation = homeLocation;
+
+ for (Visit visit : visits) {
+ totalDrivingTime += previousLocation.getDrivingTimeTo(visit.getLocation());
+ previousLocation = visit.getLocation();
}
- return cumulativeDrivingTime + lastVisit.getLocation().getDrivingTimeTo(homeLocation);
+ totalDrivingTime += previousLocation.getDrivingTimeTo(homeLocation);
+
+ return totalDrivingTime;
}
@Override
@@ -189,12 +221,16 @@ Create the `src/main/kotlin/org/acme/vehiclerouting/domain/Vehicle.kt` class:
package org.acme.vehiclerouting.domain
import java.time.LocalDateTime
-import java.util.ArrayList
import ai.timefold.solver.core.api.domain.entity.PlanningEntity
import ai.timefold.solver.core.api.domain.common.PlanningId
import ai.timefold.solver.core.api.domain.variable.PlanningListVariable
+import com.fasterxml.jackson.annotation.JsonIdentityInfo
+import com.fasterxml.jackson.annotation.JsonIdentityReference
+import com.fasterxml.jackson.annotation.ObjectIdGenerators
+
+@JsonIdentityInfo(scope = Vehicle::class, generator = ObjectIdGenerators.PropertyGenerator::class, property = "id")
@PlanningEntity
class Vehicle {
@PlanningId
@@ -203,8 +239,9 @@ class Vehicle {
lateinit var homeLocation: Location
lateinit var departureTime: LocalDateTime
+ @JsonIdentityReference(alwaysAsId = true)
@PlanningListVariable
- var visits: List? = null
+ var visits: MutableList = ArrayList()
constructor()
@@ -213,13 +250,12 @@ class Vehicle {
this.capacity = capacity
this.homeLocation = homeLocation
this.departureTime = departureTime
- this.visits = ArrayList()
}
val totalDemand: Long
get() {
var totalDemand = 0L
- for (visit in visits!!) {
+ for (visit in visits) {
totalDemand += visit.demand
}
return totalDemand
@@ -227,16 +263,16 @@ class Vehicle {
val totalDrivingTimeSeconds: Long
get() {
- if (visits!!.isEmpty()) {
+ if (visits.isEmpty()) {
return 0
}
var totalDrivingTime: Long = 0
var previousLocation = homeLocation
- for (visit in visits!!) {
- totalDrivingTime += previousLocation.getDrivingTimeTo(visit.location!!)
- previousLocation = visit.location!!
+ for (visit in visits) {
+ totalDrivingTime += previousLocation.getDrivingTimeTo(visit.location)
+ previousLocation = visit.location
}
totalDrivingTime += previousLocation.getDrivingTimeTo(homeLocation)
@@ -289,13 +325,15 @@ import java.time.LocalDateTime;
import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
import ai.timefold.solver.core.api.domain.common.PlanningId;
+import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable;
import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable;
-import ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable;
import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable;
-import ai.timefold.solver.core.api.domain.variable.ShadowVariable;
-import org.acme.vehiclerouting.solver.ArrivalTimeUpdatingVariableListener;
+import com.fasterxml.jackson.annotation.JsonIdentityInfo;
+import com.fasterxml.jackson.annotation.JsonIdentityReference;
+import com.fasterxml.jackson.annotation.ObjectIdGenerators;
+@JsonIdentityInfo(scope = Visit.class, generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
@PlanningEntity
public class Visit {
@@ -308,9 +346,11 @@ public class Visit {
private LocalDateTime maxEndTime;
private Duration serviceDuration;
+ @JsonIdentityReference(alwaysAsId = true)
@InverseRelationShadowVariable(sourceVariableName = "visits")
private Vehicle vehicle;
+ @JsonIdentityReference(alwaysAsId = true)
@PreviousElementShadowVariable(sourceVariableName = "visits")
private Visit previousVisit;
@@ -333,7 +373,7 @@ public class Visit {
// Getters and Setters excluded
- private void updateArrivalTime() {
+ public void updateArrivalTime() {
if (previousVisit == null && vehicle == null) {
arrivalTime = null;
return;
@@ -401,13 +441,15 @@ import java.time.LocalDateTime
import ai.timefold.solver.core.api.domain.entity.PlanningEntity
import ai.timefold.solver.core.api.domain.common.PlanningId
+import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable
import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable
-import ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable
import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable
-import ai.timefold.solver.core.api.domain.variable.ShadowVariable
-import org.acme.vehiclerouting.solver.ArrivalTimeUpdatingVariableListener
+import com.fasterxml.jackson.annotation.JsonIdentityInfo
+import com.fasterxml.jackson.annotation.JsonIdentityReference
+import com.fasterxml.jackson.annotation.ObjectIdGenerators
+@JsonIdentityInfo(scope = Visit::class, generator = ObjectIdGenerators.PropertyGenerator::class, property = "id")
@PlanningEntity
class Visit {
@PlanningId
@@ -419,9 +461,11 @@ class Visit {
lateinit var maxEndTime: LocalDateTime
lateinit var serviceDuration: Duration
+ @JsonIdentityReference(alwaysAsId = true)
@InverseRelationShadowVariable(sourceVariableName = "visits")
- private var vehicle: Vehicle? = null
+ var vehicle: Vehicle? = null
+ @JsonIdentityReference(alwaysAsId = true)
@PreviousElementShadowVariable(sourceVariableName = "visits")
var previousVisit: Visit? = null
@@ -443,13 +487,13 @@ class Visit {
this.serviceDuration = serviceDuration
}
- private fun updateArrivalTime() {
+ fun updateArrivalTime() {
if (previousVisit == null && vehicle == null) {
arrivalTime = null
return
}
val departureTime = previousVisit?.departureTime ?: vehicle?.departureTime
- arrivalTime = departureTime?.plusSeconds(getDrivingTimeSecondsFromPreviousStandstill())
+ arrivalTime = departureTime?.plusSeconds(drivingTimeSecondsFromPreviousStandstill)
}
val departureTime: LocalDateTime?
@@ -524,3 +568,7 @@ NOTE: `@NextElementShadowVariable` also exists, which can be used to get a refer
The `arrivalTime` field has a `@CascadingUpdateShadowVariable` annotation.
This annotation indicates which method should be triggered to update this field whenever this entity is moved, in this case the `updateArrivalTime()` method.
This change is automatically propagated to the subsequent visits and stops when the `arrivalTime` value hasn't changed or when it's reached the end of the chain of visit objects.
+
+The Jackson `@JsonIdentityInfo` and `@JsonIdentityReference` annotations on `Vehicle` and `Visit`
+serialize the `visits`, `vehicle`, and `previousVisit` references as IDs.
+Without them, a visit and its vehicle reference each other, so the JSON response never ends.
diff --git a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-solution.adoc b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-solution.adoc
index a9e80b126f4..61c263969f2 100644
--- a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-solution.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-solution.adoc
@@ -31,7 +31,6 @@ 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.valuerange.ValueRangeProvider;
import ai.timefold.solver.core.api.score.HardSoftScore;
-import ai.timefold.solver.core.api.solver.SolverStatus;
import org.acme.vehiclerouting.domain.geo.DrivingTimeCalculator;
import org.acme.vehiclerouting.domain.geo.HaversineDrivingTimeCalculator;
@@ -60,8 +59,12 @@ public class VehicleRoutePlan {
this.name = name;
this.vehicles = vehicles;
this.visits = visits;
+ initDrivingTimeMaps();
+ }
- // Enhance locations with a pre-calculated driving time map
+ // Enhance locations with a pre-calculated driving time map.
+ // Jackson uses the no-arg constructor, so a deserialized plan must call this method before solving.
+ public void initDrivingTimeMaps() {
List locations = Stream.concat(
vehicles.stream().map(Vehicle::getHomeLocation),
visits.stream().map(Visit::getLocation)).toList();
@@ -84,7 +87,6 @@ Create the `src/main/kotlin/org/acme/vehiclerouting/domain/VehicleRoutePlan.kt`
----
package org.acme.vehiclerouting.domain;
-import java.time.LocalDateTime
import java.util.stream.Stream
import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty
@@ -92,7 +94,6 @@ 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.valuerange.ValueRangeProvider
import ai.timefold.solver.core.api.score.HardSoftScore
-import ai.timefold.solver.core.api.solver.SolverStatus
import org.acme.vehiclerouting.domain.geo.DrivingTimeCalculator
import org.acme.vehiclerouting.domain.geo.HaversineDrivingTimeCalculator
@@ -125,11 +126,15 @@ class VehicleRoutePlan {
this.name = name
this.vehicles = vehicles
this.visits = visits
+ initDrivingTimeMaps()
+ }
- // Enhance locations with a pre-calculated driving time map
+ // Enhance locations with a pre-calculated driving time map.
+ // Jackson uses the no-arg constructor, so a deserialized plan must call this method before solving.
+ fun initDrivingTimeMaps() {
val locations = Stream.concat(
- vehicles.stream().map({ obj: Vehicle -> obj.homeLocation }),
- visits.stream().map({ obj: Visit -> obj.location })
+ vehicles!!.stream().map({ obj: Vehicle -> obj.homeLocation }),
+ visits!!.stream().map({ obj: Visit -> obj.location })
).toList()
val drivingTimeCalculator: DrivingTimeCalculator = HaversineDrivingTimeCalculator.INSTANCE
diff --git a/docs/src/modules/ROOT/pages/quickstart/quarkus/quarkus-quickstart.adoc b/docs/src/modules/ROOT/pages/quickstart/quarkus/quarkus-quickstart.adoc
index 051d8f32720..ba4a6776521 100644
--- a/docs/src/modules/ROOT/pages/quickstart/quarkus/quarkus-quickstart.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/quarkus/quarkus-quickstart.adoc
@@ -11,6 +11,7 @@ include::../../_attributes.adoc[]
:testannotation: @QuarkusTest
:testannotationfq: io.quarkus.test.junit.QuarkusTest
:framework-quickstart-url: {quarkus-quickstart-url}
+:framework-kotlin-quickstart-url: {quarkus-kotlin-quickstart-url}
// Keep this in sync with the quarkus repo's copy
// https://github.com/quarkusio/quarkus/blob/main/docs/src/main/asciidoc/timefold.adoc
@@ -81,13 +82,13 @@ import ai.timefold.solver.core.api.solver.SolverManager;
public class TimetableResource {
@Inject
- SolverManager solverManager;
+ SolverManager solverManager;
@POST
public Timetable solve(Timetable problem) {
UUID problemId = UUID.randomUUID();
// Submit the problem to start solving
- SolverJob solverJob = solverManager.solve(problemId, problem);
+ SolverJob solverJob = solverManager.solve(problemId, problem);
Timetable solution;
try {
// Wait until the solving ends
@@ -110,41 +111,25 @@ Create the `src/main/kotlin/org/acme/schooltimetabling/rest/TimetableResource.kt
----
package org.acme.schooltimetabling.rest
-import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis
-import ai.timefold.solver.core.api.score.HardSoftScore
-import ai.timefold.solver.core.api.solver.ScoreAnalysisFetchPolicy
-import ai.timefold.solver.core.api.solver.SolutionManager
import ai.timefold.solver.core.api.solver.SolverManager
import jakarta.inject.Inject
-import jakarta.ws.rs.Consumes
-import jakarta.ws.rs.DELETE
-import jakarta.ws.rs.GET
import jakarta.ws.rs.POST
-import jakarta.ws.rs.PUT
import jakarta.ws.rs.Path
-import jakarta.ws.rs.PathParam
-import jakarta.ws.rs.Produces
-import jakarta.ws.rs.QueryParam
-import jakarta.ws.rs.core.MediaType
-import jakarta.ws.rs.core.Response
import org.acme.schooltimetabling.domain.Timetable
-import org.acme.schooltimetabling.rest.exception.ErrorInfo
-import org.acme.schooltimetabling.rest.exception.TimetableSolverException
-import org.slf4j.Logger
-import org.slf4j.LoggerFactory
-import java.util.*
+import java.util.UUID
+import java.util.concurrent.ExecutionException
-@Path("timetables")
+@Path("/timetables")
class TimetableResource {
@Inject
- private final var solverManager: SolverManager?
+ lateinit var solverManager: SolverManager
@POST
- fun solve(problem: Timetable?): Timetable {
+ fun solve(problem: Timetable): Timetable {
val problemId = UUID.randomUUID()
// Submit the problem to start solving
- val solverJob = solverManager!!.solve(problemId, problem)
+ val solverJob = solverManager.solve(problemId, problem)
val solution: Timetable
try {
// Wait until the solving ends
@@ -260,40 +245,30 @@ In a JUnit test, generate a test dataset and send it to the `TimetableResource`
Java::
+
--
+Save the JSON request body of the earlier `curl` command as the `src/test/resources/testing-timetable.json` file.
+
Create the `src/test/java/org/acme/schooltimetabling/rest/TimetableResourceTest.java` class:
[source,java]
----
package org.acme.schooltimetabling.rest;
-import static io.restassured.RestAssured.get;
import static io.restassured.RestAssured.given;
-import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import jakarta.inject.Singleton;
-
-import ai.timefold.solver.core.api.score.analysis.ScoreAnalysis;
-import ai.timefold.solver.core.api.score.HardSoftScore;
-import ai.timefold.solver.core.api.score.constraint.ConstraintRef;
-import ai.timefold.solver.core.api.score.stream.ConstraintJustification;
-import ai.timefold.solver.core.api.solver.SolverStatus;
-import ai.timefold.solver.jackson.api.score.analysis.AbstractScoreAnalysisJacksonDeserializer;
+import java.io.File;
+import java.io.IOException;
import org.acme.schooltimetabling.domain.Timetable;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.databind.json.JsonMapper;
-import io.quarkus.jackson.ObjectMapperCustomizer;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
-import java.io.IOException;
-import java.io.File;
-
@QuarkusTest
class TimetableResourceTest {
@@ -303,10 +278,9 @@ class TimetableResourceTest {
ObjectMapper objectMapper = JsonMapper.builder()
.findAndAddModules()
.build();
- Timetable testTimetable = objectMapper
-.readValue(
- new File("src/test/resources/testing-timetable.json"),
- Timetable.class);
+ Timetable testTimetable = objectMapper.readValue(
+ new File("src/test/resources/testing-timetable.json"),
+ Timetable.class);
Timetable solution = given()
.contentType(ContentType.JSON)
@@ -338,43 +312,52 @@ Create the `src/test/kotlin/org/acme/schooltimetabling/rest/TimetableResourceTes
----
package org.acme.schooltimetabling.rest
-import ai.timefold.solver.core.api.solver.SolverStatus
import io.quarkus.test.junit.QuarkusTest
-import io.restassured.RestAssured.get
import io.restassured.RestAssured.given
import io.restassured.http.ContentType
+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.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
-import java.time.Duration
+import java.time.DayOfWeek
+import java.time.LocalTime
@QuarkusTest
class TimetableResourceTest {
@Test
fun solveDemoDataUntilFeasible() {
- val testTimetable: Timetable = // setup test data
+ // Provide the same input data shown in the text above.
+ val testTimetable = Timetable(
+ listOf(
+ Timeslot(DayOfWeek.MONDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)),
+ Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 30), LocalTime.of(10, 30))),
+ listOf(Room("Room A"), Room("Room B")),
+ listOf(
+ Lesson("1", "Math", "A. Turing", "9th grade"),
+ Lesson("2", "Chemistry", "M. Curie", "9th grade"),
+ Lesson("3", "French", "M. Curie", "10th grade"),
+ Lesson("4", "History", "I. Jones", "10th grade")))
val solution: Timetable = given()
.contentType(ContentType.JSON)
.body(testTimetable)
- .expect().contentType(ContentType.TEXT)
+ .expect().contentType(ContentType.JSON)
.`when`().post("/timetables")
.then()
.statusCode(200)
.extract()
- .as(Timetable.class);
+ .`as`(Timetable::class.java)
assertNotNull(solution.lessons)
assertNotNull(solution.timeslots)
assertNotNull(solution.rooms)
- assertNotNull(solution.lessons.get(0).room)
- assertNotNull(solution.lessons.get(0).timeslot)
- assertTrue(solution.score?.isFeasible!!)
+ assertNotNull(solution.lessons[0].room)
+ assertNotNull(solution.lessons[0].timeslot)
+ assertTrue(solution.score!!.isFeasible)
}
}
@@ -441,5 +424,5 @@ You have just developed a Quarkus application with https://timefold.ai[Timefold]
Next Steps:
-- For a full implementation with a web UI and in-memory storage, check out {quarkus-quickstart-url}[the Quarkus quickstart source code].
+- For a full implementation with a web UI and in-memory storage, check out {quarkus-quickstart-url}[the Quarkus quickstart source code] (Java) or {quarkus-kotlin-quickstart-url}[the Kotlin version].
- Check out more information about Timefold's integration with Quarkus in our xref:running-timefold-solver/library/quarkus.adoc#integrationWithQuarkus[Integration documentation].
diff --git a/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc b/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc
index de6d59fb982..72cef14d308 100644
--- a/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/service/getting-started.adoc
@@ -67,6 +67,7 @@ Your `pom.xml` file has the following content:
1.0.0-SNAPSHOT
+ {java-version}
@@ -175,7 +176,7 @@ import ai.timefold.solver.core.api.score.HardMediumSoftScore
import ai.timefold.solver.service.definition.api.AbstractSimpleModel
@PlanningSolution
-class Timetable : AbstractSimpleModel() {
+class Timetable() : AbstractSimpleModel() {
@ValueRangeProvider
@ProblemFactCollectionProperty
@@ -188,9 +189,7 @@ class Timetable : AbstractSimpleModel() {
@PlanningEntityCollectionProperty
var lessons: List = emptyList()
- constructor()
-
- constructor(timeslots: List, rooms: List, lessons: List) {
+ constructor(timeslots: List, rooms: List, lessons: List) : this() {
this.timeslots = timeslots
this.rooms = rooms
this.lessons = lessons
@@ -251,6 +250,7 @@ timefold.model.termination.spent-limit=PT30S
# Application metadata — included in the generated OpenAPI specification
timefold.model.name=my-model
+timefold.model.api-version=v1
timefold.model.contact.email=info@example.com
timefold.model.contact.name=Your Name
timefold.model.contact.url=https://example.com
@@ -262,7 +262,9 @@ For production use, consider at least five minutes (`PT5M`) to allow the solver
NOTE: The per-request `config.run.termination.spentLimit` field in the POST body (shown later) overrides this default for individual runs.
-`timefold.model.name` and the contact fields are *required* metadata.
+`timefold.model.name`, `timefold.model.api-version` and the contact fields are *required* metadata.
+Use a version such as `v1` for `timefold.model.api-version`.
+It can only contain letters, numbers, hyphens (`-`) and underscores (`_`), so a value such as `1.0.0` fails the build.
They are used to identify your service, validate the JSON schema of requests, and populate the generated OpenAPI specification.
== REST API
@@ -298,8 +300,8 @@ public interface TimetableResource extends ModelRest {
This is the most basic version of the interface. The SDK will automatically create multiple xref:running-timefold-solver/service/rest-api.adoc[REST API endpoints] for optimization actions.
The `@Path` annotation is used to configure the base path of all those endpoints.
-NOTE: Do not include an API version in `@Path`. The SDK default configuration sets `quarkus.rest.path=${timefold.model.version}`, which Quarkus automatically prepends to every `@Path` at runtime.
-So with `timefold.model.version=v1`, `@Path("/timetables")` is served at `/v1/timetables` — locally and on the platform.
+NOTE: Do not include an API version in `@Path`. The SDK default configuration sets `quarkus.rest.path=${timefold.model.api-version}`, which Quarkus automatically prepends to every `@Path` at runtime.
+So with `timefold.model.api-version=v1`, `@Path("/timetables")` is served at `/v1/timetables` — locally and on the platform.
== Running and manually testing the model
diff --git a/docs/src/modules/ROOT/pages/quickstart/shared/_constrainttests.adoc b/docs/src/modules/ROOT/pages/quickstart/shared/_constrainttests.adoc
index 5437c95a7f4..0cc7b2d2ab3 100644
--- a/docs/src/modules/ROOT/pages/quickstart/shared/_constrainttests.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/shared/_constrainttests.adoc
@@ -70,19 +70,19 @@ import java.time.LocalTime
{testannotation}
class TimetableConstraintProviderTest {
- val ROOM1: Room = Room(1, "Room1")
- private val TIMESLOT1: Timeslot = Timeslot(1, DayOfWeek.MONDAY, LocalTime.NOON)
- private val TIMESLOT2: Timeslot = Timeslot(2, DayOfWeek.TUESDAY, LocalTime.NOON)
+ private val ROOM1: Room = Room("Room1")
+ private val TIMESLOT1: Timeslot = Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.NOON)
+ private val TIMESLOT2: Timeslot = Timeslot(DayOfWeek.TUESDAY, LocalTime.of(9, 0), LocalTime.NOON)
{wiringannotation}
- lateinit var constraintVerifier: ConstraintVerifier
+ lateinit var constraintVerifier: ConstraintVerifier
@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(TimeTableConstraintProvider::roomConflict)
+ val firstLesson = Lesson("1", "Subject1", "Teacher1", "Group1").apply { timeslot = TIMESLOT1; room = ROOM1 }
+ val conflictingLesson = Lesson("2", "Subject2", "Teacher2", "Group2").apply { timeslot = TIMESLOT1; room = ROOM1 }
+ val nonConflictingLesson = Lesson("3", "Subject3", "Teacher3", "Group3").apply { timeslot = TIMESLOT2; room = ROOM1 }
+ constraintVerifier.verifyThat(TimetableConstraintProvider::roomConflict)
.given(firstLesson, conflictingLesson, nonConflictingLesson)
.penalizesBy(1)
}
diff --git a/docs/src/modules/ROOT/pages/quickstart/shared/_java-logback-config.adoc b/docs/src/modules/ROOT/pages/quickstart/shared/_java-logback-config.adoc
index 8073a7ae4d0..fe79a83c20d 100644
--- a/docs/src/modules/ROOT/pages/quickstart/shared/_java-logback-config.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/shared/_java-logback-config.adoc
@@ -1,4 +1,4 @@
-Create the `src/main/resource/logback.xml` file:
+Create the `src/main/resources/logback.xml` file:
[source,xml]
----
diff --git a/docs/src/modules/ROOT/pages/quickstart/shared/_solutionsourcecode.adoc b/docs/src/modules/ROOT/pages/quickstart/shared/_solutionsourcecode.adoc
index 80aff075565..7c268fccde0 100644
--- a/docs/src/modules/ROOT/pages/quickstart/shared/_solutionsourcecode.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/shared/_solutionsourcecode.adoc
@@ -2,4 +2,9 @@
Follow the instructions in the next sections to create the application step by step (recommended).
-In case you get stuck during this Getting Started guide, it might be worth checking the solution project {framework-quickstart-url}[on GitHub].
\ No newline at end of file
+ifdef::framework-kotlin-quickstart-url[]
+In case you get stuck during this Getting Started guide, it might be worth checking the solution project on GitHub, for {framework-quickstart-url}[Java] or {framework-kotlin-quickstart-url}[Kotlin].
+endif::[]
+ifndef::framework-kotlin-quickstart-url[]
+In case you get stuck during this Getting Started guide, it might be worth checking the solution project {framework-quickstart-url}[on GitHub].
+endif::[]
diff --git a/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-constraints.adoc b/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-constraints.adoc
index a2015805935..d18eebf30e4 100644
--- a/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-constraints.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-constraints.adoc
@@ -99,18 +99,16 @@ Create a `src/main/kotlin/org/acme/schooltimetabling/solver/TimetableConstraintP
[source,kotlin,subs=attributes+]
----
-package org.acme.kotlin.schooltimetabling.solver
+package org.acme.schooltimetabling.solver
import ai.timefold.solver.core.api.score.{score-class}
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
+import org.acme.schooltimetabling.domain.Lesson
-class TimeTableConstraintProvider : ConstraintProvider {
+class TimetableConstraintProvider : ConstraintProvider {
override fun defineConstraints(constraintFactory: ConstraintFactory): Array {
return arrayOf(
@@ -118,10 +116,7 @@ class TimeTableConstraintProvider : ConstraintProvider {
roomConflict(constraintFactory),
teacherConflict(constraintFactory),
studentGroupConflict(constraintFactory),
- // Soft constraints
- teacherRoomStability(constraintFactory),
- teacherTimeEfficiency(constraintFactory),
- studentGroupSubjectVariety(constraintFactory)
+ // Soft constraints are only implemented in the timefold-quickstarts code
)
}
@@ -138,8 +133,6 @@ class TimeTableConstraintProvider : ConstraintProvider {
)
// ... and penalize each pair with a hard weight.
.penalize({score-class}.ONE_HARD)
- .justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
- RoomConflictJustification(lesson1.room, lesson1,lesson2)}
.asConstraint("Room conflict")
}
@@ -152,8 +145,6 @@ class TimeTableConstraintProvider : ConstraintProvider {
Joiners.equal(Lesson::teacher)
)
.penalize({score-class}.ONE_HARD)
- .justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
- TeacherConflictJustification(lesson1.teacher, lesson1, lesson2)}
.asConstraint("Teacher conflict")
}
@@ -166,66 +157,9 @@ class TimeTableConstraintProvider : ConstraintProvider {
Joiners.equal(Lesson::studentGroup)
)
.penalize({score-class}.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({score-class}.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({score-class}.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({score-class}.ONE_SOFT)
- .justifyWith { lesson1: Lesson, lesson2: Lesson, _ ->
- StudentGroupSubjectVarietyJustification(lesson1.studentGroup, lesson1, lesson2)}
- .asConstraint("Student group subject variety")
- }
-
}
----
--
diff --git a/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-model.adoc b/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-model.adoc
index db2d27e224d..4f8bda80a8e 100644
--- a/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-model.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-model.adoc
@@ -205,7 +205,7 @@ public class Lesson {
}
// This constructor is only for tests
- Lesson(String id, String subject, String teacher, String studentGroup, Timeslot timeslot, Room room) {
+ public Lesson(String id, String subject, String teacher, String studentGroup, Timeslot timeslot, Room room) {
this(id, subject, teacher, studentGroup);
this.timeslot = timeslot;
this.room = room;
diff --git a/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-solution.adoc b/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-solution.adoc
index 30f1660589d..17d01a77691 100644
--- a/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-solution.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/shared/school-timetabling/_school-timetabling-solution.adoc
@@ -71,6 +71,10 @@ public class Timetable {
return score;
}
+ public void setScore(HardSoftScore score) {
+ this.score = score;
+ }
+
}
----
--
@@ -78,7 +82,7 @@ public class Timetable {
Kotlin::
+
--
-Create the `src/main/kotlin/org/acme/schooltimetabling/TimetableApp.kt` class:
+Create the `src/main/kotlin/org/acme/schooltimetabling/domain/Timetable.kt` class:
[source,kotlin]
----
@@ -90,7 +94,6 @@ 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.HardSoftScore
-import ai.timefold.solver.core.api.solver.SolverStatus
@PlanningSolution
data class Timetable (
diff --git a/docs/src/modules/ROOT/pages/quickstart/spring-boot/spring-boot-quickstart.adoc b/docs/src/modules/ROOT/pages/quickstart/spring-boot/spring-boot-quickstart.adoc
index b3a2a45782d..662cc4df8ce 100644
--- a/docs/src/modules/ROOT/pages/quickstart/spring-boot/spring-boot-quickstart.adoc
+++ b/docs/src/modules/ROOT/pages/quickstart/spring-boot/spring-boot-quickstart.adoc
@@ -9,7 +9,7 @@ include::../../_attributes.adoc[]
:framework: spring-boot
:wiringannotation: @Autowired
:wiringannotationfq: org.springframework.beans.factory.annotation.Autowired
-:testannotation: @SpringTest
+:testannotation: @SpringBootTest
:testannotationfq: org.springframework.boot.test.context.SpringBootTest
:framework-quickstart-url: {spring-boot-quickstart-url}
@@ -33,6 +33,37 @@ Use https://start.spring.io/[start.spring.io] to generate an application with th
* Spring Web (`spring-boot-starter-web`)
* Timefold Solver (`timefold-solver-spring-boot-starter`)
+The tests in this guide also use https://rest-assured.io/[REST Assured],
+which Spring Boot does not manage.
+Add it to your build file:
+
+[tabs]
+====
+Maven::
++
+--
+Add the subsequent dependency in your `pom.xml`:
+[source,xml]
+----
+
+ io.rest-assured
+ rest-assured
+ 6.3.0
+ test
+
+----
+--
+Gradle::
++
+--
+Add the subsequent dependency to your `build.gradle`:
+[source,groovy]
+----
+ testImplementation "io.rest-assured:rest-assured:6.3.0"
+----
+--
+====
+
include::../shared/school-timetabling/_school-timetabling-model.adoc[leveloffset=+1]
include::../shared/school-timetabling/_school-timetabling-constraints.adoc[leveloffset=+1]
include::../shared/school-timetabling/_school-timetabling-solution.adoc[leveloffset=+1]
@@ -73,13 +104,13 @@ import org.springframework.web.bind.annotation.RestController;
public class TimetableController {
@Autowired
- private SolverManager solverManager;
+ private SolverManager solverManager;
@PostMapping
public Timetable solve(@RequestBody Timetable problem) {
UUID problemId = UUID.randomUUID();
// Submit the problem to start solving
- SolverJob solverJob = solverManager.solve(problemId, problem);
+ SolverJob solverJob = solverManager.solve(problemId, problem);
Timetable solution;
try {
// Wait until the solving ends
@@ -115,7 +146,7 @@ import java.util.concurrent.ExecutionException
@RequestMapping("/timetable")
class TimetableController {
@Autowired
- private val solverManager: SolverManager? = null
+ private val solverManager: SolverManager? = null
@PostMapping
fun solve(@RequestBody problem: Timetable): Timetable {
@@ -200,12 +231,10 @@ import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
@SpringBootApplication
-open class TimetableSpringBootApp {
-
- fun main(args: Array) {
- SpringApplication.run(TimeTableSpringBootApp::class.java, *args)
- }
+open class TimetableSpringBootApp
+fun main(args: Array) {
+ SpringApplication.run(TimetableSpringBootApp::class.java, *args)
}
----
--
@@ -308,7 +337,7 @@ Kotlin::
----
package org.acme.schooltimetabling.config
-import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier;
+import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier
import org.acme.schooltimetabling.domain.Lesson
import org.acme.schooltimetabling.domain.Timetable
import org.acme.schooltimetabling.solver.TimetableConstraintProvider
@@ -347,15 +376,18 @@ Create the `src/test/java/org/acme/schooltimetabling/rest/TimetableControllerTes
----
package org.acme.schooltimetabling.rest;
-import ai.timefold.solver.core.api.solver.SolverStatus;
import io.restassured.http.ContentType;
+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 org.springframework.boot.test.context.SpringBootTest;
-import java.time.Duration;
+import java.time.DayOfWeek;
+import java.time.LocalTime;
+import java.util.List;
-import static io.restassured.RestAssured.get;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.*;
@@ -368,15 +400,25 @@ class TimetableControllerTest {
@Test
void solveDemoDataUntilFeasible() {
- Timetable testTimetable = // setup test data
+ // Provide the same input data shown in the text above.
+ Timetable testTimetable = new Timetable(
+ List.of(
+ new Timeslot(DayOfWeek.MONDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)),
+ new Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 30), LocalTime.of(10, 30))),
+ List.of(new Room("Room A"), new Room("Room B")),
+ List.of(
+ new Lesson("1", "Math", "A. Turing", "9th grade"),
+ new Lesson("2", "Chemistry", "M. Curie", "9th grade"),
+ new Lesson("3", "French", "M. Curie", "10th grade"),
+ new Lesson("4", "History", "I. Jones", "10th grade")));
Timetable solution = given()
.contentType(ContentType.JSON)
.body(testTimetable)
- .expect().contentType(ContentType.TEXT)
- .when().post("/timetables")
+ .expect().contentType(ContentType.JSON)
+ .when().post("/timetable")
.then()
- .statusCode(201)
+ .statusCode(200)
.extract()
.as(Timetable.class);
@@ -400,15 +442,17 @@ Create the `src/test/kotlin/org/acme/schooltimetabling/rest/TimetableControllerT
----
package org.acme.schooltimetabling.rest
-import ai.timefold.solver.core.api.solver.SolverStatus
import io.restassured.RestAssured
import io.restassured.http.ContentType
+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.awaitility.Awaitility
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
-import java.time.Duration
+import java.time.DayOfWeek
+import java.time.LocalTime
@SpringBootTest(
properties = [ // Effectively disable spent-time termination in favor of the best-score-limit
@@ -419,24 +463,34 @@ import java.time.Duration
internal class TimetableControllerTest {
@Test
fun solveDemoDataUntilFeasible() {
- val testTimetable = //setup test data
+ // Provide the same input data shown in the text above.
+ val testTimetable = Timetable(
+ listOf(
+ Timeslot(DayOfWeek.MONDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)),
+ Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 30), LocalTime.of(10, 30))),
+ listOf(Room("Room A"), Room("Room B")),
+ listOf(
+ Lesson("1", "Math", "A. Turing", "9th grade"),
+ Lesson("2", "Chemistry", "M. Curie", "9th grade"),
+ Lesson("3", "French", "M. Curie", "10th grade"),
+ Lesson("4", "History", "I. Jones", "10th grade")))
val solution = RestAssured.given()
.contentType(ContentType.JSON)
.body(testTimetable)
- .expect().contentType(ContentType.TEXT)
- .`when`().post("/timetables")
+ .expect().contentType(ContentType.JSON)
+ .`when`().post("/timetable")
.then()
- .statusCode(201)
+ .statusCode(200)
.extract()
- .as(Timetable.class)
+ .`as`(Timetable::class.java)
Assertions.assertNotNull(solution.lessons)
Assertions.assertNotNull(solution.timeslots)
Assertions.assertNotNull(solution.rooms)
Assertions.assertNotNull(solution.lessons[0].room)
Assertions.assertNotNull(solution.lessons[0].timeslot)
- Assertions.assertTrue(solution.score.isFeasible)
+ Assertions.assertTrue(solution.score!!.isFeasible)
}
}
----