From a89be5327abfbd08f7cc79690ade07fa534cde36 Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Wed, 23 Sep 2026 21:55:49 +0200 Subject: [PATCH 1/6] docs: update vehicle routing use case guide --- docs/src/modules/ROOT/pages/_attributes.adoc | 2 +- .../quarkus-vehicle-routing-quickstart.adoc | 1066 +++++++---------- .../vehicle-routing-api.adoc | 387 ++++++ .../vehicle-routing-constraints.adoc | 285 +++-- .../vehicle-routing-model.adoc | 555 ++++----- .../vehicle-routing-solution.adoc | 413 ++----- 6 files changed, 1283 insertions(+), 1425 deletions(-) create mode 100644 docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-api.adoc diff --git a/docs/src/modules/ROOT/pages/_attributes.adoc b/docs/src/modules/ROOT/pages/_attributes.adoc index f5ec1b01a2a..841013153c2 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 d2059da4ee5..b2962ed381e 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.acme vehicle-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=16 +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,117 @@ 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"] +To see which constraints contribute to the score, call the score analysis endpoint: + +[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 +343,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: - -Add some dependencies in your `pom.xml`: -[source,xml] ----- - - io.quarkus - quarkus-junit - test - ----- - -Then create the test itself: +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. -[tabs] -==== -Java:: -+ --- -Create the `src/test/java/org/acme/vehiclerouting/solver/VehicleRoutingConstraintProviderTest.java` class: +Create the `src/test/java/org/acme/vehiclerouting/solver/VehicleRoutePlanConstraintProviderTest.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 - -In a JUnit test, generate a test dataset and send it to the `VehicleRoutePlanResource` to solve. - -Add some dependencies in your `pom.xml`: -[source,xml] ----- - - io.rest-assured - rest-assured - test - - - org.awaitility - awaitility - test - ----- +==== Test the service -Then create the test itself: +In a JUnit test, send a small dataset to the REST API and wait until the run finishes. -[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 +559,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 00000000000..3a55f002335 --- /dev/null +++ b/docs/src/modules/ROOT/pages/quickstart/quarkus-vehicle-routing/vehicle-routing-api.adoc @@ -0,0 +1,387 @@ +[#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) -> first, 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 9549061b6e7..6031344e5cb 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 service module returns these justifications in the score analysis, +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 51866124a30..430b5c2ebe0 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 a9e80b126f4..07597b0d2ab 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,39 @@ 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 one is disabled or unavailable. +The local computation estimates driving times from the great-circle (Haversine) distance. -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. From d6a624203fa7d86e461951970e63c5d8818225b2 Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Tue, 29 Sep 2026 10:46:53 +0200 Subject: [PATCH 2/6] docs: no enterprise by default --- .../quarkus-vehicle-routing-quickstart.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b2962ed381e..f82a741a3e8 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 @@ -192,7 +192,7 @@ timefold.model.contact.url=https://acme.com # Model settings ######################## -timefold.model.max-thread-count=16 +timefold.model.max-thread-count=NONE timefold.model.default-config.max-thread-count=1 timefold.platform.map-service.use-remote=false timefold.platform.map-service.enable-fallback=true From 2fb22d6803c1b781e83f3aac044b29e2631dce90 Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Tue, 29 Sep 2026 10:49:00 +0200 Subject: [PATCH 3/6] docs: small clarifications --- .../quarkus-vehicle-routing-quickstart.adoc | 3 +-- .../quarkus-vehicle-routing/vehicle-routing-constraints.adoc | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) 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 f82a741a3e8..666f045a643 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 @@ -319,8 +319,7 @@ For example, visits `5`, `1`, and `4` were scheduled, in that order, to vehicle The exact numbers depend on the driving times. Running locally, the map service estimates them from the straight-line distance. -To see which constraints contribute to the score, call the score analysis endpoint: - +With the Enterprise Edition enabled, call the score analysis endpoint to see which constraints contribute to the score: [source,shell] ---- $ curl http://localhost:8080/v1/route-plans/7f3a91bc-4e2d-4c1a-b8f6-1234567890ab/score-analysis 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 6031344e5cb..88562aa565a 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 @@ -95,7 +95,7 @@ public final class VehicleRoutePlanConstraintGroup { Every constraint match can carry a xref:constraints-and-score/score-calculation.adoc#constraintStreamsCustomizingJustifications[justification]: an object that explains why the constraint matched. -The service module returns these justifications in the score analysis, +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`. From e8b4d49c91bc2c2dfcccd5f9a7f7836d0ab3c2c2 Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Tue, 29 Sep 2026 11:12:22 +0200 Subject: [PATCH 4/6] docs: small clarifications --- .../quarkus-vehicle-routing-quickstart.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 666f045a643..c4ea0142514 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 @@ -192,7 +192,7 @@ timefold.model.contact.url=https://acme.com # Model settings ######################## -timefold.model.max-thread-count=NONE +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 From bcf8a3cbc6aa04c2e27a1d84e0efff3cbe95909e Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Sun, 4 Oct 2026 15:01:12 +0200 Subject: [PATCH 5/6] Improve duplicate visit ID handling in visitMap Change handling of duplicate visit IDs in visitMap collection. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../quarkus-vehicle-routing/vehicle-routing-api.adoc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 3a55f002335..ff08f039231 100644 --- 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 @@ -277,7 +277,9 @@ public class VehicleRoutePlanModelConvertor implements Optional lastModelOutput) { Map visitMap = modelInput.visits().stream() .map(VehicleRoutePlanModelConvertor::toVisit) - .collect(Collectors.toMap(Visit::getId, visit -> visit, (first, second) -> first, LinkedHashMap::new)); + .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(); From 90787cf4dd780679c288bac125a69365bf7110f0 Mon Sep 17 00:00:00 2001 From: Tom Cools Date: Mon, 5 Oct 2026 09:38:04 +0200 Subject: [PATCH 6/6] Update 'enable-fallback' description for clarity Clarified the description of the 'enable-fallback' option to specify that it applies when the remote service is unavailable. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../quarkus-vehicle-routing/vehicle-routing-solution.adoc | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 07597b0d2ab..caf824981f1 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 @@ -168,8 +168,7 @@ timefold.platform.map-service.enable-fallback=true * `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 one is disabled or unavailable. -The local computation estimates driving times from the great-circle (Haversine) distance. +* `enable-fallback` allows falling back to the local computation when the remote service is unavailable. Running locally, you use the local computation. When you xref:deploying-to-platform/guide.adoc[deploy the model to the Timefold Platform],