Classic VRPs

This notebook shows how to use PyVRP to solve two classic variants of the VRP: the capacitated vehicle routing problem (CVRP), and the vehicle routing problem with time windows (VRPTW). It builds on the tutorial by solving much larger instances, and going into more detail about the various plotting tools and diagnostics available in PyVRP.

A CVRP instance is defined on a complete graph \(G=(V,A)\), where \(V\) is the vertex set and \(A\) is the arc set. The vertex set \(V\) is partitioned into \(V=\{0\} \cup V_c\), where \(0\) represents the depot and \(V_c=\{1, \dots, n\}\) denotes the set of \(n\) customers. Each arc \((i, j) \in A\) has a weight \(d_{ij} \ge 0\) that represents the travel distance from \(i \in V\) to \(j \in V\). Each customer \(i \in V_c\) has a demand \(q_{i} \ge 0\). The objective is to find a feasible solution that minimises the total distance.

A VRPTW instance additionally incorporates time aspects into the problem. For the sake of exposition we assume the travel duration \(t_{ij} \ge 0\) is equal to the travel distance \(d_{ij}\) in this notebook. Each customer \(i \in V_c\) has a service time \(s_{i} \ge 0\) and a (hard) time window \(\left[e_i, l_i\right]\) that denotes the earliest and latest time that service can start. A vehicle is allowed to arrive at a customer location before the beginning of the time window, but it must wait for the window to open to start the delivery. Each vehicle must return to the depot before the end of the depot time window \(H\). The objective is to find a feasible solution that minimises the total distance.

Let’s first import what we will use in this notebook.

[1]:
import matplotlib.pyplot as plt
from tabulate import tabulate
from vrplib import read_solution

from pyvrp import Model, read
from pyvrp.plotting import (
    plot_coordinates,
    plot_instance,
    plot_result,
    plot_route_schedule,
)
from pyvrp.stop import MaxIterations, MaxRuntime

The capacitated VRP

Reading the instance

We will solve the X-n439-k37 instance, which is part of the X instance set that is widely used to benchmark CVRP algorithms. The function pyvrp.read reads the instance file and converts it to a ProblemData instance. We pass the argument round_func="round" to compute the Euclidean distances rounded to the nearest integral, which is the convention for the X benchmark set. We also load the best known solution to evaluate our solver later on.

[2]:
INSTANCE = read("data/X-n439-k37.vrp", round_func="round")
BKS = read_solution("data/X-n439-k37.sol")

Let’s plot the instance and see what we have.

[3]:
_, ax = plt.subplots(figsize=(8, 8))
plot_coordinates(INSTANCE, ax=ax)
plt.tight_layout()
../_images/examples_basic_vrps_6_0.png

Solving the instance

We will again use the Model interface to solve the instance. The Model interface supports a convenient from_data method that can be used to instantiate a model from a known ProblemData object.

[4]:
model = Model.from_data(INSTANCE)
result = model.solve(stop=MaxIterations(2000), seed=42, display=False)
print(result)
Solution results
================
    # routes: 37
   # clients: 438
   objective: 36693
    distance: 36693
    duration: 36693
# iterations: 2000
    run-time: 51.45 seconds

Routes
------
Route #1: 348 411 3 169 2 8 311 434 105 236 217 414
Route #2: 370 133 425 223 349 410 267 386 299 400 97 72
Route #3: 228 346 162 166 435 281 239 42 335 260 26 326
Route #4: 172 149 202 155 41 275 92 71 406 308 195 422
Route #5: 218 375 296 57 392 139 200 145 122 416 407 421
Route #6: 270 404 381 385 345 438 312 241 360 242 342 221
Route #7: 83 412 17 403 384 366 418 206 43 347 211 250
Route #8: 353 121 237 393 325 249 380 268 229 324 233 437
Route #9: 280 245 303 110 409 388 225 86 315 352 264 372
Route #10: 243 321 420 396 423 391 337 433 377 227 115 44
Route #11: 297 126 66 339 293 89 428 402 101 252 47 376
Route #12: 323 383 253 289 338 309 319 329 266 351 432 413
Route #13: 251 137 98 341 286 350 138 246 283
Route #14: 257 271 285 193 215 153 15 159 65 144 334 146
Route #15: 79 189 61 197 7 344 173 118 91 154 431 25
Route #16: 130 80 88 210 22 56 75 196 116 113 274 140
Route #17: 204 31 5 176 131 6 1 367 426 134 207 333
Route #18: 387 397 62 90 109 371 390 184 395 287 161 58
Route #19: 361 331 220 240 340 108 330 19 248 430 28 401
Route #20: 73 157 135 177 152 408 302 255 327 389 343 24
Route #21: 175 132 4 34 230 67 16 112 378 21 181 84
Route #22: 292 244 11 209 354 74 63 117 103 13 171 160
Route #23: 165 235 192 382 174 291 30 259 364 399 106 216
Route #24: 265 313 178 310 222 125 190 356 290 368 417 316
Route #25: 394 379 301 10 37 123 64 272 328 213 279 114
Route #26: 81 179 96 256 78 182 77 99 124
Route #27: 320 234 405 168 95 52 198 214 357 363 300 424
Route #28: 170 183 205 219 150 282 224 273 284 269 120 332
Route #29: 142 39 232 35 208 374 322 304 419 336 369 188
Route #30: 163 151 199 164 51 100 141 27 54 186 128 53
Route #31: 48 12 85 69 68 111 20 148 314 306 107 180
Route #32: 277 45 49 94 212 46 40 247 203 50 194 38
Route #33: 167 201 76 119 127 102 87 29 158 191 129 70
Route #34: 36 18 307 262 261 14 436 373 317 398 427 156
Route #35: 415 276 226 278 33 298 288 55 263 355 429 254
Route #36: 32 82 147 143 60 104 185 294 318 23 93 258
Route #37: 9 295 358 359 365 238 305 362 187 136 59 231

