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 read, solve
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()
Solving the instance¶
We will use the default solve method that PyVRP provides for data instances.
[4]:
result = solve(INSTANCE, stop=MaxIterations(2000), seed=42, display=False)
print(result)
Solution results
================
# routes: 37
# trips: 37
# clients: 438
objective: 36965
distance: 36965
duration: 36965
# iterations: 2000
run-time: 3.11 seconds
Routes
------
Route #1: 72 97 400 299 386 267 410 349 223 425 133 370
Route #2: 164 51 35 322 374 208 141 100 27 54 186 128
Route #3: 195 404 381 346 162 166 345 385 438 312 237 121
Route #4: 33 23 318 294 185 104 60 143 288 55 355 254
Route #5: 207 58 161 19 343 389 327 340 240 108 330 248
Route #6: 93 258 70 129 191 158 29 87 102 127 119 76
Route #7: 280 245 303 421 409 110 388 225 352 264 372 391
Route #8: 417 368 290 300 424 213 279 114 316 120 269 284
Route #9: 25 189 61 130 80 131 6 1 367 426 5 31
Route #10: 323 243 321 383 413 253 289 257 376 138 246 283
Route #11: 315 86 83 293 89 17 412 407 416 418 250 393
Route #12: 81 170 183 150 205 219 142 199 151 163 53
Route #13: 48 12 85 69 68 111 20 148 314 306 107 180
Route #14: 265 313 291 178 222 310 160 174 382 192 235 165
Route #15: 132 4 181 21 378 112 16 67 230 34 135 73
Route #16: 333 134 387 287 395 220 331 361 430 28 401
Route #17: 204 176 79 431 334 144 65 137 251 146
Route #18: 194 50 18 307 262 261 14 436 373 317 398 36
Route #19: 38 167 203 247 201 40 46 212 94 49 45 277
Route #20: 98 341 285 252 101 402 339 66 126 297 423 396
Route #21: 156 427 415 276 226 278 298 263 429 365 359 9
Route #22: 39 232 336 304 419 405 369 188 320 273 224 282
Route #23: 84 244 11 209 354 74 63 117 103 13 171 292
Route #24: 332 328 52 198 214 357 363 356 190 30 259 364
Route #25: 175 24 157 177 152 255 302 408 371 390 184 397
Route #26: 228 435 211 347 43 296 375 218 281 239 42 335
Route #27: 350 286 47 271 338 309 319 329 266 351 432 420
Route #28: 210 22 56 75 196 116 113 274 140 90 109 62
Route #29: 159 15 153 215 91 118 173 88 344 197 7 154
Route #30: 295 358 238 305 147 82 32 362 187 136 59 231
Route #31: 206 57 392 139 200 145 122 366 384 403 428 193
Route #32: 234 95 168 272 123 64 10 37 301 379 394 125
Route #33: 44 325 249 221 342 360 241 242 337 433 377 227
Route #34: 124 99 77 399 106 216 182 78 256 96 179
Route #35: 172 149 422 353 115 380 268 229 324 233 437
Route #36: 202 308 270 406 71 41 155 275 92 260 26 414
Route #37: 217 236 105 434 311 8 2 169 3 411 348 326
[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: 36965.
This is 1.6% 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()
The Objectives plot gives an overview of the solution quality over the course of the search. 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 solve method. 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)
Solving the instance¶
[9]:
result = solve(INSTANCE, stop=MaxIterations(1000), seed=42, display=False)
print(result)
Solution results
================
# routes: 4
# trips: 4
# clients: 100
objective: 7761
distance: 7761
duration: 17761
# iterations: 1000
run-time: 0.69 seconds
Routes
------
Route #1: 61 42 44 39 38 36 35 37 40 43 41 72 71 93 96 54 81
Route #2: 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 #3: 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
Route #4: 94 92 95 67 62 50 34 31 29 27 26 28 30 32 33 76 89 63 85 51 84 56 91 80
[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()
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_warp | delivery |
|---|---|---|---|---|---|
| 17 | 1325 | 1700 | 0 | 0 | [2860] |
| 27 | 2187 | 2700 | 0 | 0 | [4650] |
| 32 | 2266 | 3200 | 0 | 0 | [5920] |
| 24 | 1983 | 2400 | 0 | 0 | [3810] |
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()
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 = [
{
"location": visit.location, # Client or depot location of visit
"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]:
| location | start_service | end_service | service_duration | wait_duration |
|---|---|---|---|---|
| 0 | 2991 | 2991 | 0 | 0 |
| 61 | 3149 | 3249 | 100 | 0 |
| 42 | 3429 | 3529 | 100 | 0 |
| 44 | 3549 | 3649 | 100 | 0 |
| 39 | 3702 | 3802 | 100 | 0 |
| 38 | 3822 | 3922 | 100 | 0 |
| 36 | 3980 | 4080 | 100 | 0 |
| 35 | 4100 | 4200 | 100 | 0 |
| 37 | 4236 | 4336 | 100 | 0 |
| 40 | 4394 | 4494 | 100 | 0 |
| 43 | 4544 | 4644 | 100 | 0 |
| 41 | 4748 | 4848 | 100 | 0 |
| 72 | 4959 | 5059 | 100 | 0 |
| 71 | 5160 | 5260 | 100 | 0 |
| 93 | 5310 | 5410 | 100 | 0 |
| 96 | 5473 | 5573 | 100 | 0 |
| 54 | 5633 | 5733 | 100 | 0 |
| 81 | 5796 | 5896 | 100 | 0 |
| 0 | 6016 | 6016 | 0 | 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)
Here, we will use a runtime-based stopping criterion: we give the solver 30 seconds to compute.
[18]:
result = solve(INSTANCE, 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: 26349.7.
This is 2.1% worse than the best-known solution, which is 25797.5.
[20]:
plot_result(result, INSTANCE)
plt.tight_layout()
Conclusion¶
In this notebook, we used PyVRP’s solve functionality 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.