diff --git a/docs/src/modules/ROOT/pages/_attributes.adoc b/docs/src/modules/ROOT/pages/_attributes.adoc
index f5ec1b01a2..841013153c 100644
--- a/docs/src/modules/ROOT/pages/_attributes.adoc
+++ b/docs/src/modules/ROOT/pages/_attributes.adoc
@@ -4,5 +4,5 @@
: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
+: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/quarkus-vehicle-routing/quarkus-vehicle-routing-quickstart.adoc b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/quarkus-vehicle-routing-quickstart.adoc
index d2059da4ee..c4ea014251 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
@@ -6,29 +6,34 @@
:icons: font
include::../../_attributes.adoc[]
-// Keep this in sync with the quarkus repo's copy
-// https://github.com/quarkusio/quarkus/blob/main/docs/src/main/asciidoc/timefold.adoc
-// Keep this also in sync with spring-boot-quickstart.adoc where applicable
-
-This guide walks you through the process of creating a Vehicle Routing application
-with https://quarkus.io/[Quarkus] and https://timefold.ai[Timefold]'s constraint solving Artificial Intelligence (AI).
+This guide walks you through the process of creating a Vehicle Routing optimization service
+with https://timefold.ai[Timefold]'s constraint solving Artificial Intelligence (AI).
+It builds on the xref:running-timefold-solver/service/overview.adoc#serviceOverview[service module]:
+you define the planning model and its API, and Timefold Solver takes care of the rest.
[TIP]
====
-https://docs.timefold.ai/field-service-routing/latest/introduction[Check out our off-the-shelf model for Field Service Routing] (REST API)
+https://docs.timefold.ai/field-service-routing/latest/introduction[Check out our off-the-shelf model for Field Service Routing] (REST API).
+It goes beyond basic vehicle routing and supports additional constraints such as priorities, skills, fairness and more.
====
+[sectnums!]
== What you will build
-You will build a REST application that optimizes a https://timefold.ai/vehicle-routing-problem[Vehicle Routing Problem] (VRP):
+You will build an optimization service that solves a https://timefold.ai/vehicle-routing-problem[Vehicle Routing Problem] (VRP)
+with capacities and time windows:
image::quickstart/vehicle-routing/vehicleRouteScreenshot.png[]
+Each vehicle leaves its own home location at a set time, drives a route of visits, and returns home.
Your service will assign `Visit` instances to `Vehicle` instances automatically
-by using AI to adhere to hard and soft scheduling _constraints_, such as the following examples:
+by using AI to adhere to hard, medium and soft _constraints_:
-* The demand for a vehicle cannot exceed its capacity.
-* The deliveries have specific deadlines that must be met.
+* The demand of the visits on a route cannot exceed the capacity of the vehicle.
+* A visit must be serviced before the end of its time window.
+A vehicle that arrives early waits.
+* As many visits as possible should be assigned to a vehicle.
+A visit that fits on no route is left unassigned rather than forced onto one.
* The less total travel time, the better.
Mathematically speaking, VRP is an _NP-hard_ problem.
@@ -38,6 +43,7 @@ for a non-trivial dataset, even on a supercomputer.
Luckily, AI constraint solvers such as Timefold Solver have advanced algorithms
that deliver a near-optimal solution in a reasonable amount of time.
+[sectnums!]
== Solution source code
Follow the instructions in the next sections to create the application step by step (recommended).
@@ -53,420 +59,161 @@ $ git clone {quickstarts-clone-url}
+
or download an {quickstarts-archive-url}[archive].
-. Find the solution in {vrp-quickstart-url}[the `java` directory]
+. Find the solution in {vrp-quickstart-url}[the `use-cases/vehicle-routing` directory]
and run it (see its README file).
+The complete example also includes a web UI, demo datasets, input validation, metrics and a recommendation endpoint.
+[sectnums!]
== Prerequisites
To complete this guide, you need:
-include::../shared/_java-prerequisites.adoc[]
+* **Tools**
+** JDK {java-version} or higher
+** Maven
+** An IDE of your choice (IntelliJ IDEA, VSCode, ...)
-== The build file and the dependencies
+* **Knowledge**
+** Java (Basic)
+** Quarkus (Basic)
-Use https://code.quarkus.io/?a=timefold-solver-quickstart&j=21&e=rest&e=rest-jackson&e=ai.timefold.solver%3Atimefold-solver-quarkus-jackson&e=ai.timefold.solver%3Atimefold-solver-quarkus[code.quarkus.io] to generate an application
-with the following extensions, for Maven or Gradle:
+If this is your first service, consider doing the xref:quickstart/service/getting-started.adoc[Getting started: building a service] guide first.
+It introduces the service module with a simpler model.
-[NOTE]
-====
-Clicking the link above will automatically select the dependencies for you on *code.quarkus.io*.
-====
+== The build file and the dependencies
-* Quarkus REST JAX-RS (`quarkus-rest`)
-* Quarkus REST Jackson (`quarkus-rest-jackson`)
-* Timefold Solver (`timefold-solver-quarkus`)
-* Timefold Solver Jackson (`timefold-solver-quarkus-jackson`)
+Create a Maven file that uses the service parent POM
+and depends on `timefold-solver-service-with-maps`.
+That dependency adds the map service, which provides the driving times between locations.
Your `pom.xml` file has the following content:
-[tabs]
-====
-Java::
-+
---
-[source,xml,subs=attributes+]
+[source,xml,subs=attributes+,options="nowrap"]
----
4.0.0
- org.acme
- vehicle-routing
- 1.0-SNAPSHOT
-
-
- 11
- UTF-8
-
- {quarkus-version}
- {timefold-solver-version}
-
-
-
-
-
- io.quarkus
-
- quarkus-bom
- ${version.io.quarkus}
- pom
- import
-
-
- ai.timefold.solver
- timefold-solver-bom
- ${version.ai.timefold.solver}
- pom
- import
-
-
-
-
-
- io.quarkus
- quarkus-rest
-
-
- io.quarkus
- quarkus-rest-jackson
-
-
- ai.timefold.solver
- timefold-solver-quarkus
-
-
- ai.timefold.solver
- timefold-solver-quarkus-jackson
-
-
-
-
-
-
- maven-compiler-plugin
- ${version.compiler.plugin}
-
-
- io.quarkus
- quarkus-maven-plugin
- ${version.io.quarkus}
- true
-
-
-
- build
-
-
-
-
-
- maven-surefire-plugin
-
-
- org.jboss.logmanager.LogManager
-
-
-
-
-
-
-----
---
-Kotlin::
-+
---
-[source,xml,subs=attributes+]
-----
-
-
- 4.0.0
+
+ ai.timefold.solver
+ timefold-solver-service-parent
+ {timefold-solver-version}
+ org.acmevehicle-routing
- 1.0-SNAPSHOT
+ ${revision}
- 11
- UTF-8
-
- {quarkus-version}
- {timefold-solver-version}
+ 1.0.0-SNAPSHOT
+ 21
-
-
-
- io.quarkus
-
- quarkus-bom
- ${version.io.quarkus}
- pom
- import
-
-
- ai.timefold.solver
- timefold-solver-bom
- ${version.ai.timefold.solver}
- pom
- import
-
-
-
-
- io.quarkus
- quarkus-rest
-
-
- io.quarkus
- quarkus-rest-jackson
- ai.timefold.solver
- timefold-solver-quarkus
+ timefold-solver-service-with-maps
+
ai.timefold.solver
- timefold-solver-quarkus-jackson
-
-
- org.jetbrains.kotlin
- kotlin-stdlib
- 1.9.22
+ timefold-solver-service-maps-service-test
+ test
-
-
- src/main/kotlin
- src/test/kotlin
-
-
- maven-compiler-plugin
- ${version.compiler.plugin}
-
-
- io.quarkus
- quarkus-maven-plugin
- ${version.io.quarkus}
- true
-
-
-
- build
-
-
-
-
-
- maven-surefire-plugin
-
-
- org.jboss.logmanager.LogManager
-
-
-
-
- org.jetbrains.kotlin
- kotlin-maven-plugin
- ${version.kotlin}
-
-
- compile
-
- compile
-
-
-
- test-compile
-
- test-compile
-
-
-
-
-
- org.jetbrains.kotlin
- kotlin-maven-allopen
- ${version.kotlin}
-
-
-
- true
- 21
-
- all-open
-
-
-
-
-
-
-
-
-
-
----
---
-====
+
+The parent POM brings in Quarkus, the REST layer, the OpenAPI tooling and the usual test libraries,
+so you do not need to declare them yourself.
include::vehicle-routing-model.adoc[leveloffset=+1]
include::vehicle-routing-constraints.adoc[leveloffset=+1]
include::vehicle-routing-solution.adoc[leveloffset=+1]
+include::vehicle-routing-api.adoc[leveloffset=+1]
-== Create the solver service
+== Expose the REST API
-Now you are ready to put everything together and create a REST service.
-But solving planning problems on REST threads causes HTTP timeout issues.
-Therefore, the Quarkus extension injects a `SolverManager` instance,
-which runs solvers in a separate thread pool
-and can solve multiple datasets in parallel.
+To expose the service, provide an interface which extends the `ModelRest` interface.
+The service module generates all the xref:running-timefold-solver/service/rest-api.adoc#generatedEndpoints[REST endpoints] from it:
+submitting a problem, polling for the solution, terminating a run early, score analysis and more.
-[tabs]
-====
-Java::
-+
---
-Create the `src/main/java/org/acme/vehiclerouting/rest/VehicleRoutePlanResource.java` class:
+Create the `src/main/java/org/acme/vehiclerouting/rest/VehicleRoutePlanResource.java` interface:
-[source,java]
+[source,java,options="nowrap"]
----
package org.acme.vehiclerouting.rest;
-import java.util.UUID;
-import java.util.concurrent.ExecutionException;
-
-import jakarta.inject.Inject;
-import jakarta.ws.rs.Consumes;
-import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
-import jakarta.ws.rs.Produces;
-import jakarta.ws.rs.core.MediaType;
-import ai.timefold.solver.core.api.solver.SolverJob;
-import ai.timefold.solver.core.api.solver.SolverManager;
+import ai.timefold.solver.service.rest.api.ModelRest;
-import org.acme.vehiclerouting.domain.VehicleRoutePlan;
-
-@Path("route-plans")
-public class VehicleRoutePlanResource {
-
- private final SolverManager solverManager;
-
- public VehicleRoutePlanResource() {
- this.solverManager = null;
- }
-
- @Inject
- public VehicleRoutePlanResource(SolverManager solverManager) {
- this.solverManager = solverManager;
- }
-
- @POST
- @Consumes({ MediaType.APPLICATION_JSON })
- @Produces(MediaType.APPLICATION_JSON)
- public VehicleRoutePlan solve(VehicleRoutePlan problem) {
- String jobId = UUID.randomUUID().toString();
- SolverJob solverJob = solverManager.solveBuilder()
- .withProblemId(jobId)
- .withProblem(problem)
- .run();
- VehicleRoutePlan solution;
- try {
- // Wait until the solving ends
- solution = solverJob.getFinalBestSolution();
- } catch (InterruptedException | ExecutionException e) {
- throw new IllegalStateException("Solving failed.", e);
- }
- return solution;
- }
+// Endpoints are automatically added by the service module.
+@Path("/route-plans")
+public interface VehicleRoutePlanResource extends ModelRest {
}
----
---
-Kotlin::
-+
---
-Create the `src/main/kotlin/org/acme/vehiclerouting/rest/VehicleRoutePlanResource.kt` class:
-
-[source,kotlin]
-----
-package org.acme.vehiclerouting.rest
-import java.util.UUID
-import java.util.concurrent.ExecutionException
+The `@Path` annotation configures the base path of all those endpoints.
+The service module prefixes it with the API version, so the endpoints are served at `/v1/route-plans`.
-import jakarta.inject.Inject
-import jakarta.ws.rs.Consumes
-import jakarta.ws.rs.POST
-import jakarta.ws.rs.Path
-import jakarta.ws.rs.Produces
-import jakarta.ws.rs.core.MediaType
+== Configure the service
-import ai.timefold.solver.core.api.solver.SolverManager
+Without a termination setting, the solver runs forever.
+You also need to provide some basic metadata about your service.
-import org.acme.vehiclerouting.domain.VehicleRoutePlan
+Create the `src/main/resources/application.properties` file:
-@Path("route-plans")
-class VehicleRoutePlanResource {
- private val solverManager: SolverManager?
+[source,properties,options="nowrap"]
+----
+########################
+# Timefold Solver properties
+########################
- constructor() {
- this.solverManager = null
- }
+# The solver runs for 30 seconds. To run for 5 minutes use "PT5M" and for 2 hours use "PT2H".
+timefold.model.termination.spent-limit=PT30S
- @Inject
- constructor(solverManager: SolverManager?) {
- this.solverManager = solverManager
- }
+########################
+# Model information
+########################
- @POST
- @Consumes(MediaType.APPLICATION_JSON)
- @Produces(MediaType.APPLICATION_JSON)
- fun solve(problem: VehicleRoutePlan): VehicleRoutePlan {
- val jobId = UUID.randomUUID().toString()
- val solverJob = solverManager!!.solveBuilder()
- .withProblemId(jobId)
- .withProblem(problem)
- .run()
- val solution: VehicleRoutePlan
- try {
- // Wait until the solving ends
- solution = solverJob.finalBestSolution
- } catch (e: InterruptedException) {
- throw IllegalStateException("Solving failed.", e)
- } catch (e: ExecutionException) {
- throw IllegalStateException("Solving failed.", e)
- }
- return solution
- }
-}
-----
---
-====
+timefold.model.id=vehicle-routing
+quarkus.application.name=${timefold.model.id}
+timefold.model.name=Vehicle Routing
+model.api.version=v1
+timefold.model.api-version=${model.api.version}
-For simplicity's sake, this initial implementation waits for the solver to finish,
-which can still cause an HTTP timeout.
-The _complete_ implementation avoids HTTP timeouts much more elegantly.
+timefold.model.contact.email=example@acme.com
+timefold.model.contact.name=A.C.M.E.
+timefold.model.contact.url=https://acme.com
-== Set the termination time
+########################
+# Model settings
+########################
-Without a termination setting or a `terminationEarly()` event, the solver runs forever.
-To avoid that, limit the solving time to five seconds.
-That is short enough to avoid the HTTP timeout.
+timefold.model.max-thread-count=1
+timefold.model.default-config.max-thread-count=1
+timefold.platform.map-service.use-remote=false
+timefold.platform.map-service.enable-fallback=true
-Create the `src/main/resources/application.properties` file:
+########################
+# Test overrides
+########################
-[source,properties]
-----
-# The solver runs only for 5 seconds to avoid a HTTP timeout in this simple implementation.
-# It's recommended to run for at least 5 minutes ("5m") otherwise.
-quarkus.timefold.solver.termination.spent-limit=5s
+%test.timefold.model.termination.spent-limit=PT30S
+%test.timefold.model.termination.best-score-limit=0hard/0medium/*soft
----
+* `timefold.model.termination.spent-limit` sets the default maximum time the solver runs,
+in https://en.wikipedia.org/wiki/ISO_8601#Durations[ISO 8601 duration format].
+A request can override it with `config.run.termination.spentLimit`.
+* `timefold.model.name` and the contact fields are *required* metadata.
+They identify your service and populate the generated OpenAPI specification.
+* The `timefold.platform.map-service` properties are explained in xref:#vrpQuarkusQuickStartMapService[Driving times from the map service].
+* The test overrides stop the solver as soon as every visit is assigned without breaking a hard constraint (`0hard/0medium/*soft`).
+The spent limit remains as a backstop, for a dataset where the fleet cannot absorb every visit.
+
Timefold Solver returns _the best solution_ found in the available termination time.
Due to xref:optimization-algorithms/overview.adoc#doesTimefoldFindTheOptimalSolution[the nature of NP-hard problems],
the best solution might not be optimal, especially for larger datasets.
@@ -478,51 +225,116 @@ First start the application:
[source,shell]
----
-$ mvn compile quarkus:dev
+$ mvn quarkus:dev
----
include::../shared/_quarkus-dev-mode-note.adoc[]
+Open the http://localhost:8080/q/swagger-ui/[Swagger UI] to inspect the generated endpoints.
+
=== Try the application
Now that the application is running, you can test the REST service.
You can use any REST client you wish.
The following example uses the Linux command `curl` to send a POST request:
-[source,shell]
+[source,shell,options="nowrap"]
----
-$ curl -i -X POST http://localhost:8080/route-plans -H "Content-Type:application/json" -d '{"name":"demo","vehicles":[{"id":"1","capacity":15,"homeLocation":[40.605994321126936,-75.68106859680056],"departureTime":"2024-02-10T07:30:00"},{"id":"2","capacity":25,"homeLocation":[40.32196770776356,-75.69785667307953],"departureTime":"2024-02-10T07:30:00"}],"visits":[{"id":"1","name":"Dan Green","location":[40.76104493121754,-75.16056341466826],"demand":1,"minStartTime":"2024-02-10T13:00:00","maxEndTime":"2024-02-10T18:00:00","serviceDuration":1200},{"id":"2","name":"Ivy King","location":[40.13754381024318,-75.492526629236],"demand":1,"minStartTime":"2024-02-10T13:00:00","maxEndTime":"2024-02-10T18:00:00","serviceDuration":1200.000000000},{"id":"3","name":"Flo Li","location":[39.87122455090297,-75.64520072015769],"demand":2,"minStartTime":"2024-02-10T08:00:00","maxEndTime":"2024-02-10T12:00:00","serviceDuration":600.000000000},{"id":"4","name":"Flo Cole","location":[40.46124744193433,-75.18250987609025],"demand":1,"minStartTime":"2024-02-10T13:00:00","maxEndTime":"2024-02-10T18:00:00","serviceDuration":2400.000000000},{"id":"5","name":"Carl Green","location":[40.61352381171549,-75.83301278355529],"demand":1,"minStartTime":"2024-02-10T08:00:00","maxEndTime":"2024-02-10T12:00:00","serviceDuration":1800.000000000}]}'
+$ curl -X POST http://localhost:8080/v1/route-plans -H "Content-Type: application/json" -d '{
+ "config": {
+ "run": {
+ "termination": {
+ "spentLimit": "PT5S"
+ }
+ }
+ },
+ "modelInput": {
+ "startDateTime": "2026-02-10T07:30:00Z",
+ "endDateTime": "2026-02-11T00:00:00Z",
+ "vehicles": [
+ {"id": "1", "capacity": 15, "homeLocation": {"latitude": 40.6059, "longitude": -75.6810}, "departureTime": "2026-02-10T07:30:00Z"},
+ {"id": "2", "capacity": 25, "homeLocation": {"latitude": 40.3219, "longitude": -75.6978}, "departureTime": "2026-02-10T07:30:00Z"}
+ ],
+ "visits": [
+ {"id": "1", "name": "Dan Green", "location": {"latitude": 40.7610, "longitude": -75.1605}, "demand": 1, "minStartTime": "2026-02-10T13:00:00Z", "maxEndTime": "2026-02-10T18:00:00Z", "serviceDurationMinutes": 20},
+ {"id": "2", "name": "Ivy King", "location": {"latitude": 40.1375, "longitude": -75.4925}, "demand": 1, "minStartTime": "2026-02-10T13:00:00Z", "maxEndTime": "2026-02-10T18:00:00Z", "serviceDurationMinutes": 20},
+ {"id": "3", "name": "Flo Li", "location": {"latitude": 39.8712, "longitude": -75.6452}, "demand": 2, "minStartTime": "2026-02-10T08:00:00Z", "maxEndTime": "2026-02-10T12:00:00Z", "serviceDurationMinutes": 10},
+ {"id": "4", "name": "Flo Cole", "location": {"latitude": 40.4612, "longitude": -75.1825}, "demand": 1, "minStartTime": "2026-02-10T13:00:00Z", "maxEndTime": "2026-02-10T18:00:00Z", "serviceDurationMinutes": 40},
+ {"id": "5", "name": "Carl Green", "location": {"latitude": 40.6135, "longitude": -75.8330}, "demand": 1, "minStartTime": "2026-02-10T08:00:00Z", "maxEndTime": "2026-02-10T12:00:00Z", "serviceDurationMinutes": 30}
+ ]
+ }
+}'
+----
+
+The service does not wait for the solver to finish.
+It answers right away with `202 Accepted` and the metadata of the new run:
+
+[source,json,options="nowrap"]
+----
+{
+ "id": "7f3a91bc-4e2d-4c1a-b8f6-1234567890ab",
+ "name": "Dataset-2026-02-09T10:15:30.123+01:00",
+ "submitDateTime": "2026-02-09T10:15:30.123+01:00",
+ "solverStatus": "DATASET_CREATED"
+}
----
-After about five seconds, according to the termination spent time defined in your `application.properties`,
-the service returns an output similar to the following example:
+Use the `id` to retrieve the (intermediate) solution:
-[source]
+[source,shell]
+----
+$ curl http://localhost:8080/v1/route-plans/7f3a91bc-4e2d-4c1a-b8f6-1234567890ab
----
-HTTP/1.1 200
-Content-Type: application/json
-...
-{"name":"demo","vehicles":[{"id":"1","capacity":15,"homeLocation":[40.605994321126936,-75.68106859680056],"departureTime":"2024-02-10T07:30:00","visits":[],"arrivalTime":"2024-02-10T15:34:11","totalDemand":3,"totalDrivingTimeSeconds":10826},{"id":"2","capacity":25,"homeLocation":[40.32196770776356,-75.69785667307953],"departureTime":"2024-02-10T07:30:00","visits":[],"arrivalTime":"2024-02-10T13:52:18","totalDemand":3,"totalDrivingTimeSeconds":7890}],"visits":[{"id":"1","name":"Dan Green","location":[40.76104493121754,-75.16056341466826],"demand":1,"minStartTime":"2024-02-10T13:00:00","maxEndTime":"2024-02-10T18:00:00","serviceDuration":1200.000000000,"vehicle":"1","previousVisit":"5","nextVisit":"4","arrivalTime":"2024-02-10T09:40:50","startServiceTime":"2024-02-10T13:00:00","departureTime":"2024-02-10T13:20:00","drivingTimeSecondsFromPreviousStandstill":4250},{"id":"2","name":"Ivy King","location":[40.13754381024318,-75.492526629236],"demand":1,"minStartTime":"2024-02-10T13:00:00","maxEndTime":"2024-02-10T18:00:00","serviceDuration":1200.000000000,"vehicle":"2","previousVisit":"3","nextVisit":null,"arrivalTime":"2024-02-10T09:19:12","startServiceTime":"2024-02-10T13:00:00","departureTime":"2024-02-10T13:20:00","drivingTimeSecondsFromPreviousStandstill":2329},{"id":"3","name":"Flo Li","location":[39.87122455090297,-75.64520072015769],"demand":2,"minStartTime":"2024-02-10T08:00:00","maxEndTime":"2024-02-10T12:00:00","serviceDuration":600.000000000,"vehicle":"2","previousVisit":null,"nextVisit":"2","arrivalTime":"2024-02-10T08:30:23","startServiceTime":"2024-02-10T08:30:23","departureTime":"2024-02-10T08:40:23","drivingTimeSecondsFromPreviousStandstill":3623},{"id":"4","name":"Flo Cole","location":[40.46124744193433,-75.18250987609025],"demand":1,"minStartTime":"2024-02-10T13:00:00","maxEndTime":"2024-02-10T18:00:00","serviceDuration":2400.000000000,"vehicle":"1","previousVisit":"1","nextVisit":null,"arrivalTime":"2024-02-10T14:00:04","startServiceTime":"2024-02-10T14:00:04","departureTime":"2024-02-10T14:40:04","drivingTimeSecondsFromPreviousStandstill":2404},{"id":"5","name":"Carl Green","location":[40.61352381171549,-75.83301278355529],"demand":1,"minStartTime":"2024-02-10T08:00:00","maxEndTime":"2024-02-10T12:00:00","serviceDuration":1800.000000000,"vehicle":"1","previousVisit":null,"nextVisit":"1","arrivalTime":"2024-02-10T07:45:25","startServiceTime":"2024-02-10T08:00:00","departureTime":"2024-02-10T08:30:00","drivingTimeSecondsFromPreviousStandstill":925}],"score":"0hard/-18716soft","totalDrivingTimeSeconds":18716}
+After about five seconds, according to the `spentLimit` of the request,
+the service returns an output similar to the following example:
+
+[source,json,options="nowrap"]
+----
+{
+ "metadata": {
+ "id": "7f3a91bc-4e2d-4c1a-b8f6-1234567890ab",
+ ...
+ "solverStatus": "SOLVING_COMPLETED",
+ "score": "0hard/0medium/-18716soft"
+ },
+ "modelOutput": {
+ "vehicles": [
+ {"id": "1", "visitIds": ["5", "1", "4"], "totalDemand": 3, "totalDrivingTimeSeconds": 10826, "arrivalTime": "2026-02-10T15:34:11Z"},
+ {"id": "2", "visitIds": ["3", "2"], "totalDemand": 3, "totalDrivingTimeSeconds": 7890, "arrivalTime": "2026-02-10T13:52:18Z"}
+ ],
+ "visits": [
+ {"id": "1", "vehicleId": "1", "arrivalTime": "2026-02-10T09:40:50Z", "startServiceTime": "2026-02-10T13:00:00Z", "departureTime": "2026-02-10T13:20:00Z", "drivingTimeSecondsFromPreviousStandstill": 4250},
+ ...
+ ]
+ }
+}
----
-Notice that your application assigned all five visits to one of the two vehicles.
+Notice that your application assigned all five visits to one of the two vehicles,
+so the medium score is `0`.
Also notice that it conforms to all hard constraints.
-For example, visits `1`, `4`, and `5` were scheduled to the vehicle `1`.
+For example, visits `5`, `1`, and `4` were scheduled, in that order, to vehicle `1`.
-On the server side, the `info` log shows what Timefold Solver did in those five seconds:
+The exact numbers depend on the driving times.
+Running locally, the map service estimates them from the straight-line distance.
-[source,options="nowrap"]
+With the Enterprise Edition enabled, call the score analysis endpoint to see which constraints contribute to the score:
+[source,shell]
----
-... Solving started: time spent (17), best score (0hard/0soft), environment mode (PHASE_ASSERT), move thread count (NONE), random (JDK with seed 0).
-... Construction Heuristic phase (0) ended: time spent (33), best score (0hard/-18755soft), move evaluation speed (2222/sec), step total (5).
-... Local Search phase (1) ended: time spent (5000), best score (0hard/-18716soft), move evaluation speed (89685/sec), step total (40343).
-... Solving ended: time spent (5000), best score (0hard/-18716soft), move evaluation speed (89079/sec), phase total (2), environment mode (PHASE_ASSERT), move thread count (NONE).
+$ curl http://localhost:8080/v1/route-plans/7f3a91bc-4e2d-4c1a-b8f6-1234567890ab/score-analysis
----
+Every constraint match in the response carries its justification,
+for example `"Vehicle '1' drives 180 minute(s) to service 3 visit(s)."`.
+
+See the xref:running-timefold-solver/service/consumer-guide.adoc[service consumer guide] for everything a client of your service can do,
+including polling versus Server-Sent Events and terminating a run early.
+
=== Test the application
A good application includes test coverage.
+The parent POM already brings in JUnit, REST Assured, Awaitility and AssertJ.
==== Test the constraints
@@ -530,360 +342,202 @@ To test each constraint in isolation, use a `ConstraintVerifier` in unit tests.
It tests each constraint's corner cases in isolation from the other tests,
which lowers maintenance when adding a new constraint with proper test coverage.
-First update your build tool configuration:
+A `ConstraintVerifier` test builds the domain objects directly,
+so it bypasses the model enrichment step in which the map service builds the travel time matrix.
+The `timefold-solver-service-maps-service-test` dependency provides the classes to build that matrix yourself:
+`HaversineTravelTimeAndDistanceMatrixProvider`, the same straight-line calculation the map service uses locally,
+and `TestDistanceCalculator`, which fills in the matrix for a list of locations.
-Add some dependencies in your `pom.xml`:
-[source,xml]
-----
-
- io.quarkus
- quarkus-junit
- test
-
-----
-
-Then create the test itself:
+Create the `src/test/java/org/acme/vehiclerouting/solver/VehicleRoutePlanConstraintProviderTest.java` class:
-[tabs]
-====
-Java::
-+
---
-Create the `src/test/java/org/acme/vehiclerouting/solver/VehicleRoutingConstraintProviderTest.java` class:
-
-[source,java]
+[source,java,options="nowrap"]
----
package org.acme.vehiclerouting.solver;
import java.time.Duration;
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.time.LocalTime;
-import java.util.Arrays;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
import jakarta.inject.Inject;
import ai.timefold.solver.core.api.score.stream.test.ConstraintVerifier;
+import ai.timefold.solver.core.api.solver.SolutionManager;
+import ai.timefold.solver.service.maps.api.model.Location;
+import ai.timefold.solver.service.maps.haversine.impl.HaversineTravelTimeAndDistanceMatrixProvider;
+import ai.timefold.solver.service.maps.service.test.api.TestDistanceCalculator;
-import org.acme.vehiclerouting.domain.Location;
import org.acme.vehiclerouting.domain.Vehicle;
import org.acme.vehiclerouting.domain.VehicleRoutePlan;
import org.acme.vehiclerouting.domain.Visit;
-import org.acme.vehiclerouting.domain.geo.HaversineDrivingTimeCalculator;
-import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
-class VehicleRoutingConstraintProviderTest {
+class VehicleRoutePlanConstraintProviderTest {
- /*
- * LOCATION_1 to LOCATION_2 is approx. 11713 m ~843 seconds of driving time
- * LOCATION_2 to LOCATION_3 is approx. 8880 m ~639 seconds of driving time
- * LOCATION_1 to LOCATION_3 is approx. 13075 m ~941 seconds of driving time
- */
- private static final Location LOCATION_1 = new Location(49.288087, 16.562172);
- private static final Location LOCATION_2 = new Location(49.190922, 16.624466);
- private static final Location LOCATION_3 = new Location(49.1767533245638, 16.50422914190477);
+ private static final OffsetDateTime DAY_START = OffsetDateTime.of(2024, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC);
- private static final LocalDate TOMORROW = LocalDate.now().plusDays(1);
+ private static final HaversineTravelTimeAndDistanceMatrixProvider PROVIDER =
+ new HaversineTravelTimeAndDistanceMatrixProvider(new ObjectMapper());
@Inject
- ConstraintVerifier constraintVerifier;
-
- @BeforeAll
- static void initDrivingTimeMaps() {
- HaversineDrivingTimeCalculator.getInstance().initDrivingTimeMaps(Arrays.asList(LOCATION_1, LOCATION_2, LOCATION_3));
- }
+ ConstraintVerifier constraintVerifier;
@Test
- void vehicleCapacityPenalized() {
- LocalDateTime tomorrow_07_00 = LocalDateTime.of(TOMORROW, LocalTime.of(7, 0));
- LocalDateTime tomorrow_08_00 = LocalDateTime.of(TOMORROW, LocalTime.of(8, 0));
- LocalDateTime tomorrow_10_00 = LocalDateTime.of(TOMORROW, LocalTime.of(10, 0));
- Vehicle vehicleA = new Vehicle("1", 100, LOCATION_1, tomorrow_07_00);
- Visit visit1 = new Visit("2", "John", LOCATION_2, 80, tomorrow_08_00, tomorrow_10_00, Duration.ofMinutes(30L));
- vehicleA.getVisits().add(visit1);
- Visit visit2 = new Visit("3", "Paul", LOCATION_3, 40, tomorrow_08_00, tomorrow_10_00, Duration.ofMinutes(30L));
- vehicleA.getVisits().add(visit2);
-
- constraintVerifier.verifyThat(VehicleRoutingConstraintProvider::vehicleCapacity)
- .given(vehicleA, visit1, visit2)
- .penalizesBy(20);
+ void vehicleCapacity() {
+ Vehicle vehicle = new Vehicle("1", 10, new Location(51.00, 3.65), DAY_START.withHour(7));
+ Visit visit1 = aVisit("1", new Location(51.01, 3.66), 5);
+ Visit visit2 = aVisit("2", new Location(51.02, 3.68), 8);
+ vehicle.getVisits().addAll(List.of(visit1, visit2));
+
+ // Three over capacity: 5 + 8 of 10.
+ constraintVerifier.verifyThat(VehicleRoutePlanConstraintProvider::vehicleCapacity)
+ .givenSolution(aRoutePlan(List.of(vehicle), List.of(visit1, visit2)))
+ .penalizesBy(3);
}
-}
-
-----
---
-Kotlin::
-+
---
-Create the `src/test/kotlin/org/acme/schooltimetabling/solver/TimetableConstraintProviderTest.kt` class:
-
-[source,kotlin]
-----
-package org.acme.vehiclerouting.solver;
-
-import java.time.Duration
-import java.time.LocalDate
-import java.time.LocalDateTime
-import java.time.LocalTime
-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
-import org.acme.vehiclerouting.domain.Vehicle
-import org.acme.vehiclerouting.domain.VehicleRoutePlan
-import org.acme.vehiclerouting.domain.Visit
-import org.acme.vehiclerouting.domain.geo.HaversineDrivingTimeCalculator
-import org.junit.jupiter.api.BeforeAll
-import org.junit.jupiter.api.Test
-
-import io.quarkus.test.junit.QuarkusTest
-
-@QuarkusTest
-internal class VehicleRoutingConstraintProviderTest {
-
- @Inject
- lateinit var constraintVerifier: ConstraintVerifier
- @Test
- fun vehicleCapacityPenalized() {
- val tomorrow_07_00 = LocalDateTime.of(TOMORROW, LocalTime.of(7, 0))
- val tomorrow_08_00 = LocalDateTime.of(TOMORROW, LocalTime.of(8, 0))
- 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)
- val visit2 = Visit("3", "Paul", LOCATION_3, 40, tomorrow_08_00, tomorrow_10_00, Duration.ofMinutes(30L))
- vehicleA.visits!!.add(visit2)
-
- constraintVerifier!!.verifyThat { obj: VehicleRoutingConstraintProvider, factory: ConstraintFactory? ->
- obj.vehicleCapacity(
- factory!!
- )
- }
- .given(vehicleA, visit1, visit2)
- .penalizesBy(20)
+ private static Visit aVisit(String id, Location location, int demand) {
+ return new Visit(id, "Visit " + id, location, demand,
+ DAY_START.withHour(8), DAY_START.withHour(18), Duration.ofMinutes(10));
}
- companion object {
- /*
- * LOCATION_1 to LOCATION_2 is approx. 11713 m ~843 seconds of driving time
- * LOCATION_2 to LOCATION_3 is approx. 8880 m ~639 seconds of driving time
- * LOCATION_1 to LOCATION_3 is approx. 13075 m ~941 seconds of driving time
- */
- private val LOCATION_1 = Location(49.288087, 16.562172)
- private val LOCATION_2 = Location(49.190922, 16.624466)
- private val LOCATION_3 = Location(49.1767533245638, 16.50422914190477)
-
- private val TOMORROW: LocalDate = LocalDate.now().plusDays(1)
- @JvmStatic
- @BeforeAll
- fun initDrivingTimeMaps() {
- HaversineDrivingTimeCalculator.INSTANCE.initDrivingTimeMaps(
- Arrays.asList(
- LOCATION_1, LOCATION_2, LOCATION_3
- )
- )
- }
+ private static VehicleRoutePlan aRoutePlan(List vehicles, List visits) {
+ VehicleRoutePlan plan = new VehicleRoutePlan(vehicles, visits);
+ // Build the travel time matrix the map service would otherwise build.
+ TestDistanceCalculator.initDistanceMaps(plan.getLocations(),
+ PROVIDER::calculateDistance,
+ PROVIDER::calculateTravelTime);
+ SolutionManager.updateShadowVariables(plan);
+ return plan;
}
}
----
---
-====
-This test verifies that the constraint `VehicleRoutingConstraintProvider::vehicleCapacity`,
-when given two visits assigned to the same vehicle, penalizes with a match weight of `20` (exceeded capacity).
-So with a constraint weight of `20hard` it would reduce the score by `-20hard`.
+This test verifies that the constraint `VehicleRoutePlanConstraintProvider::vehicleCapacity`,
+when given two visits assigned to the same vehicle, penalizes with a match weight of `3` (exceeded capacity).
+So with a constraint weight of `1hard` it would reduce the score by `-3hard`.
Notice how `ConstraintVerifier` ignores the constraint weight during testing - even
if those constraint weights are hard coded in the `ConstraintProvider` - because
constraints weights change regularly before going into production.
This way, constraint weight tweaking does not break the unit tests.
-==== Test the solver
+==== Test the service
-In a JUnit test, generate a test dataset and send it to the `VehicleRoutePlanResource` to solve.
+In a JUnit test, send a small dataset to the REST API and wait until the run finishes.
-Add some dependencies in your `pom.xml`:
-[source,xml]
-----
-
- io.rest-assured
- rest-assured
- test
-
-
- org.awaitility
- awaitility
- test
-
-----
-
-Then create the test itself:
-
-[tabs]
-====
-Java::
-+
---
Create the `src/test/java/org/acme/vehiclerouting/rest/VehicleRoutePlanResourceTest.java` class:
-[source,java]
+[source,java,options="nowrap"]
----
package org.acme.vehiclerouting.rest;
import static io.restassured.RestAssured.get;
import static io.restassured.RestAssured.given;
+import static org.assertj.core.api.Assertions.assertThat;
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.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
-import ai.timefold.solver.core.api.solver.SolverStatus;
+import jakarta.inject.Inject;
-import org.acme.vehiclerouting.domain.VehicleRoutePlan;
+import org.acme.vehiclerouting.dto.input.LocationInputDTO;
+import org.acme.vehiclerouting.dto.input.VehicleInputDTO;
+import org.acme.vehiclerouting.dto.input.VehicleRoutePlanInput;
+import org.acme.vehiclerouting.dto.input.VisitInputDTO;
import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
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);
+ private static final OffsetDateTime DAY_START = OffsetDateTime.of(2024, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC);
+
+ private static final Set TERMINAL_STATUSES = Set.of(
+ "DATASET_INVALID",
+ "SOLVING_COMPLETED",
+ "SOLVING_FAILED",
+ "SOLVING_INCOMPLETE");
+
+ // The ObjectMapper of the application, which knows how to serialize OffsetDateTime.
+ @Inject
+ ObjectMapper mapper;
- String jobId = given()
+ @Test
+ void solveUntilFeasible() throws Exception {
+ List vehicles = List.of(
+ new VehicleInputDTO("1", 20, new LocationInputDTO(51.00, 3.65), DAY_START.withHour(7), List.of()),
+ new VehicleInputDTO("2", 20, new LocationInputDTO(51.05, 3.75), DAY_START.withHour(7), List.of()));
+ List visits = List.of(
+ aVisit("1", 51.01, 3.66),
+ aVisit("2", 51.02, 3.68),
+ aVisit("3", 51.06, 3.76),
+ aVisit("4", 51.07, 3.78));
+ var input = new VehicleRoutePlanInput(DAY_START.withHour(7), DAY_START.plusDays(1), vehicles, visits);
+
+ String datasetId = given()
.contentType(ContentType.JSON)
- .body(vehicleRoutePlan)
- .expect().contentType(ContentType.TEXT)
- .when().post("/route-plans")
+ .body(mapper.writeValueAsString(Map.of("modelInput", input)))
+ .when().post("/v1/route-plans")
.then()
- .statusCode(200)
- .extract()
- .asString();
+ .statusCode(202)
+ .extract().jsonPath().getString("id");
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());
- }
-}
+ .until(() -> TERMINAL_STATUSES.contains(
+ get("/v1/route-plans/" + datasetId).jsonPath().getString("metadata.solverStatus")));
-----
---
-Kotlin::
-+
---
-Create the `src/test/kotlin/org/acme/vehiclerouting/rest/VehicleRoutePlanResourceTest.kt` class:
+ var response = get("/v1/route-plans/" + datasetId).then().extract().jsonPath();
+ assertThat(response.getString("metadata.solverStatus")).isEqualTo("SOLVING_COMPLETED");
+ assertThat(response.getString("metadata.score")).startsWith("0hard/0medium/");
+ }
-[source,kotlin]
+ private static VisitInputDTO aVisit(String id, double latitude, double longitude) {
+ return new VisitInputDTO(id, "Visit " + id, new LocationInputDTO(latitude, longitude), 1,
+ DAY_START.withHour(8), DAY_START.withHour(18), 10);
+ }
+}
----
-package org.acme.vehiclerouting.rest
-
-import java.time.Duration
-
-import ai.timefold.solver.core.api.solver.SolverStatus
-
-import org.acme.vehiclerouting.domain.VehicleRoutePlan
-import org.junit.jupiter.api.Test
-
-import io.quarkus.test.junit.QuarkusTest
-import io.restassured.RestAssured
-import io.restassured.http.ContentType
-import org.awaitility.Awaitility
+This test verifies that after solving, the service found a solution that assigns every visit
+without breaking a hard constraint.
-import org.junit.jupiter.api.Assertions.assertEquals
-import org.junit.jupiter.api.Assertions.assertNotNull
-import org.junit.jupiter.api.Assertions.assertTrue
+The `%test` properties in `application.properties` terminate the solver
+as soon as such a solution (`0hard/0medium/*soft`) is found.
+This avoids hard coding a solver time, because the test might run on arbitrary hardware.
+This approach ensures that the test runs long enough to find a feasible solution, even on slow machines.
+But it does not run a millisecond longer than it strictly must, even on fast machines.
-@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()
- .contentType(ContentType.JSON)
- .body(vehicleRoutePlan)
- .expect().contentType(ContentType.TEXT)
- .`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())
- }
-}
-----
---
-====
+=== Generate the initial OpenAPI specification
-This test verifies that after solving that it found a feasible solution (no hard constraints broken).
+Before running a full build with `mvn install`, generate the initial OpenAPI specification file.
+The build compares the generated specification against `src/build/openapi.json` to catch accidental API changes,
+so the build fails if that file does not exist yet.
-Add test properties to the `src/main/resources/application.properties` file:
+Run the following command once to create it:
-[source,properties]
+[source,shell]
----
-quarkus.timefold.solver.termination.spent-limit=5s
-
-# Effectively disable spent-time termination in favor of the best-score-limit
-%test.quarkus.timefold.solver.termination.spent-limit=1h
-%test.quarkus.timefold.solver.termination.best-score-limit=0hard/*soft
+$ mvn clean package -Dupdate-api
----
-Normally, the solver finds a feasible solution in less than 200 milliseconds.
-Notice how the `application.properties` overwrites the solver termination during tests
-to terminate as soon as a feasible solution (`0hard/*soft`) is found.
-This avoids hard coding a solver time, because the unit test might run on arbitrary hardware.
-This approach ensures that the test runs long enough to find a feasible solution, even on slow machines.
-But it does not run a millisecond longer than it strictly must, even on fast machines.
+We recommend committing `src/build/openapi.json` to version control.
+See xref:running-timefold-solver/service/rest-api.adoc#deliberateAPIChanges[Deliberate API changes] for more details.
=== Logging
@@ -904,22 +558,111 @@ change the logging in the `application.properties` file or with a `-D` system pr
quarkus.log.category."ai.timefold.solver".level=debug
----
-Use `debug` logging to show every _step_:
+Use `debug` logging to show every _step_ and `trace` logging to show every _step_ and every _move_ per step.
+
+== Going further
+
+The {vrp-quickstart-url}[complete quickstart] adds more on top of what this guide covers.
+
+=== Nearby selection (Enterprise Edition)
+
+xref:optimization-algorithms/move-selector-reference.adoc#nearbySelection[Nearby selection] is a Timefold Solver Enterprise Edition feature.
+It makes the solver focus on moves between visits that are close to each other,
+which makes a big difference for routing problems.
+
+Nearby selection needs a `NearbyDistanceMeter` that measures how close a visit is to another visit or to a vehicle.
+To cover both with a single class, first let `Vehicle` and `Visit` expose their location through a common interface.
+
+Create the `src/main/java/org/acme/vehiclerouting/domain/LocationAware.java` interface:
-[source,options="nowrap"]
+[source,java,options="nowrap"]
----
-... Solving started: time spent (67), best score (0hard/0soft), environment mode (PHASE_ASSERT), random (JDK with seed 0).
-... CH step (0), time spent (128), score (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 (0hard/0soft), selected move count (15), picked move ([Physics(102) {null -> Room A}, Physics(102) {null -> MONDAY 09:30}]).
-...
+package org.acme.vehiclerouting.domain;
+
+import ai.timefold.solver.service.maps.api.model.Location;
+
+public interface LocationAware {
+
+ Location getLocation();
+}
----
-Use `trace` logging to show every _step_ and every _move_ per step.
+Make `Vehicle` and `Visit` implement it.
+`Visit` already has a `getLocation()` method.
+For `Vehicle`, add one that returns its home location:
+
+[source,java,options="nowrap"]
+----
+@PlanningEntity
+public class Vehicle implements LocationAware {
+
+ ...
+
+ @Override
+ public Location getLocation() {
+ return homeLocation;
+ }
+}
+----
+
+[source,java,options="nowrap"]
+----
+@PlanningEntity
+public class Visit implements LocationAware {
+ ...
+}
+----
+
+Then create the `src/main/java/org/acme/vehiclerouting/domain/LocationDistanceMeter.java` class:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.domain;
+
+import ai.timefold.solver.core.impl.heuristic.selector.common.nearby.NearbyDistanceMeter;
+
+public class LocationDistanceMeter implements NearbyDistanceMeter {
+
+ @Override
+ public double getNearbyDistance(Visit origin, LocationAware destination) {
+ return origin.getLocation().getTravelTimeTo(destination.getLocation()).seconds();
+ }
+}
+----
+
+Finally, register it in `application.properties`:
+
+[source,properties,options="nowrap"]
+----
+%enterprise.quarkus.timefold.solver.nearby-distance-meter-class=org.acme.vehiclerouting.domain.LocationDistanceMeter
+----
+
+The `%enterprise` prefix only applies the property under the `enterprise` Maven profile (`mvn quarkus:dev -Denterprise`),
+so the model still runs under the Community Edition.
+
+=== Everything else
+
+* *Input validation*: a `ModelValidator` rejects inputs that are well-formed but make no sense,
+such as duplicate IDs, a route referring to a visit that does not exist, or a time window too short for its service duration.
+See xref:running-timefold-solver/service/rest-api.adoc#validatingRestInput[Validating REST input].
+* *Demo data*: a `DemoDataGenerator` publishes ready-to-solve datasets under `/v1/demo-data`,
+which the web UI of the quickstart uses.
+See xref:running-timefold-solver/service/demo-data.adoc[Demo data].
+* *Metrics*: `VehicleRoutePlan` also implements `InputMetricsAware` and `OutputMetricsAware`,
+to report figures such as the number of unassigned visits and the total driving time.
+See xref:running-timefold-solver/service/exposing-metrics.adoc[Exposing metrics].
+* *Recommended assignments*: a xref:running-timefold-solver/service/rest-api.adoc#customEndpoints[custom endpoint]
+next to the generated ones, `POST /v1/route-plans/recommendation`, answers the question
+"where would this new visit fit best into the plan we already have?"
+It uses the xref:responding-to-change/recommendation-api.adoc#assignmentRecommendationAPI[Assignment Recommendation API],
+which is a Timefold Solver Enterprise Edition feature.
+* *Deploying to the Timefold Platform*: see xref:deploying-to-platform/guide.adoc[Deploying to Timefold Platform].
+[sectnums!]
== Summary
Congratulations!
-You have just developed a Quarkus application with https://timefold.ai[Timefold]!
+You have just developed a vehicle routing optimization service with https://timefold.ai[Timefold]!
-For a full implementation with a web UI and in-memory storage,
-check out {vrp-quickstart-url}[the Quarkus quickstart source code].
\ No newline at end of file
+For the full implementation with a web UI, demo data, validation and recommendations,
+check out {vrp-quickstart-url}[the quickstart source code].
diff --git a/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-api.adoc b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-api.adoc
new file mode 100644
index 0000000000..ff08f03923
--- /dev/null
+++ b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-api.adoc
@@ -0,0 +1,389 @@
+[#vrpQuarkusQuickStartApi]
+= Define the API of the service
+:imagesdir: ../..
+
+The domain classes are built for the solver:
+they hold object references, shadow variables and a travel time matrix.
+The users of your service should not have to know about any of that.
+So the service has its own API classes, and a
+xref:running-timefold-solver/service/rest-api.adoc#modelConverter[`ModelConvertor`] translates between the two.
+
+* `ModelInput`: the problem a user submits.
+* `ModelOutput`: the solution the service returns.
+* `ModelConfigOverrides`: the settings a user can change per request, such as constraint weights.
+
+[NOTE]
+====
+In the {vrp-quickstart-url}[complete quickstart], every class and field below also carries a MicroProfile OpenAPI `@Schema` annotation,
+for example `@Schema(description = "Unique identifier of the vehicle.", required = true, minLength = 1)`.
+These annotations only serve to generate the xref:running-timefold-solver/service/rest-api.adoc#openAPISpecification[OpenAPI specification]:
+they describe each field and mark which fields are required and which values are allowed.
+The service module validates incoming requests against that specification,
+so a request with a missing required field or a value out of range is rejected before it reaches your code.
+
+To keep the listings short, this guide leaves the `@Schema` annotations out.
+Add them to your own DTOs to get a documented and validated API.
+====
+
+== The input
+
+In the input, a route is a list of visit IDs.
+A new problem has empty routes, but a user can also submit an existing plan, for example to improve it further.
+A visit that appears in no vehicle's `visitIds` is unassigned.
+
+Create the `src/main/java/org/acme/vehiclerouting/dto/input/LocationInputDTO.java` record:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.dto.input;
+
+public record LocationInputDTO(Double latitude, Double longitude) {
+}
+----
+
+Create the `src/main/java/org/acme/vehiclerouting/dto/input/VehicleInputDTO.java` record:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.dto.input;
+
+import static java.util.Collections.emptyList;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+
+public record VehicleInputDTO(
+ String id,
+ Integer capacity,
+ LocationInputDTO homeLocation,
+ OffsetDateTime departureTime,
+ // The visits on this vehicle's route, in order. Empty when the vehicle has no route yet.
+ List visitIds) {
+
+ public VehicleInputDTO {
+ visitIds = visitIds != null ? visitIds : emptyList();
+ }
+
+ public VehicleInputDTO withVisitIds(List visitIds) {
+ return new VehicleInputDTO(id, capacity, homeLocation, departureTime, visitIds);
+ }
+}
+----
+
+Create the `src/main/java/org/acme/vehiclerouting/dto/input/VisitInputDTO.java` record:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.dto.input;
+
+import java.time.OffsetDateTime;
+
+public record VisitInputDTO(
+ String id,
+ String name,
+ LocationInputDTO location,
+ Integer demand,
+ OffsetDateTime minStartTime,
+ OffsetDateTime maxEndTime,
+ Integer serviceDurationMinutes) {
+}
+----
+
+Finally, the input itself wraps the vehicles and the visits and implements `ModelInput`.
+Create the `src/main/java/org/acme/vehiclerouting/dto/input/VehicleRoutePlanInput.java` record:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.dto.input;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+
+import ai.timefold.solver.service.definition.api.ModelInput;
+
+public record VehicleRoutePlanInput(
+ OffsetDateTime startDateTime,
+ OffsetDateTime endDateTime,
+ List vehicles,
+ List visits)
+ implements
+ ModelInput {
+
+ public VehicleRoutePlanInput withVehicles(List vehicles) {
+ return new VehicleRoutePlanInput(startDateTime, endDateTime, vehicles, visits);
+ }
+}
+----
+
+All date-times are `OffsetDateTime`, so the JSON always carries an offset, for example `2026-02-10T07:30:00Z`.
+
+== The configuration overrides
+
+`ModelConfigOverrides` lists what a user can tune per request.
+In this model, that is the weight of the `Minimize travel time` constraint.
+`@ConstraintReference` links the field to that constraint.
+A weight left unset (`null`) is not overridden, so the value from the configuration profile (or the constraint's default) applies.
+Read xref:running-timefold-solver/service/model-config-overrides.adoc[Model configuration overrides] to learn more.
+
+Create the `src/main/java/org/acme/vehiclerouting/dto/input/VehicleRoutePlanConfigOverrides.java` record:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.dto.input;
+
+import ai.timefold.solver.service.definition.api.ModelConfigOverrides;
+import ai.timefold.solver.service.definition.api.domain.ConstraintReference;
+
+import org.acme.vehiclerouting.domain.VehicleRoutePlanConstraintProperties;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record VehicleRoutePlanConfigOverrides(
+ @ConstraintReference(VehicleRoutePlanConstraintProperties.MINIMIZE_TRAVEL_TIME) Long minimizeTravelTimeWeight)
+ implements
+ ModelConfigOverrides {
+
+ // Required by the service module to generate the default configuration profile.
+ public VehicleRoutePlanConfigOverrides() {
+ this(null);
+ }
+}
+----
+
+== The output
+
+The output mirrors the input.
+For each vehicle, it returns the route as a list of visit IDs, along with its total demand and driving time.
+For each visit, it returns the vehicle that services it and when it does so.
+The fields of an unassigned visit are `null`.
+
+Create the `src/main/java/org/acme/vehiclerouting/dto/output/VehicleOutputDTO.java` record:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.dto.output;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.ALWAYS)
+public record VehicleOutputDTO(
+ String id,
+ List visitIds,
+ Integer totalDemand,
+ Long totalDrivingTimeSeconds,
+ OffsetDateTime arrivalTime) {
+}
+----
+
+Create the `src/main/java/org/acme/vehiclerouting/dto/output/VisitOutputDTO.java` record:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.dto.output;
+
+import java.time.OffsetDateTime;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.ALWAYS)
+public record VisitOutputDTO(
+ String id,
+ String vehicleId,
+ OffsetDateTime arrivalTime,
+ OffsetDateTime startServiceTime,
+ OffsetDateTime departureTime,
+ Long drivingTimeSecondsFromPreviousStandstill) {
+}
+----
+
+Create the `src/main/java/org/acme/vehiclerouting/dto/output/VehicleRoutePlanOutput.java` record:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.dto.output;
+
+import java.util.List;
+
+import ai.timefold.solver.service.definition.api.ModelOutput;
+
+public record VehicleRoutePlanOutput(
+ List vehicles,
+ List visits)
+ implements
+ ModelOutput {
+}
+----
+
+[#vrpQuarkusQuickStartModelConvertor]
+== The model convertor
+
+The `ModelConvertor` connects the API classes to the domain classes.
+The service module calls it at three moments:
+
+* `toSolverModel()`: before solving, to turn the input (and the configuration overrides) into a `VehicleRoutePlan`.
+When the service resumes a run that was interrupted, `lastModelOutput` holds the last known solution,
+so the routes continue from there instead of starting over.
+* `toModelOutput()`: whenever a new best solution is found, to turn the `VehicleRoutePlan` into the output.
+* `applyOutputToInput()`: to overlay a solution on the original input,
+for example to submit the result of one run as the starting point of the next.
+
+Create the `src/main/java/org/acme/vehiclerouting/service/VehicleRoutePlanModelConvertor.java` class:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.service;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides;
+import ai.timefold.solver.core.api.score.HardMediumSoftScore;
+import ai.timefold.solver.service.definition.api.ModelConvertor;
+import ai.timefold.solver.service.definition.api.domain.ModelConfig;
+import ai.timefold.solver.service.maps.api.model.Location;
+
+import org.acme.vehiclerouting.domain.Vehicle;
+import org.acme.vehiclerouting.domain.VehicleRoutePlan;
+import org.acme.vehiclerouting.domain.VehicleRoutePlanConstraintProperties;
+import org.acme.vehiclerouting.domain.Visit;
+import org.acme.vehiclerouting.dto.input.LocationInputDTO;
+import org.acme.vehiclerouting.dto.input.VehicleInputDTO;
+import org.acme.vehiclerouting.dto.input.VehicleRoutePlanConfigOverrides;
+import org.acme.vehiclerouting.dto.input.VehicleRoutePlanInput;
+import org.acme.vehiclerouting.dto.input.VisitInputDTO;
+import org.acme.vehiclerouting.dto.output.VehicleOutputDTO;
+import org.acme.vehiclerouting.dto.output.VehicleRoutePlanOutput;
+import org.acme.vehiclerouting.dto.output.VisitOutputDTO;
+
+@ApplicationScoped
+public class VehicleRoutePlanModelConvertor implements
+ ModelConvertor {
+
+ @Override
+ public VehicleRoutePlan toSolverModel(VehicleRoutePlanInput modelInput,
+ ModelConfig modelConfig,
+ Optional lastModelOutput) {
+ Map visitMap = modelInput.visits().stream()
+ .map(VehicleRoutePlanModelConvertor::toVisit)
+ .collect(Collectors.toMap(Visit::getId, visit -> visit, (first, second) -> {
+ throw new IllegalArgumentException("Duplicate visit ID '%s'.".formatted(first.getId()));
+ }, LinkedHashMap::new));
+ List vehicles = modelInput.vehicles().stream()
+ .map(VehicleRoutePlanModelConvertor::toVehicle)
+ .toList();
+
+ VehicleRoutePlan routePlan = new VehicleRoutePlan(vehicles, List.copyOf(visitMap.values()));
+ applyConstraintWeightOverrides(routePlan, modelConfig);
+ applyRoutes(vehicles, visitMap, modelInput, lastModelOutput);
+ return routePlan;
+ }
+
+ @Override
+ public VehicleRoutePlanOutput toModelOutput(VehicleRoutePlan solverModel) {
+ List vehicles = solverModel.getVehicles().stream()
+ .map(vehicle -> new VehicleOutputDTO(vehicle.getId(),
+ vehicle.getVisits().stream().map(Visit::getId).toList(),
+ vehicle.getTotalDemand(), vehicle.getTotalDrivingTimeSeconds(), vehicle.arrivalTime()))
+ .toList();
+ List visits = solverModel.getVisits().stream()
+ .map(visit -> new VisitOutputDTO(visit.getId(),
+ visit.getVehicle() == null ? null : visit.getVehicle().getId(),
+ visit.getArrivalTime(), visit.getStartServiceTime(), visit.getDepartureTime(),
+ visit.getDrivingTimeSecondsFromPreviousStandstillOrNull()))
+ .toList();
+ return new VehicleRoutePlanOutput(vehicles, visits);
+ }
+
+ @Override
+ public VehicleRoutePlanInput applyOutputToInput(VehicleRoutePlanInput modelInput,
+ VehicleRoutePlanOutput modelOutput) {
+ // The assignment is the route list, so overlaying the output means replacing one list per vehicle.
+ Map routeByVehicleId = modelOutput.vehicles().stream()
+ .collect(Collectors.toMap(VehicleOutputDTO::id, vehicle -> vehicle));
+ List updatedVehicles = modelInput.vehicles().stream()
+ .map(vehicle -> {
+ VehicleOutputDTO solved = routeByVehicleId.get(vehicle.id());
+ return solved == null || solved.visitIds() == null ? vehicle : vehicle.withVisitIds(solved.visitIds());
+ })
+ .toList();
+ return modelInput.withVehicles(updatedVehicles);
+ }
+
+ private static Location toLocation(LocationInputDTO dto) {
+ return new Location(dto.latitude(), dto.longitude());
+ }
+
+ private static Vehicle toVehicle(VehicleInputDTO dto) {
+ return new Vehicle(dto.id(), dto.capacity(), toLocation(dto.homeLocation()), dto.departureTime());
+ }
+
+ private static Visit toVisit(VisitInputDTO dto) {
+ return new Visit(dto.id(), dto.name(), toLocation(dto.location()), dto.demand(), dto.minStartTime(),
+ dto.maxEndTime(), Duration.ofMinutes(dto.serviceDurationMinutes()));
+ }
+
+ private static void applyConstraintWeightOverrides(VehicleRoutePlan routePlan,
+ ModelConfig modelConfig) {
+ if (modelConfig == null || modelConfig.overrides() == null) {
+ return;
+ }
+ // A null weight means the input did not override it,
+ // so the configuration profile value (or the constraint's default) is kept.
+ Long minimizeTravelTimeWeight = modelConfig.overrides().minimizeTravelTimeWeight();
+ if (minimizeTravelTimeWeight != null) {
+ Map weights = new HashMap<>();
+ weights.put(VehicleRoutePlanConstraintProperties.MINIMIZE_TRAVEL_TIME,
+ HardMediumSoftScore.ofSoft(minimizeTravelTimeWeight));
+ routePlan.setConstraintWeightOverrides(ConstraintWeightOverrides.of(weights));
+ }
+ }
+
+ /**
+ * Fills the list variable of every vehicle: from lastModelOutput when a halted run is being
+ * recovered, and from the input's own routes otherwise. The shadow variables are deliberately
+ * not set here - the solver derives them when it loads the solution.
+ */
+ private static void applyRoutes(List vehicles, Map visitMap,
+ VehicleRoutePlanInput modelInput, Optional lastModelOutput) {
+ Map> routeByVehicleId = lastModelOutput
+ .map(output -> output.vehicles().stream()
+ .filter(vehicle -> vehicle.visitIds() != null)
+ .collect(Collectors.toMap(VehicleOutputDTO::id, VehicleOutputDTO::visitIds)))
+ .orElseGet(() -> modelInput.vehicles().stream()
+ .collect(Collectors.toMap(VehicleInputDTO::id, VehicleInputDTO::visitIds)));
+
+ for (Vehicle vehicle : vehicles) {
+ List visitIds = routeByVehicleId.get(vehicle.getId());
+ if (visitIds == null || visitIds.isEmpty()) {
+ continue;
+ }
+ // The solver mutates this list, so it cannot be an immutable copy of the input's.
+ List route = new ArrayList<>(visitIds.size());
+ for (String visitId : visitIds) {
+ Visit visit = visitMap.get(visitId);
+ if (visit == null) {
+ throw new IllegalArgumentException("Unknown visit '%s'.".formatted(visitId));
+ }
+ route.add(visit);
+ }
+ vehicle.setVisits(route);
+ }
+ }
+}
+----
+
+Notice that the convertor does not touch the travel time matrix.
+The service module calls the map service after `toSolverModel()`, as part of model enrichment,
+so the `VehicleRoutePlan` already has its driving times by the time the solver starts.
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 9549061b6e..88562aa565 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
@@ -6,140 +6,243 @@ The higher the better.
Timefold Solver looks for the best solution, which is the solution with the highest score found in the available time.
It might be the _optimal_ solution.
-Because this use case has hard and soft constraints,
-use the `HardSoftScore` class to represent the score:
+Because this use case has hard, medium and soft constraints,
+use the `HardMediumSoftScore` class to represent the score:
* Hard constraints must not be broken.
For example: _The vehicle capacity must not be exceeded._
-* Soft constraints should not be broken.
+* Medium constraints should not be broken.
+For example: _As many visits as possible should be assigned to a vehicle._
+* Soft constraints should not be broken either, but are only considered once the medium constraints are as good as they get.
For example: _The sum total of travel time._
Hard constraints are weighted against other hard constraints.
-Soft constraints are weighted too, against other soft constraints.
-*Hard constraints always outweigh soft constraints*, regardless of their respective weights.
+Medium and soft constraints are weighted too, against other constraints of the same level.
+*Hard constraints always outweigh medium constraints, and medium constraints always outweigh soft constraints*, regardless of their respective weights.
-To calculate the score, create a `VehicleRoutingConstraintProvider` class
+The medium level is what makes unassigned visits work.
+Assigning a visit is always worth more than any saving in travel time,
+but never worth breaking a hard constraint.
+So the solver only leaves a visit unassigned when it cannot fit on any route.
+
+== Constraint names
+
+The service module exposes constraints through its REST API, for example in the score analysis
+and in the constraint weight overrides.
+To reference them consistently, keep the constraint names in one place.
+
+Create the `src/main/java/org/acme/vehiclerouting/domain/VehicleRoutePlanConstraintProperties.java` class:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.domain;
+
+public final class VehicleRoutePlanConstraintProperties {
+
+ public static final String VEHICLE_CAPACITY = "Vehicle capacity";
+ public static final String SERVICE_FINISHED_AFTER_MAX_END_TIME = "Service finished after max end time";
+
+ public static final String MAXIMIZE_VISITS_ASSIGNED = "Maximize visits assigned";
+
+ public static final String MINIMIZE_TRAVEL_TIME = "Minimize travel time";
+
+ private VehicleRoutePlanConstraintProperties() {
+ }
+}
+----
+
+Constraints are also organized in groups, which the Timefold Platform uses to present them.
+Create the `src/main/java/org/acme/vehiclerouting/solver/VehicleRoutePlanConstraintGroup.java` class:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.solver;
+
+import ai.timefold.solver.service.definition.api.description.ConstraintGroupInfo;
+
+public final class VehicleRoutePlanConstraintGroup {
+
+ public static final ConstraintGroupInfo VEHICLE_CAPACITY = new ConstraintGroupInfo("vehicleCapacity",
+ "Vehicle capacity",
+ "Keep the total demand of the visits on a vehicle's route within the capacity that vehicle has.",
+ "IconTruckLoading",
+ new String[] { "vehicle capacity" });
+
+ public static final ConstraintGroupInfo TIME_WINDOWS = new ConstraintGroupInfo("timeWindows",
+ "Time windows",
+ "Service every visit inside the time window it accepts a vehicle in.",
+ "IconClock",
+ new String[] { "time windows" });
+
+ public static final ConstraintGroupInfo VISIT_ASSIGNMENT = new ConstraintGroupInfo("visitAssignment",
+ "Visit assignment",
+ "Get as many visits as possible onto a vehicle's route, rather than leaving them unserviced.",
+ "IconMapPin",
+ new String[] { "visit assignment" });
+
+ public static final ConstraintGroupInfo TRAVEL_TIME = new ConstraintGroupInfo("travelTime",
+ "Travel time",
+ "Keep the fleet on the road for as little time as possible.",
+ "IconRoute",
+ new String[] { "travel time" });
+
+ private VehicleRoutePlanConstraintGroup() {
+ }
+}
+----
+
+== Constraint justifications
+
+Every constraint match can carry a xref:constraints-and-score/score-calculation.adoc#constraintStreamsCustomizingJustifications[justification]:
+an object that explains why the constraint matched.
+The Enterprise Edition's score analysis endpoint returns these justifications,
+so a user of your service can see exactly which vehicle is overloaded or which visit is late.
+
+Each justification is a record that implements `ModelConstraintJustification`.
+
+Create the `src/main/java/org/acme/vehiclerouting/domain/justification/VehicleRoutePlanJustification.java` interface:
+
+[source,java,options="nowrap"]
+----
+package org.acme.vehiclerouting.domain.justification;
+
+import ai.timefold.solver.service.definition.api.ModelConstraintJustification;
+
+import org.acme.vehiclerouting.domain.Vehicle;
+
+public interface VehicleRoutePlanJustification extends ModelConstraintJustification {
+
+ String getDescription();
+
+ default String description() {
+ return getDescription();
+ }
+
+ record VehicleCapacityJustification(String vehicle, int capacity, int totalDemand, int excessDemand)
+ implements VehicleRoutePlanJustification {
+
+ public static VehicleCapacityJustification of(Vehicle vehicle) {
+ return new VehicleCapacityJustification(vehicle.getId(), vehicle.getCapacity(), vehicle.getTotalDemand(),
+ vehicle.getTotalDemand() - vehicle.getCapacity());
+ }
+
+ @Override
+ public String getDescription() {
+ return "Vehicle '%s' carries a demand of %d, which is %d over its capacity of %d."
+ .formatted(vehicle, totalDemand, excessDemand, capacity);
+ }
+ }
+
+ // ServiceFinishedAfterMaxEndTimeJustification, VisitNotAssignedJustification
+ // and TravelTimeJustification follow the same pattern and are excluded
+}
+----
+
+As with the xref:#vrpQuarkusQuickStartApi[API classes], the complete quickstart annotates the justifications with `@Schema`
+so they are documented in the generated OpenAPI specification.
+There, the interface also lists every justification record in `@Schema(oneOf = ...)`:
+a record that is not listed does not show up in the specification.
+
+See {vrp-quickstart-url}[the quickstart source code] for the other three justification records.
+
+== The constraint provider
+
+To calculate the score, create a `VehicleRoutePlanConstraintProvider` class
to perform incremental score calculation.
It uses Timefold Solver's xref:constraints-and-score/score-calculation.adoc[Constraint Streams API]
-which is inspired by Java Streams and SQL:
+which is inspired by Java Streams and SQL.
-[tabs]
-====
-Java::
-+
---
-Create a `src/main/java/org/acme/vehiclerouting/solver/VehicleRoutingConstraintProvider.java` class:
+Each constraint is registered with a `ConstraintInfo`, which gives it a name, a description and a group.
+The service module uses this information to describe the constraints of your model in its REST API.
-[source,java]
+Create the `src/main/java/org/acme/vehiclerouting/solver/VehicleRoutePlanConstraintProvider.java` class:
+
+[source,java,options="nowrap"]
----
package org.acme.vehiclerouting.solver;
-import ai.timefold.solver.core.api.score.HardSoftScore;
+import ai.timefold.solver.core.api.score.HardMediumSoftScore;
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.service.definition.api.description.ConstraintInfo;
-import org.acme.vehiclerouting.domain.Visit;
import org.acme.vehiclerouting.domain.Vehicle;
+import org.acme.vehiclerouting.domain.VehicleRoutePlanConstraintProperties;
+import org.acme.vehiclerouting.domain.Visit;
+import org.acme.vehiclerouting.domain.justification.VehicleRoutePlanJustification.ServiceFinishedAfterMaxEndTimeJustification;
+import org.acme.vehiclerouting.domain.justification.VehicleRoutePlanJustification.TravelTimeJustification;
+import org.acme.vehiclerouting.domain.justification.VehicleRoutePlanJustification.VehicleCapacityJustification;
+import org.acme.vehiclerouting.domain.justification.VehicleRoutePlanJustification.VisitNotAssignedJustification;
-public class VehicleRoutingConstraintProvider implements ConstraintProvider {
-
- public static final String VEHICLE_CAPACITY = "vehicleCapacity";
- public static final String SERVICE_FINISHED_AFTER_MAX_END_TIME = "serviceFinishedAfterMaxEndTime";
- public static final String MINIMIZE_TRAVEL_TIME = "minimizeTravelTime";
+public class VehicleRoutePlanConstraintProvider implements ConstraintProvider {
@Override
public Constraint[] defineConstraints(ConstraintFactory factory) {
return new Constraint[] {
+ // Hard constraints
vehicleCapacity(factory),
serviceFinishedAfterMaxEndTime(factory),
+
+ // Medium constraints
+ maximizeVisitsAssigned(factory),
+
+ // Soft constraints
minimizeTravelTime(factory)
};
}
- protected Constraint vehicleCapacity(ConstraintFactory factory) {
+ public Constraint vehicleCapacity(ConstraintFactory factory) {
return factory.forEach(Vehicle.class)
.filter(vehicle -> vehicle.getTotalDemand() > vehicle.getCapacity())
- .penalizeLong(HardSoftScore.ONE_HARD,
+ .penalize(HardMediumSoftScore.ONE_HARD,
vehicle -> vehicle.getTotalDemand() - vehicle.getCapacity())
- .asConstraint(VEHICLE_CAPACITY);
+ .justifyWith((vehicle, score) -> VehicleCapacityJustification.of(vehicle))
+ .asConstraint(new ConstraintInfo(VehicleRoutePlanConstraintProperties.VEHICLE_CAPACITY,
+ VehicleRoutePlanConstraintProperties.VEHICLE_CAPACITY,
+ "The total demand of all visits assigned to a vehicle must not exceed its capacity.",
+ VehicleRoutePlanConstraintGroup.VEHICLE_CAPACITY));
}
- protected Constraint serviceFinishedAfterMaxEndTime(ConstraintFactory factory) {
+ public Constraint serviceFinishedAfterMaxEndTime(ConstraintFactory factory) {
return factory.forEach(Visit.class)
.filter(Visit::isServiceFinishedAfterMaxEndTime)
- .penalizeLong(HardSoftScore.ONE_HARD,
+ .penalize(HardMediumSoftScore.ONE_HARD,
Visit::getServiceFinishedDelayInMinutes)
- .asConstraint(SERVICE_FINISHED_AFTER_MAX_END_TIME);
+ .justifyWith((visit, score) -> ServiceFinishedAfterMaxEndTimeJustification.of(visit))
+ .asConstraint(
+ new ConstraintInfo(VehicleRoutePlanConstraintProperties.SERVICE_FINISHED_AFTER_MAX_END_TIME,
+ VehicleRoutePlanConstraintProperties.SERVICE_FINISHED_AFTER_MAX_END_TIME,
+ "A visit must be serviced before its maximum end time.",
+ VehicleRoutePlanConstraintGroup.TIME_WINDOWS));
}
- protected Constraint minimizeTravelTime(ConstraintFactory factory) {
+ public Constraint maximizeVisitsAssigned(ConstraintFactory factory) {
+ return factory.forEachIncludingUnassigned(Visit.class)
+ .filter(visit -> visit.getVehicle() == null)
+ .penalize(HardMediumSoftScore.ONE_MEDIUM, visit -> visit.getServiceDuration().toMinutes())
+ .justifyWith((visit, score) -> VisitNotAssignedJustification.of(visit))
+ .asConstraint(new ConstraintInfo(VehicleRoutePlanConstraintProperties.MAXIMIZE_VISITS_ASSIGNED,
+ VehicleRoutePlanConstraintProperties.MAXIMIZE_VISITS_ASSIGNED,
+ "As many visits as possible should be assigned to a vehicle.",
+ VehicleRoutePlanConstraintGroup.VISIT_ASSIGNMENT));
+ }
+
+ public Constraint minimizeTravelTime(ConstraintFactory factory) {
return factory.forEach(Vehicle.class)
- .penalizeLong(HardSoftScore.ONE_SOFT,
+ .penalize(HardMediumSoftScore.ONE_SOFT,
Vehicle::getTotalDrivingTimeSeconds)
- .asConstraint(MINIMIZE_TRAVEL_TIME);
+ .justifyWith((vehicle, score) -> TravelTimeJustification.of(vehicle))
+ .asConstraint(new ConstraintInfo(VehicleRoutePlanConstraintProperties.MINIMIZE_TRAVEL_TIME,
+ VehicleRoutePlanConstraintProperties.MINIMIZE_TRAVEL_TIME,
+ "Minimize the total travel time of all vehicles.",
+ VehicleRoutePlanConstraintGroup.TRAVEL_TIME));
}
}
-
----
---
-
-Kotlin::
-+
---
-Create a `src/main/kotlin/org/acme/vehiclerouting/solver/VehicleRoutingConstraintProvider.kt` class:
-
-[source,kotlin]
-----
-package org.acme.vehiclerouting.solver
-
-import ai.timefold.solver.core.api.score.HardSoftScore
-import ai.timefold.solver.core.api.score.stream.Constraint
-import ai.timefold.solver.core.api.score.stream.ConstraintFactory
-import ai.timefold.solver.core.api.score.stream.ConstraintProvider
-
-import org.acme.vehiclerouting.domain.Visit
-import org.acme.vehiclerouting.domain.Vehicle
-
-class VehicleRoutingConstraintProvider : ConstraintProvider {
- override fun defineConstraints(factory: ConstraintFactory): Array {
- return arrayOf(
- vehicleCapacity(factory),
- serviceFinishedAfterMaxEndTime(factory),
- minimizeTravelTime(factory)
- )
- }
- protected fun vehicleCapacity(factory: ConstraintFactory): Constraint {
- return factory.forEach(Vehicle::class.java)
- .filter({ vehicle: Vehicle -> vehicle.totalDemand > vehicle.capacity })
- .penalizeLong(
- HardSoftScore.ONE_HARD
- ) { vehicle: Vehicle -> vehicle.totalDemand - vehicle.capacity }
- .asConstraint(VEHICLE_CAPACITY)
- }
-
- protected fun serviceFinishedAfterMaxEndTime(factory: ConstraintFactory): Constraint {
- return factory.forEach(Visit::class.java)
- .filter({ obj: Visit -> obj.isServiceFinishedAfterMaxEndTime })
- .penalizeLong(HardSoftScore.ONE_HARD,
- { obj: Visit -> obj.serviceFinishedDelayInMinutes })
- .asConstraint(SERVICE_FINISHED_AFTER_MAX_END_TIME)
- }
-
- protected fun minimizeTravelTime(factory: ConstraintFactory): Constraint {
- return factory.forEach(Vehicle::class.java)
- .penalizeLong(HardSoftScore.ONE_SOFT,
- { obj: Vehicle -> obj.totalDrivingTimeSeconds })
- .asConstraint(MINIMIZE_TRAVEL_TIME)
- }
-
- companion object {
- const val VEHICLE_CAPACITY: String = "vehicleCapacity"
- const val SERVICE_FINISHED_AFTER_MAX_END_TIME: String = "serviceFinishedAfterMaxEndTime"
- const val MINIMIZE_TRAVEL_TIME: String = "minimizeTravelTime"
- }
-}
-----
---
-====
+Notice that `maximizeVisitsAssigned` starts from `forEachIncludingUnassigned(Visit.class)`.
+A plain `forEach(Visit.class)` skips visits that are not on any route,
+which are exactly the visits this constraint needs to penalize.
+The penalty is the service duration of the unassigned visit,
+so the solver prefers to leave out a short visit rather than a long one.
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 51866124a3..430b5c2ebe 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
@@ -2,127 +2,58 @@
= Model the domain objects
:imagesdir: ../..
-Your goal is to assign each visit to a vehicle.
+Your goal is to assign each visit to a vehicle, in the order that vehicle services them.
You will create these classes:
image::quickstart/vehicle-routing/vehicleRoutingClassDiagramPure.png[]
== Location
-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.
+Every location in the model, whether a vehicle's home location or a visit's destination,
+is an `ai.timefold.solver.service.maps.api.model.Location`.
+This class comes with the `timefold-solver-service-with-maps` dependency, so you do not create it yourself.
-[tabs]
-====
-Java::
-+
---
-Create the `src/main/java/org/acme/vehiclerouting/domain/Location.java` class:
-
-[source,java]
-----
-package org.acme.vehiclerouting.domain;
-
-import java.util.Map;
-
-public class Location {
-
- private double latitude;
- private double longitude;
-
- private Map drivingTimeSeconds;
-
- public Location(double latitude, double longitude) {
- this.latitude = latitude;
- this.longitude = longitude;
- }
-
- public double getLatitude() {
- return latitude;
- }
-
- public double getLongitude() {
- return longitude;
- }
-
- public Map getDrivingTimeSeconds() {
- return drivingTimeSeconds;
- }
-
- public void setDrivingTimeSeconds(Map drivingTimeSeconds) {
- this.drivingTimeSeconds = drivingTimeSeconds;
- }
-
- public long getDrivingTimeTo(Location location) {
- return drivingTimeSeconds.get(location);
- }
-}
-----
---
-
-Kotlin::
-+
---
-Create the `src/main/kotlin/org/acme/vehiclerouting/domain/Location.kt` class:
-
-[source,kotlin]
-----
-package org.acme.vehiclerouting.domain
-
-class Location @JsonCreator constructor(val latitude: Double, val longitude: Double) {
- var drivingTimeSeconds: Map? = null
-
- fun getDrivingTimeTo(location: Location): Long {
- if (drivingTimeSeconds == null) {
- return 0
- }
- return drivingTimeSeconds!![location]!!
- }
-
- override fun toString(): String {
- return "$latitude,$longitude"
- }
-}
-----
---
-====
+A `Location` holds a latitude and a longitude.
+Its `getTravelTimeTo(Location)` method returns the driving time to another location.
+That driving time comes from a travel time matrix that the xref:#vrpQuarkusQuickStartMapService[map service] builds before solving starts.
+You never compute distances in your own code.
== Vehicle
-`Vehicle` has a defined route plan with scheduled visits to make.
+`Vehicle` has a route of visits to make.
Each vehicle has a specific departure time and starting location.
It returns to its home location after completing the route and has a maximum capacity that must not be exceeded.
During solving, Timefold Solver updates the `visits` field of the `Vehicle` class to assign a list of visits.
-Because Timefold Solver changes this field, `Vehicle` is a https://timefold.ai/docs/timefold-solver/latest/using-timefold-solver/modeling-planning-problems#planningEntity[_planning entity_]:
+Because Timefold Solver changes this field, `Vehicle` is a xref:domain-modeling/modeling-planning-problems.adoc#planningEntity[_planning entity_]:
image::quickstart/vehicle-routing/vehicleRoutingClassDiagramAnnotated.png[]
Based on the diagram, the `visits` field is a genuine variable that changes during the solving process.
-To ensure that Timefold Solver recognizes it as a https://timefold.ai/docs/timefold-solver/latest/using-timefold-solver/modeling-planning-problems#planningListVariable[sequence of connected variables],
+To ensure that Timefold Solver recognizes it as a xref:domain-modeling/modeling-planning-problems.adoc#planningListVariable[sequence of connected variables],
the field must have an `@PlanningListVariable` annotation indicating that the solver can distribute a subset of the
available visits to it.
-The objective is to create an ordered scheduled visit plan for each vehicle.
+The objective is to create an ordered route for each vehicle.
+
+`allowsUnassignedValues = true` lets the solver leave a visit off every route.
+When the fleet cannot service every visit, for example because it lacks capacity, the solver still produces a plan.
+The plan leaves out the visits that do not fit, instead of breaking a hard constraint to squeeze them in.
-[tabs]
-====
-Java::
-+
---
Create the `src/main/java/org/acme/vehiclerouting/domain/Vehicle.java` class:
-[source,java]
+[source,java,options="nowrap"]
----
package org.acme.vehiclerouting.domain;
-import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
-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.entity.PlanningEntity;
import ai.timefold.solver.core.api.domain.variable.PlanningListVariable;
+import ai.timefold.solver.service.maps.api.model.Location;
@PlanningEntity
public class Vehicle {
@@ -131,16 +62,19 @@ public class Vehicle {
private String id;
private int capacity;
private Location homeLocation;
+ private OffsetDateTime departureTime;
- private LocalDateTime departureTime;
-
- @PlanningListVariable
+ /**
+ * The route of this vehicle: the visits it services, in the order it services them. The
+ * assignment is this list, so a visit that appears in no vehicle's list is unassigned.
+ */
+ @PlanningListVariable(allowsUnassignedValues = true)
private List visits;
public Vehicle() {
}
- public Vehicle(String id, int capacity, Location homeLocation, LocalDateTime departureTime) {
+ public Vehicle(String id, int capacity, Location homeLocation, OffsetDateTime departureTime) {
this.id = id;
this.capacity = capacity;
this.homeLocation = homeLocation;
@@ -148,118 +82,67 @@ public class Vehicle {
this.visits = new ArrayList<>();
}
- // Getters and Setters excluded
-
+ /**
+ * @return the demand of every visit on the route added up; 0 while it is not computed yet
+ */
public int getTotalDemand() {
- int totalDemand = 0;
- for (Visit visit : visits) {
- totalDemand += visit.getDemand();
+ if (visits.isEmpty()) {
+ return 0;
}
- return totalDemand;
+
+ Visit lastVisit = visits.get(visits.size() - 1);
+ Integer cumulativeDemand = lastVisit.getCumulativeDemand();
+ return cumulativeDemand == null ? 0 : cumulativeDemand;
}
+ /**
+ * @return the driving time of the whole route, home location to home location, in seconds;
+ * 0 while it is not computed yet
+ */
public long getTotalDrivingTimeSeconds() {
if (visits.isEmpty()) {
return 0;
}
- long totalDrivingTime = 0;
- Location previousLocation = homeLocation;
+ Visit lastVisit = visits.get(visits.size() - 1);
+ Long cumulativeDrivingTime = lastVisit.getCumulativeDrivingTimeSeconds();
+ if (cumulativeDrivingTime == null) {
+ return 0;
+ }
+ return cumulativeDrivingTime + lastVisit.getLocation().getTravelTimeTo(homeLocation).seconds();
+ }
- for (Visit visit : visits) {
- totalDrivingTime += previousLocation.getDrivingTimeTo(visit.getLocation());
- previousLocation = visit.getLocation();
+ /**
+ * @return the time this vehicle is back at its home location, or its departure time when it has
+ * no visits to make; null while the timings of its last visit are not computed yet
+ */
+ public OffsetDateTime arrivalTime() {
+ if (visits.isEmpty()) {
+ return departureTime;
}
- totalDrivingTime += previousLocation.getDrivingTimeTo(homeLocation);
- return totalDrivingTime;
+ Visit lastVisit = visits.get(visits.size() - 1);
+ OffsetDateTime lastDepartureTime = lastVisit.getDepartureTime();
+ if (lastDepartureTime == null) {
+ return null;
+ }
+ return lastDepartureTime.plusSeconds(lastVisit.getLocation().getTravelTimeTo(homeLocation).seconds());
}
+ // Getters, setters, equals() and hashCode() (based on id) excluded
+
@Override
public String toString() {
return id;
}
}
----
---
-
-Kotlin::
-+
---
-Create the `src/main/kotlin/org/acme/vehiclerouting/domain/Vehicle.kt` class:
-
-[source,kotlin]
-----
-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
-
-@PlanningEntity
-class Vehicle {
- @PlanningId
- lateinit var id: String
- var capacity: Int = 0
- lateinit var homeLocation: Location
- lateinit var departureTime: LocalDateTime
-
- @PlanningListVariable
- var visits: List? = null
-
- constructor()
-
- constructor(id: String, capacity: Int, homeLocation: Location, departureTime: LocalDateTime) {
- this.id = id
- this.capacity = capacity
- this.homeLocation = homeLocation
- this.departureTime = departureTime
- this.visits = ArrayList()
- }
-
- val totalDemand: Long
- get() {
- var totalDemand = 0L
- for (visit in visits!!) {
- totalDemand += visit.demand
- }
- return totalDemand
- }
-
- val totalDrivingTimeSeconds: Long
- get() {
- if (visits!!.isEmpty()) {
- return 0
- }
-
- var totalDrivingTime: Long = 0
- var previousLocation = homeLocation
-
- for (visit in visits!!) {
- totalDrivingTime += previousLocation.getDrivingTimeTo(visit.location!!)
- previousLocation = visit.location!!
- }
- totalDrivingTime += previousLocation.getDrivingTimeTo(homeLocation)
-
- return totalDrivingTime
- }
-
- override fun toString(): String {
- return id
- }
-}
-----
---
-====
The `Vehicle` class has an `@PlanningEntity` annotation,
so Timefold Solver knows that this class changes during solving because it contains one or more planning variables.
Notice the `toString()` method keeps the output short,
-so it is easier to read Timefold Solver's `DEBUG` or `TRACE` log, as shown later.
+so it is easier to read Timefold Solver's `DEBUG` or `TRACE` log.
[NOTE]
====
@@ -275,30 +158,26 @@ A visit includes a destination location, a delivery time window represented by `
a demand that needs to be fulfilled by the vehicle, and a service duration time.
The `Visit` class has an `@PlanningEntity` annotation
-but no genuine variables and is called a https://timefold.ai/docs/timefold-solver/latest/using-timefold-solver/modeling-planning-problems#shadowVariable[shadow entity].
+but no genuine variables, so it is called a xref:domain-modeling/modeling-planning-problems.adoc#shadowVariable[shadow entity].
-[tabs]
-====
-Java::
-+
---
-Create or adjust the `src/main/java/org/acme/vehiclerouting/domain/Visit.java` class:
+Create the `src/main/java/org/acme/vehiclerouting/domain/Visit.java` class:
-[source,java]
+[source,java,options="nowrap"]
----
package org.acme.vehiclerouting.domain;
import java.time.Duration;
-import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Objects;
-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.entity.PlanningEntity;
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.ShadowSources;
import ai.timefold.solver.core.api.domain.variable.ShadowVariable;
-
-import org.acme.vehiclerouting.solver.ArrivalTimeUpdatingVariableListener;
+import ai.timefold.solver.service.maps.api.model.Location;
@PlanningEntity
public class Visit {
@@ -308,24 +187,24 @@ public class Visit {
private String name;
private Location location;
private int demand;
- private LocalDateTime minStartTime;
- private LocalDateTime maxEndTime;
+ private OffsetDateTime minStartTime;
+ private OffsetDateTime maxEndTime;
private Duration serviceDuration;
@InverseRelationShadowVariable(sourceVariableName = "visits")
private Vehicle vehicle;
-
@PreviousElementShadowVariable(sourceVariableName = "visits")
private Visit previousVisit;
-
- @CascadingUpdateShadowVariable(targetMethodName = "updateArrivalTime")
- private LocalDateTime arrivalTime;
+ @ShadowVariable(supplierName = "timingsSupplier")
+ private Timings timings;
+ @ShadowVariable(supplierName = "cumulativeDemandSupplier")
+ private Integer cumulativeDemand;
public Visit() {
}
public Visit(String id, String name, Location location, int demand,
- LocalDateTime minStartTime, LocalDateTime maxEndTime, Duration serviceDuration) {
+ OffsetDateTime minStartTime, OffsetDateTime maxEndTime, Duration serviceDuration) {
this.id = id;
this.name = name;
this.location = location;
@@ -335,188 +214,151 @@ public class Visit {
this.serviceDuration = serviceDuration;
}
- // Getters and Setters excluded
-
- private void updateArrivalTime() {
+ /**
+ * Computes the arrival, start service and departure time, and the driving time so far, in one go:
+ * they are all derived from the same predecessor's timings, so a single supplier keeps them consistent.
+ *
+ * @return null while this visit is unassigned or its predecessor is not timed yet
+ */
+ @ShadowSources({ "vehicle", "previousVisit.timings" })
+ public Timings timingsSupplier() {
if (previousVisit == null && vehicle == null) {
- arrivalTime = null;
- return;
- }
- LocalDateTime departureTime = previousVisit == null ? vehicle.getDepartureTime() : previousVisit.getDepartureTime();
- arrivalTime = departureTime != null ? departureTime.plusSeconds(getDrivingTimeSecondsFromPreviousStandstill()) : null;
- }
-
- public LocalDateTime getDepartureTime() {
- if (arrivalTime == null) {
return null;
}
- return getStartServiceTime().plus(serviceDuration);
- }
-
- public LocalDateTime getStartServiceTime() {
- if (arrivalTime == null) {
+ OffsetDateTime previousDepartureTime =
+ previousVisit == null ? vehicle.getDepartureTime() : previousVisit.getDepartureTime();
+ if (previousDepartureTime == null) {
return null;
}
- return arrivalTime.isBefore(minStartTime) ? minStartTime : arrivalTime;
- }
-
- public boolean isServiceFinishedAfterMaxEndTime() {
- return arrivalTime != null
- && arrivalTime.plus(serviceDuration).isAfter(maxEndTime);
- }
-
- public long getServiceFinishedDelayInMinutes() {
- if (arrivalTime == null) {
- return 0;
- }
- return Duration.between(maxEndTime, arrivalTime.plus(serviceDuration)).toMinutes();
+ long drivingTimeSeconds = getDrivingTimeSecondsFromPreviousStandstill();
+ long previousCumulativeDrivingTimeSeconds =
+ previousVisit == null ? 0 : previousVisit.getCumulativeDrivingTimeSeconds();
+ var arrivalTime = previousDepartureTime.plusSeconds(drivingTimeSeconds);
+ var startServiceTime = arrivalTime.isBefore(minStartTime) ? minStartTime : arrivalTime;
+ return new Timings(arrivalTime, startServiceTime, startServiceTime.plus(serviceDuration),
+ previousCumulativeDrivingTimeSeconds + drivingTimeSeconds);
}
- public long getDrivingTimeSecondsFromPreviousStandstill() {
+ /**
+ * @return the demand of this visit and every visit before it on the route added up,
+ * or null while this visit is unassigned
+ */
+ @ShadowSources({ "vehicle", "previousVisit.cumulativeDemand" })
+ public Integer cumulativeDemandSupplier() {
if (vehicle == null) {
- throw new IllegalStateException(
- "This method must not be called when the shadow variables are not initialized yet.");
+ return null;
}
if (previousVisit == null) {
- return vehicle.getHomeLocation().getDrivingTimeTo(location);
+ return demand;
}
- return previousVisit.getLocation().getDrivingTimeTo(location);
+ Integer previousCumulativeDemand = previousVisit.getCumulativeDemand();
+ return previousCumulativeDemand == null ? null : previousCumulativeDemand + demand;
}
- @Override
- public String toString() {
- return id;
+ public OffsetDateTime getArrivalTime() {
+ return timings == null ? null : timings.arrivalTime();
}
-}
-----
---
-
-Kotlin::
-+
---
-Create the `src/main/kotlin/org/acme/vehiclerouting/domain/Visit.kt` class:
-
-[source,kotlin]
-----
-package org.acme.vehiclerouting.domain
-import java.time.Duration
-import java.time.LocalDateTime
+ public OffsetDateTime getStartServiceTime() {
+ return timings == null ? null : timings.startServiceTime();
+ }
-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.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
+ public OffsetDateTime getDepartureTime() {
+ return timings == null ? null : timings.departureTime();
+ }
-import org.acme.vehiclerouting.solver.ArrivalTimeUpdatingVariableListener
+ /**
+ * @return the driving time from the vehicle's home location up to this visit, in seconds,
+ * or null while this visit is not timed yet
+ */
+ public Long getCumulativeDrivingTimeSeconds() {
+ return timings == null ? null : timings.cumulativeDrivingTimeSeconds();
+ }
-@PlanningEntity
-class Visit {
- @PlanningId
- lateinit var id: String
- lateinit var name: String
- lateinit var location: Location
- var demand: Int = 0
- lateinit var minStartTime: LocalDateTime
- lateinit var maxEndTime: LocalDateTime
- lateinit var serviceDuration: Duration
+ /**
+ * @return the demand of this visit and every visit before it on the route added up,
+ * or null while this visit is unassigned
+ */
+ public Integer getCumulativeDemand() {
+ return cumulativeDemand;
+ }
- @InverseRelationShadowVariable(sourceVariableName = "visits")
- private var vehicle: Vehicle? = null
+ public boolean isAssigned() {
+ return vehicle != null;
+ }
- @PreviousElementShadowVariable(sourceVariableName = "visits")
- var previousVisit: Visit? = null
-
- @CascadingUpdateShadowVariable(targetMethodName = "updateArrivalTime")
- var arrivalTime: LocalDateTime? = null
-
- constructor()
-
- constructor(
- id: String, name: String, location: Location, demand: Int,
- minStartTime: LocalDateTime, maxEndTime: LocalDateTime, serviceDuration: Duration
- ) {
- this.id = id
- this.name = name
- this.location = location
- this.demand = demand
- this.minStartTime = minStartTime
- this.maxEndTime = maxEndTime
- this.serviceDuration = serviceDuration
+ public boolean isServiceFinishedAfterMaxEndTime() {
+ var serviceStart = getStartServiceTime();
+ return serviceStart != null
+ && serviceStart.plus(serviceDuration).isAfter(maxEndTime);
}
- private fun updateArrivalTime() {
- if (previousVisit == null && vehicle == null) {
- arrivalTime = null
- return
+ public long getServiceFinishedDelayInMinutes() {
+ var departureTime = getDepartureTime();
+ if (departureTime == null) {
+ return 0;
}
- val departureTime = previousVisit?.departureTime ?: vehicle?.departureTime
- arrivalTime = departureTime?.plusSeconds(getDrivingTimeSecondsFromPreviousStandstill())
+ return roundDurationToNextOrEqualMinutes(Duration.between(maxEndTime, departureTime));
}
- val departureTime: LocalDateTime?
- get() {
- if (arrivalTime == null) {
- return null
- }
- return startServiceTime!!.plus(serviceDuration)
+ private static long roundDurationToNextOrEqualMinutes(Duration duration) {
+ var remainder = duration.minus(duration.truncatedTo(ChronoUnit.MINUTES));
+ var minutes = duration.toMinutes();
+ if (remainder.equals(Duration.ZERO)) {
+ return minutes;
}
+ return minutes + 1;
+ }
- val startServiceTime: LocalDateTime?
- get() {
- if (arrivalTime == null) {
- return null
- }
- return if (arrivalTime!!.isBefore(minStartTime)) minStartTime else arrivalTime
+ public long getDrivingTimeSecondsFromPreviousStandstill() {
+ if (vehicle == null) {
+ throw new IllegalStateException(
+ "This method must not be called when the shadow variables are not initialized yet.");
}
-
- val isServiceFinishedAfterMaxEndTime: Boolean
- get() = (arrivalTime != null
- && arrivalTime!!.plus(serviceDuration).isAfter(maxEndTime))
-
- val serviceFinishedDelayInMinutes: Long
- get() {
- if (arrivalTime == null) {
- return 0
- }
- return Duration.between(maxEndTime, arrivalTime!!.plus(serviceDuration)).toMinutes()
+ if (previousVisit == null) {
+ return vehicle.getHomeLocation().getTravelTimeTo(location).seconds();
}
+ return previousVisit.getLocation().getTravelTimeTo(location).seconds();
+ }
- val drivingTimeSecondsFromPreviousStandstill: Long
- get() {
- if (vehicle == null) {
- throw IllegalStateException(
- "This method must not be called when the shadow variables are not initialized yet."
- )
- }
- if (previousVisit == null) {
- return vehicle!!.homeLocation.getDrivingTimeTo(location)
- }
- return previousVisit!!.location.getDrivingTimeTo((location))
+ /**
+ * @return the same driving time as {@link #getDrivingTimeSecondsFromPreviousStandstill()}, but
+ * null instead of an exception while this visit is still unassigned
+ */
+ public Long getDrivingTimeSecondsFromPreviousStandstillOrNull() {
+ if (vehicle == null) {
+ return null;
}
+ return getDrivingTimeSecondsFromPreviousStandstill();
+ }
+
+ // Getters, setters, equals() and hashCode() (based on id) excluded
- override fun toString(): String {
- return id
+ @Override
+ public String toString() {
+ return id;
+ }
+
+ /**
+ * The times at which this visit is serviced, all derived from the route this visit is in.
+ *
+ * @param cumulativeDrivingTimeSeconds the driving time from the vehicle's home location up to this visit
+ */
+ public record Timings(OffsetDateTime arrivalTime, OffsetDateTime startServiceTime,
+ OffsetDateTime departureTime, long cumulativeDrivingTimeSeconds) {
}
}
----
---
-====
-Some methods are annotated with `@InverseRelationShadowVariable`, `@PreviousElementShadowVariable` and `@CascadingUpdateShadowVariable`.
-They are called https://timefold.ai/docs/timefold-solver/latest/using-timefold-solver/modeling-planning-problems#shadowVariable[shadow variables],
-and because Timefold Solver changes them,
-`Visit` is a https://timefold.ai/docs/timefold-solver/latest/using-timefold-solver/modeling-planning-problems#planningEntity[_planning entity_]:
-
-image::quickstart/vehicle-routing/vehicleRoutingCompleteClassDiagramAnnotated.png[]
+The fields `vehicle`, `previousVisit`, `timings` and `cumulativeDemand` are
+xref:domain-modeling/modeling-planning-problems.adoc#shadowVariable[shadow variables].
+Timefold Solver updates them automatically whenever the `visits` list of a vehicle changes.
The field `vehicle` has an `@InverseRelationShadowVariable` annotation,
creating a bi-directional relationship with the `Vehicle`.
-The function returns a reference to the `Vehicle` where the visit is scheduled.
+It holds a reference to the `Vehicle` where the visit is scheduled, or `null` if the visit is unassigned.
Let's say the visit `Ann` was scheduled to the vehicle `V1` during the solving process.
-The method returns a reference of `V1`.
+The field then holds a reference to `V1`.
The field `previousVisit` is annotated with `@PreviousElementShadowVariable`.
The solver will update this field with a reference of the visit preceding the current visit instance.
@@ -525,7 +367,26 @@ the `previousVisit` field will be filled with `Ann` for the visit of `Beth`.
NOTE: `@NextElementShadowVariable` also exists, which can be used to get a reference to the successor element.
-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 `timings` field is a xref:domain-modeling/modeling-planning-problems.adoc#customShadowVariable[custom shadow variable].
+`@ShadowVariable(supplierName = "timingsSupplier")` tells Timefold Solver to compute it with the `timingsSupplier()` method.
+`@ShadowSources` on that method lists what the result depends on: the `vehicle` of this visit and the `timings` of the previous visit.
+Whenever one of those changes, Timefold Solver recalculates the `timings` of this visit, and in turn those of every visit after it on the route.
+
+The arrival time, the start of service and the departure time all derive from the departure time of the previous stop.
+So the `Timings` record computes them together, in a single shadow variable
+(see xref:domain-modeling/modeling-planning-problems.adoc#customShadowVariableMultipleVariables[updating multiple fields at once]).
+A vehicle that arrives before `minStartTime` waits, so servicing starts at `minStartTime` rather than at the arrival time.
+`Timings` also keeps the cumulative driving time: the driving time from the vehicle's home location up to this visit.
+It adds the driving time from the previous stop to the cumulative driving time of the previous visit,
+so it builds on the same `previousVisit.timings` source.
+
+The `cumulativeDemand` field is a second custom shadow variable, computed by `cumulativeDemandSupplier()`.
+It holds the demand of this visit plus the cumulative demand of the previous visit,
+so it depends on `vehicle` and `previousVisit.cumulativeDemand`.
+The demand does not depend on the timings, so it is a separate shadow variable:
+a change that only affects the timings, such as a different departure time, does not recalculate it.
+
+Because every visit carries these running totals, the last visit on a route holds the totals of the whole route.
+That is why `Vehicle.getTotalDemand()` and `Vehicle.getTotalDrivingTimeSeconds()` only look at the last visit
+instead of looping over the entire route.
+`getTotalDrivingTimeSeconds()` still adds the drive from the last visit back to the home location.
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 a9e80b126f..caf824981f 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
@@ -3,41 +3,41 @@
A `VehicleRoutePlan` wraps all `Vehicle` and `Visit` instances of a single dataset.
Furthermore, because it contains all vehicles and visits, each with a specific planning variable state,
-it is a https://timefold.ai/docs/timefold-solver/latest/using-timefold-solver/modeling-planning-problems#planningProblemAndPlanningSolution[_planning solution_]
+it is a xref:domain-modeling/modeling-planning-problems.adoc#planningProblemAndPlanningSolution[_planning solution_]
and it has a score:
-* If visits are still unassigned, then it is an _uninitialized_ solution.
* If it breaks hard constraints, then it is an _infeasible_ solution,
-for example, a solution with the score `-2hard/-3soft`.
+for example, a solution with the score `-2hard/0medium/-3soft`.
* If it adheres to all hard constraints, then it is a _feasible_ solution,
-for example, a solution with the score `0hard/-7soft`.
+for example, a solution with the score `0hard/-30medium/-7soft`.
+* If it is feasible and every visit is assigned, the medium score is `0`,
+for example, `0hard/0medium/-7soft`.
+
+`VehicleRoutePlan` implements `LocationsAwareSolverModel`.
+This interface extends the service module's `SolverModel`
+and tells the xref:#vrpQuarkusQuickStartMapService[map service] which locations to include in the travel time matrix.
-[tabs]
-====
-Java::
-+
---
Create the `src/main/java/org/acme/vehiclerouting/domain/VehicleRoutePlan.java` class:
-[source,java]
+[source,java,options="nowrap"]
----
package org.acme.vehiclerouting.domain;
import java.util.List;
+import java.util.Optional;
import java.util.stream.Stream;
+import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides;
import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
import ai.timefold.solver.core.api.domain.solution.PlanningScore;
import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
import ai.timefold.solver.core.api.domain.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;
+import ai.timefold.solver.core.api.score.HardMediumSoftScore;
+import ai.timefold.solver.service.maps.api.model.Location;
+import ai.timefold.solver.service.maps.service.integration.api.LocationsAwareSolverModel;
@PlanningSolution
-public class VehicleRoutePlan {
+public class VehicleRoutePlan implements LocationsAwareSolverModel {
@PlanningEntityCollectionProperty
private List vehicles;
@@ -47,99 +47,61 @@ public class VehicleRoutePlan {
private List visits;
@PlanningScore
- private HardSoftScore score;
+ private HardMediumSoftScore score;
- // Fields and constructors used for visualization excluded
+ private ConstraintWeightOverrides constraintWeightOverrides = ConstraintWeightOverrides.none();
+
+ // Reported back by the map service: the locations it could not resolve, if any.
+ private List locationsNotInMap = List.of();
public VehicleRoutePlan() {
}
- public VehicleRoutePlan(String name,
- List vehicles,
- List visits) {
- this.name = name;
+ public VehicleRoutePlan(List vehicles, List visits) {
this.vehicles = vehicles;
this.visits = visits;
-
- // Enhance locations with a pre-calculated driving time map
- List locations = Stream.concat(
- vehicles.stream().map(Vehicle::getHomeLocation),
- visits.stream().map(Visit::getLocation)).toList();
-
- DrivingTimeCalculator drivingTimeCalculator = HaversineDrivingTimeCalculator.getInstance();
- drivingTimeCalculator.initDrivingTimeMaps(locations);
}
- // Getters and Setters excluded
-}
-----
---
-
-Kotlin::
-+
---
-Create the `src/main/kotlin/org/acme/vehiclerouting/domain/VehicleRoutePlan.kt` class:
-
-[source,kotlin]
-----
-package org.acme.vehiclerouting.domain;
-
-import java.time.LocalDateTime
-import java.util.stream.Stream
-
-import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty
-import ai.timefold.solver.core.api.domain.solution.PlanningScore
-import ai.timefold.solver.core.api.domain.solution.PlanningSolution
-import ai.timefold.solver.core.api.domain.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
-
-@PlanningSolution
-class VehicleRoutePlan {
- lateinit var name: String
-
- @PlanningEntityCollectionProperty
- var vehicles: List? = null
- private set
-
- @PlanningEntityCollectionProperty
- @ValueRangeProvider
- var visits: List? = null
- private set
+ public long getTotalDrivingTimeSeconds() {
+ return vehicles == null ? 0 : vehicles.stream().mapToLong(Vehicle::getTotalDrivingTimeSeconds).sum();
+ }
- @PlanningScore
- var score: HardSoftScore? = null
+ // ── LocationsAwareSolverModel ──
- // Fields and constructors used for visualization excluded
+ @Override
+ public List getLocations() {
+ if (vehicles == null || visits == null) {
+ return List.of();
+ }
+ return Stream.concat(
+ vehicles.stream().map(Vehicle::getHomeLocation),
+ visits.stream().map(Visit::getLocation)).toList();
+ }
- constructor()
+ // Every solve builds its own one-off matrix rather than reusing a named, pre-built one.
+ @Override
+ public Optional getLocationSetName() {
+ return Optional.empty();
+ }
- constructor(
- name: String,
- vehicles: List,
- visits: List
- ) {
- this.name = name
- this.vehicles = vehicles
- this.visits = visits
+ @Override
+ public void setLocationsNotInMap(List locationsNotInMap) {
+ this.locationsNotInMap = locationsNotInMap == null ? List.of() : locationsNotInMap;
+ }
- // Enhance locations with a pre-calculated driving time map
- val locations = Stream.concat(
- vehicles.stream().map({ obj: Vehicle -> obj.homeLocation }),
- visits.stream().map({ obj: Visit -> obj.location })
- ).toList()
+ @Override
+ public List getLocationsNotInMap() {
+ return locationsNotInMap;
+ }
- val drivingTimeCalculator: DrivingTimeCalculator = HaversineDrivingTimeCalculator.INSTANCE
- drivingTimeCalculator.initDrivingTimeMaps(locations)
+ @Override
+ public ConstraintWeightOverrides getConstraintWeightOverrides() {
+ return constraintWeightOverrides;
}
+
+ // Other getters and setters excluded
}
----
---
-====
-
The `VehicleRoutePlan` class has an `@PlanningSolution` annotation,
so Timefold Solver knows that this class contains all of the input and output data.
@@ -149,22 +111,26 @@ Specifically, these classes are the input of the problem:
* The `vehicles` field with all vehicles
** This is a list of planning entities, because they change during solving.
** For each `Vehicle`:
-*** The value of the `visits` is typically still `empty`, so unassigned.
+*** The value of the `visits` is typically still empty, so unassigned.
It is a planning variable.
*** The other fields, such as `capacity`, `homeLocation` and `departureTime`, are filled in.
These fields are problem properties.
* The `visits` field with all visits
** This is a list of planning entities, because they change during solving.
** For each `Visit`:
-*** The values of `vehicle`, `previousVisit`, `nextVisit`, `arrivalTime` are typically still `null` for a fresh solution.
-They are planning shadow variables.
+*** The values of `vehicle`, `previousVisit` and `timings` are typically still `null` for a fresh solution.
+They are shadow variables.
*** The other fields, such as `name`, `location` and `demand`, are filled in.
These fields are problem properties.
However, this class is also the output of the solution:
-* The `vehicles` field for which each `Vehicle` instance has non-null `visits` field after solving.
-* The `score` field that represents the quality of the output solution, for example, `0hard/-5soft`.
+* The `vehicles` field for which each `Vehicle` instance has its `visits` filled in after solving.
+* The `score` field that represents the quality of the output solution, for example, `0hard/0medium/-5soft`.
+
+`getConstraintWeightOverrides()` is required by the `SolverModel` interface.
+The xref:#vrpQuarkusQuickStartModelConvertor[model convertor] fills it in
+when a request overrides a constraint weight.
== The value range providers
@@ -173,242 +139,38 @@ It holds the `Visit` instances which Timefold Solver can pick from to assign to
The `visits` field has an `@ValueRangeProvider` annotation to connect the `@PlanningListVariable` with the `@ValueRangeProvider`,
by matching the type of the planning list variable with the type returned by the xref:domain-modeling/modeling-planning-problems.adoc#planningValueRangeProvider[value range provider].
-== Distance calculation
+[#vrpQuarkusQuickStartMapService]
+== Driving times from the map service
-A matrix of distances between each location is typically calculated before starting the solver.
-First create a contract for driving time calculation:
+A matrix of driving times between each pair of locations has to be available before the solver starts.
+You do not build that matrix yourself: the map service of the service module builds it.
-[tabs]
-====
-Java::
-+
---
-Create the `src/main/java/org/acme/vehiclerouting/domain/geo/DrivingTimeCalculator.java` interface:
+Before every solve, the service module xref:running-timefold-solver/service/model-enrichment.adoc#solverModelEnrichment[enriches] the solver model.
+Because `VehicleRoutePlan` implements `LocationsAwareSolverModel`, the map service is one of those enrichers:
-[source,java]
-----
-package org.acme.vehiclerouting.domain.geo;
+* `getLocations()` returns every location the matrix needs to cover:
+every vehicle's home location plus every visit's location.
+* `getLocationSetName()` returns empty, so each solve builds its own one-off matrix
+rather than reusing a named, pre-built one.
+* `setLocationsNotInMap()` lets the map service report back any locations it could not resolve,
+so the model retains that information instead of silently dropping it.
-import java.util.Collection;
-import java.util.Map;
-import java.util.function.Function;
-import java.util.stream.Collectors;
+After that, `Location.getTravelTimeTo(otherLocation)` returns the driving time between any two of those locations.
+`Vehicle.getTotalDrivingTimeSeconds()` and `Visit.getDrivingTimeSecondsFromPreviousStandstill()` both rely on it.
-import org.acme.vehiclerouting.domain.Location;
+Two properties control how the matrix gets built:
-public interface DrivingTimeCalculator {
-
- long calculateDrivingTime(Location from, Location to);
-
- default Map> calculateBulkDrivingTime(
- Collection fromLocations,
- Collection toLocations) {
- return fromLocations.stream().collect(Collectors.toMap(
- Function.identity(),
- from -> toLocations.stream().collect(Collectors.toMap(
- Function.identity(),
- to -> calculateDrivingTime(from, to)))));
- }
-
- default void initDrivingTimeMaps(Collection locations) {
- Map> drivingTimeMatrix = calculateBulkDrivingTime(locations, locations);
- locations.forEach(location -> location.setDrivingTimeSeconds(drivingTimeMatrix.get(location)));
- }
-}
+[source,properties,options="nowrap"]
----
---
-
-Kotlin::
-+
---
-Create the `src/main/kotlin/org/acme/vehiclerouting/domain/geo/DrivingTimeCalculator.kt` interface:
-
-[source,kotlin]
+timefold.platform.map-service.use-remote=false
+timefold.platform.map-service.enable-fallback=true
----
-package org.acme.vehiclerouting.domain.geo
-
-import org.acme.vehiclerouting.domain.Location
-import java.util.function.Function
-import java.util.stream.Collectors
-
-interface DrivingTimeCalculator {
-
- fun calculateDrivingTime(from: Location, to: Location): Long
-
- fun calculateBulkDrivingTime(
- fromLocations: Collection,
- toLocations: Collection
- ): Map> {
- return fromLocations.stream().collect(
- Collectors.toMap(
- Function.identity()
- ) { from: Location ->
- toLocations.stream()
- .collect(
- Collectors.toMap(
- Function.identity(),
- { to: Location ->
- calculateDrivingTime(
- from,
- to
- )
- })
- )
- }
- )
- }
- fun initDrivingTimeMaps(locations: Collection) {
- val drivingTimeMatrix = calculateBulkDrivingTime(locations, locations)
- locations.forEach { location: Location ->
- location.drivingTimeSeconds = drivingTimeMatrix[location]
- }
- }
-}
-----
---
-====
+* `use-remote` switches between the remote map service of the Timefold Platform,
+which uses real road-network driving times, and a local computation.
+* `enable-fallback` allows falling back to the local computation when the remote service is unavailable.
-Then create an implementation using Haversine method:
-
-[tabs]
-====
-Java::
-+
---
-Create the `src/main/java/org/acme/vehiclerouting/domain/geo/HaversineDrivingTimeCalculator.java` class:
-
-[source,java]
-----
-package org.acme.vehiclerouting.domain.geo;
-
-import org.acme.vehiclerouting.domain.Location;
-
-public final class HaversineDrivingTimeCalculator implements DrivingTimeCalculator {
-
- private static final HaversineDrivingTimeCalculator INSTANCE = new HaversineDrivingTimeCalculator();
-
- public static final int AVERAGE_SPEED_KMPH = 50;
-
- private static final int EARTH_RADIUS_IN_M = 6371000;
- private static final int TWICE_EARTH_RADIUS_IN_M = 2 * EARTH_RADIUS_IN_M;
-
- static long metersToDrivingSeconds(long meters) {
- return Math.round((double) meters / AVERAGE_SPEED_KMPH * 3.6);
- }
-
- public static synchronized HaversineDrivingTimeCalculator getInstance() {
- return INSTANCE;
- }
-
- private HaversineDrivingTimeCalculator() {
- }
-
- @Override
- public long calculateDrivingTime(Location from, Location to) {
- if (from.equals(to)) {
- return 0L;
- }
-
- CartesianCoordinate fromCartesian = locationToCartesian(from);
- CartesianCoordinate toCartesian = locationToCartesian(to);
- return metersToDrivingSeconds(calculateDistance(fromCartesian, toCartesian));
- }
-
- private long calculateDistance(CartesianCoordinate from, CartesianCoordinate to) {
- if (from.equals(to)) {
- return 0L;
- }
-
- double dX = from.x - to.x;
- double dY = from.y - to.y;
- double dZ = from.z - to.z;
- double r = Math.sqrt((dX * dX) + (dY * dY) + (dZ * dZ));
- return Math.round(TWICE_EARTH_RADIUS_IN_M * Math.asin(r));
- }
-
- private CartesianCoordinate locationToCartesian(Location location) {
- double latitudeInRads = Math.toRadians(location.getLatitude());
- double longitudeInRads = Math.toRadians(location.getLongitude());
- // Cartesian coordinates, normalized for a sphere of diameter 1.0
- double cartesianX = 0.5 * Math.cos(latitudeInRads) * Math.sin(longitudeInRads);
- double cartesianY = 0.5 * Math.cos(latitudeInRads) * Math.cos(longitudeInRads);
- double cartesianZ = 0.5 * Math.sin(latitudeInRads);
- return new CartesianCoordinate(cartesianX, cartesianY, cartesianZ);
- }
-
- private record CartesianCoordinate(double x, double y, double z) {
-
- }
-}
-----
---
-
-Kotlin::
-+
---
-Create the `src/main/kotlin/org/acme/vehiclerouting/domain/geo/HaversineDrivingTimeCalculator.kt` class:
-
-[source,kotlin]
-----
-package org.acme.vehiclerouting.domain.geo
-
-import kotlin.math.asin
-import kotlin.math.sqrt
-import kotlin.math.cos
-import kotlin.math.sin
-
-import org.acme.vehiclerouting.domain.Location
-
-class HaversineDrivingTimeCalculator private constructor() : DrivingTimeCalculator {
- override fun calculateDrivingTime(from: Location, to: Location): Long {
- if (from == to) {
- return 0L
- }
-
- val fromCartesian = locationToCartesian(from)
- val toCartesian = locationToCartesian(to)
- return metersToDrivingSeconds(calculateDistance(fromCartesian, toCartesian))
- }
-
- private fun calculateDistance(from: CartesianCoordinate, to: CartesianCoordinate): Long {
- if (from == to) {
- return 0L
- }
-
- val dX = from.x - to.x
- val dY = from.y - to.y
- val dZ = from.z - to.z
- val r: Double = sqrt((dX * dX) + (dY * dY) + (dZ * dZ))
- return Math.round(TWICE_EARTH_RADIUS_IN_M * asin(r))
- }
-
- private fun locationToCartesian(location: Location): CartesianCoordinate {
- val latitudeInRads = Math.toRadians(location.latitude)
- val longitudeInRads = Math.toRadians(location.longitude)
- // Cartesian coordinates, normalized for a sphere of diameter 1.0
- val cartesianX: Double = 0.5 * cos(latitudeInRads) * sin(longitudeInRads)
- val cartesianY: Double = 0.5 * cos(latitudeInRads) * cos(longitudeInRads)
- val cartesianZ: Double = 0.5 * sin(latitudeInRads)
- return CartesianCoordinate(cartesianX, cartesianY, cartesianZ)
- }
-
- private data class CartesianCoordinate(val x: Double, val y: Double, val z: Double)
- companion object {
- @JvmStatic
- @get:Synchronized
- val INSTANCE: HaversineDrivingTimeCalculator = HaversineDrivingTimeCalculator()
-
- const val AVERAGE_SPEED_KMPH: Int = 50
-
- private const val EARTH_RADIUS_IN_M = 6371000
- private const val TWICE_EARTH_RADIUS_IN_M = 2 * EARTH_RADIUS_IN_M
-
- fun metersToDrivingSeconds(meters: Long): Long {
- return Math.round(meters.toDouble() / AVERAGE_SPEED_KMPH * 3.6)
- }
- }
-}
-----
---
-====
\ No newline at end of file
+Running locally, you use the local computation.
+When you xref:deploying-to-platform/guide.adoc[deploy the model to the Timefold Platform],
+the https://docs.timefold.ai/timefold-platform/latest/how-tos/maps-service[maps service] of the platform
+provides real road-network driving times without any change to your code.