[5]:
gap = 100 * (result.cost() - BKS["cost"]) / BKS["cost"]
print(f"Found a solution with cost: {result.cost()}.")
print(f"This is {gap:.1f}% worse than the best known", end=" ")
print(f"solution, which is {BKS['cost']}.")
Found a solution with cost: 36693.
This is 0.8% worse than the best known solution, which is 36391.

We’ve managed to find a very good solution quickly!

The Result object also contains useful statistics about the optimisation. We can now plot these statistics as well as the final solution use plot_result.

[6]:
fig = plt.figure(figsize=(15, 9))
plot_result(result, INSTANCE, fig)
fig.tight_layout()
../_images/examples_basic_vrps_11_0.png

PyVRP internally uses a genetic algorithm consisting of a population of feasible and infeasible solutions. These solutions are iteratively combined into new offspring solutions, that should result in increasingly better solutions. Of course, the solutions should not all be too similar: then there is little to gain from combining different solutions. The top-left Diversity plot tracks the average diversity of solutions in each of the feasible and infeasible solution populations. The Objectives plot gives an overview of the best and average solution quality in the current population. The bottom-left figure shows iteration runtimes in seconds. Finally, the Solution plot shows the best observed solution.

The VRP with time windows

Reading the instance

We start with a basic example that loads an instance and solves it using the standard configuration used by the Model interface. For the basic example we use one of the well-known Solomon instances.

We again use the function pyvrp.read. We pass the argument round_func="dimacs" following the DIMACS VRP challenge convention, this computes distances and durations truncated to one decimal place.

[7]:
INSTANCE = read("data/RC208.vrp", round_func="dimacs")
BKS = read_solution("data/RC208.sol")

Let’s plot the instance and see what we have. The function plot_instance will plot time windows, delivery demands and coordinates, which should give us a good impression of what the instance looks like. These plots can also be produced separately by calling the appropriate plot_* function: see the API documentation for details.

[8]:
fig = plt.figure(figsize=(12, 6))
plot_instance(INSTANCE, fig)
../_images/examples_basic_vrps_16_0.png

Solving the instance

We will again use the Model interface to solve the instance.

[9]:
model = Model.from_data(INSTANCE)
result = model.solve(stop=MaxIterations(1000), seed=42, display=False)
print(result)
Solution results
================
    # routes: 4
   # clients: 100
   objective: 7761
    distance: 7761
    duration: 17761
# iterations: 1000
    run-time: 3.88 seconds

Routes
------
Route #1: 90 65 82 99 52 83 64 49 19 18 48 21 23 25 77 58 75 97 59 87 74 86 57 24 22 20 66
Route #2: 94 92 95 67 62 50 34 31 29 27 26 28 30 32 33 76 89 63 85 51 84 56 91 80
Route #3: 61 42 44 39 38 36 35 37 40 43 41 72 71 93 96 54 81
Route #4: 69 98 88 2 6 7 79 73 78 12 14 47 17 16 15 13 9 11 10 53 60 8 46 4 45 5 3 1 70 100 55 68

[10]:
cost = result.cost() / 10
gap = 100 * (cost - BKS["cost"]) / BKS["cost"]
print(f"Found a solution with cost: {cost}.")
print(f"This is {gap:.1f}% worse than the optimal solution,", end=" ")
print(f"which is {BKS['cost']}.")
Found a solution with cost: 776.1.
This is 0.0% worse than the optimal solution, which is 776.1.

We’ve managed to find a (near) optimal solution in a few seconds!

[11]:
fig = plt.figure(figsize=(15, 9))
plot_result(result, INSTANCE, fig)
fig.tight_layout()
../_images/examples_basic_vrps_21_0.png

We can also inspect some statistics of the different routes, such as route distance, various durations, the number of stops and total delivery amount.

[12]:
solution = result.best
routes = solution.routes()

data = [
    {
        "num_stops": len(route),
        "distance": route.distance(),
        "service_duration": route.service_duration(),
        "wait_duration": route.wait_duration(),
        "time_warp": route.time_warp(),
        "delivery": route.delivery(),
    }
    for route in routes
]

tabulate(data, headers="keys", tablefmt="html")
[12]:
num_stops distance service_duration wait_duration time_warpdelivery
27 2187 2700 0 0[4650]
24 1983 2400 0 0[3810]
17 1325 1700 0 0[2860]
32 2266 3200 0 0[5920]

We can inspect the routes in more detail using the plot_route_schedule function. This will plot distance on the x-axis, and time on the y-axis, separating actual travel/driving time from waiting and service time. The clients visited are plotted as grey vertical bars indicating their time windows. In some cases, there is slack in the route indicated by a semi-transparent region on top of the earliest time line. The grey background indicates the remaining load of the truck during the route, where the (right) y-axis ends at the vehicle capacity.

[13]:
fig, axarr = plt.subplots(2, 2, figsize=(15, 9))
for idx, (ax, route) in enumerate(zip(axarr.flatten(), routes)):
    plot_route_schedule(
        INSTANCE,
        route,
        title=f"Route {idx}",
        ax=ax,
        legend=idx == 0,
    )

fig.tight_layout()
../_images/examples_basic_vrps_25_0.png

Each route begins at a given start_time, that can be obtained as follows. Note that this start time is typically not zero, that is, routes do not have to start immediately at the beginning of the time horizon.

[14]:
solution = result.best
shortest_route = min(solution.routes(), key=len)

shortest_route.start_time()
[14]:
2991

Some of the statistics presented in the plots above can also be obtained from the route schedule, as follows:

[15]:
data = [
    {
        "start_service": visit.start_service,
        "end_service": visit.end_service,
        "service_duration": visit.service_duration,
        "wait_duration": visit.wait_duration,  # if vehicle arrives early
    }
    for visit in shortest_route.schedule()
]

tabulate(data, headers="keys", tablefmt="html")
[15]:
start_service end_service service_duration wait_duration
3149 3249 100 0
3429 3529 100 0
3549 3649 100 0
3702 3802 100 0
3822 3922 100 0
3980 4080 100 0
4100 4200 100 0
4236 4336 100 0
4394 4494 100 0
4544 4644 100 0
4748 4848 100 0
4959 5059 100 0
5160 5260 100 0
5310 5410 100 0
5473 5573 100 0
5633 5733 100 0
5796 5896 100 0

Solving a larger VRPTW instance

To show that PyVRP can also handle much larger instances, we will solve one of the largest Gehring and Homberger VRPTW benchmark instances. The selected instance - RC2_10_5 - has 1000 clients.

[16]:
INSTANCE = read("data/RC2_10_5.vrp", round_func="dimacs")
BKS = read_solution("data/RC2_10_5.sol")
[17]:
fig = plt.figure(figsize=(15, 9))
plot_instance(INSTANCE, fig)
../_images/examples_basic_vrps_32_0.png

Here, we will use a runtime-based stopping criterion: we give the solver 30 seconds to compute.

[18]:
model = Model.from_data(INSTANCE)
result = model.solve(stop=MaxRuntime(30), seed=42, display=False)
[19]:
cost = result.cost() / 10
gap = 100 * (cost - BKS["cost"]) / BKS["cost"]
print(f"Found a solution with cost: {cost}.")
print(f"This is {gap:.1f}% worse than the best-known solution,", end=" ")
print(f"which is {BKS['cost']}.")
Found a solution with cost: 27638.0.
This is 7.1% worse than the best-known solution, which is 25797.5.
[20]:
plot_result(result, INSTANCE)
plt.tight_layout()
../_images/examples_basic_vrps_36_0.png

Conclusion

In this notebook, we used PyVRP’s Model interface to solve a CVRP instance with 438 clients to near-optimality, as well as several VRPTW instances, including a large 1000 client instance. Moreover, we demonstrated how to use the plotting tools to visualise the instance and statistics collected during the search procedure.