Vehicle Routing Problem with Time Windows

This notebook shows how to use the PyVRP library to solve the Vehicle Routing Problem with Time Windows (VRPTW).

A VRPTW 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\), which need not to be symmetric and/or euclidean. Each customer \(i \in V_c\) has a demand \(q_{i} \ge 0\), 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\). We assume that there is an unlimited number of homogeneous vehicles available, each with maximum capacity \(Q > 0\).

A feasible solution to the VRPTW consists of a set of routes that all begin and end at the depot, such that each customer is visited exactly once, all customers are served within their time windows and none of the vehicles exceed their capacity. The objective is to find a feasible solution that minimizes the total distance.

In the following, we will show how to configure the Hybrid Genetic Search algorithm to solve three different VRPTW instances. Moreover, we demonstrate how to use plotting and diagnostics utilities from the PyVRP library to analyze the results.

Basic example

We’ll start with a basic example that loads an instance and solves it using a standard configuration. First, let’s import some necessary components.

[1]:
from typing import Dict, Optional
from pathlib import Path
from IPython.display import display

import matplotlib.pyplot as plt
import pandas as pd
import vrplib

from pyvrp import (
    GeneticAlgorithm,
    GeneticAlgorithmParams,
    Individual,
    PenaltyManager,
    Population,
    PopulationParams,
    ProblemData,
    Result,
    XorShift128,
    diagnostics,
    plotting,
    read,
)
from pyvrp.crossover import selective_route_exchange as srex
from pyvrp.diversity import broken_pairs_distance as bpd
from pyvrp.educate import (
    NODE_OPERATORS,
    ROUTE_OPERATORS,
    LocalSearch,
    compute_neighbours,
)
from pyvrp.stop import MaxIterations, MaxRuntime, StoppingCriterion

Read and plot instance

We will first load one of the classical Solomon instances. We use the function pyvrp.read, which reads the instance file and converts it to a ProblemData instance. We pass the following arguments:

  • instance_format='solomon': this parses the instance file as a Solomon formatted instance (as opposed to VRPLIB formatted instances),

  • round_func='trunc1': this compute distances with 1 decimal precision, by multipliying all distances by 10 and converting to integers (i.e., following the DIMACS VRP challenge convention).

[2]:
instance = read(
    "data/RC208.vrp", instance_format="solomon", round_func="trunc1"
)
instance_bks = vrplib.read_solution("data/RC208.sol")

Let’s plot the instance and see what we have. The function plotting.plot_instance will plot time windows, demands and coordinates, which should give us a good impression of what the instance looks like (note that these can also be plotted separately using the plotting.plot_* functions.

[3]:
fig = plt.figure(figsize=(15, 9))
plotting.plot_instance(instance, fig)
../_images/examples_vrptw_6_0.png

Solving an instance

Now let’s run the algorithm using our solve function and compare the result with the best known solution. To compute the cost for the solution, we need a CostEvaluator instance. We can get it from the penalty manager.

[5]:
def report_gap(best_cost: float, bks: Dict, scale_factor: float = 10):
    objective = best_cost / scale_factor
    bks_cost = bks["cost"]
    pct_diff = 100 * (objective - bks_cost) / bks_cost

    print(f"Found a solution with cost: {objective}.")
    print(f"This is {pct_diff:.1f}% worse than the best known", end=" ")
    print(f"solution, which is {bks_cost}.")


cost_evaluator = PenaltyManager().get_cost_evaluator()
result = solve(instance, seed=42, max_runtime=5)
report_gap(cost_evaluator.cost(result.best), instance_bks)
Found a solution with cost: 776.1.
This is 0.0% worse than the best known solution, which is 776.1.

We’ve managed to find the best known solution in 5 seconds!

Plot results

The result object (of type Result) contains useful statistics about the optimization since we set collect_statistics=True in GeneticAlgorithmParams (see the solve function). We can now plot these statistics as well as the final solution use plotting.plot_result.

[6]:
def plot_result(result: Result, instance: ProblemData):
    fig = plt.figure(figsize=(15, 9))
    plotting.plot_result(result, instance, fig)
    fig.tight_layout()


plot_result(result, instance)
../_images/examples_vrptw_14_0.png

Inspect routes

We can also inspect some statistics of the different routes, such as route distance and duration, number of stops and total demand, using diagnostics.get_all_route_statistics. Let’s verify that the cost of our solution is equal to the sum of the distances.

[7]:
cost_evaluator = PenaltyManager().get_cost_evaluator()
solution = result.best
route_stats = diagnostics.get_all_route_statistics(solution, instance)
route_stats_df = pd.DataFrame(route_stats)

# Verify that cost is equal to total distance
assert route_stats_df["distance"].sum() == cost_evaluator.cost(solution)

display(route_stats_df.head())
distance start_time end_time duration timewarp wait_time service_time num_stops total_demand fillrate is_feasible is_empty
0 2187 0 7043 7043 0 2156 2700 27 465 0.465 True False
1 1983 0 6862 6862 0 2479 2400 24 381 0.381 True False
2 1325 0 6016 6016 0 2991 1700 17 286 0.286 True False
3 2266 0 7295 7295 0 1829 3200 32 592 0.592 True False

We can inspect the routes in more detail using the plotting.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. When a vehicle arrives too early at a customer, we can see a jump to the start of the time window in the main (earliest) time line. In some cases, there is slack in the route indicated by a semi-transparent region on top of the earliest time line (see plot of Route 1). The grey background indicates the remaining load of the truck during the route, where the (right) y-axis ends at the vehicle capacity.

[8]:
def plot_route_schedule(result: Result, instance: ProblemData):
    fig, axarr = plt.subplots(2, 2, figsize=(15, 9))

    routes = result.best.get_routes()
    for idx, (ax, route) in enumerate(zip(axarr.reshape(-1), routes)):
        plotting.plot_route_schedule(
            instance, route, title=f"Route {idx}", ax=ax, legend=idx == 0
        )

    fig.tight_layout()


plot_route_schedule(result, instance)
../_images/examples_vrptw_18_0.png

Solving larger instances

Gehring & Homberger instance

Let’s try one of the largest Gehring & Homberger instances (1000 customers), and plot the instance, results and some routes.

[9]:
instance_gh = read(
    "data/RC2_10_5.vrp", instance_format="solomon", round_func="trunc1"
)
bks_gh = vrplib.read_solution("data/RC2_10_5.sol")

fig = plt.figure(figsize=(15, 9))
plotting.plot_instance(instance_gh, fig)

result_gh = solve(instance_gh, seed=42, max_runtime=30)

cost_evaluator = PenaltyManager().get_cost_evaluator()
best_cost_gh = cost_evaluator.cost(result_gh.best)
report_gap(best_cost_gh, bks_gh)
plot_result(result_gh, instance_gh)
plot_route_schedule(result_gh, instance_gh)
Found a solution with cost: 26548.6.
This is 2.9% worse than the best known solution, which is 25797.5.
../_images/examples_vrptw_21_1.png
../_images/examples_vrptw_21_2.png
../_images/examples_vrptw_21_3.png

ORTEC instance (from the EURO Meets NeurIPS 2022 Competition)

Let’s also try an instance based on real data, which uses an actual over-the-road distance matrix (non-Euclidean) and atypical client distribution. Note that the file is in VRPLIB format and the distances are already integral and so there is no rounding.

[10]:
name_ortec = "ORTEC-VRPTW-ASYM-0bdff870-d1-n458-k35"
instance_ortec = read(f"data/{name_ortec}.txt")
bks_ortec = vrplib.read_solution(f"data/{name_ortec}.sol")

fig = plt.figure(figsize=(15, 9))
plotting.plot_instance(instance_ortec, fig)

result_ortec = solve(instance_ortec, seed=42, max_runtime=30)

cost_evaluator = PenaltyManager().get_cost_evaluator()
best_cost_ortec = cost_evaluator.cost(result_ortec.best)
report_gap(best_cost_ortec, bks_ortec, scale_factor=1)
plot_result(result_ortec, instance_ortec)
plot_route_schedule(result_ortec, instance_ortec)
Found a solution with cost: 259297.0.
This is 0.2% worse than the best known solution, which is 258704.
../_images/examples_vrptw_23_1.png
../_images/examples_vrptw_23_2.png
../_images/examples_vrptw_23_3.png

Conclusion

In this notebook, we showed how to configure the Hybrid Genetic Search algorithm to solve the VRPTW. We run the solver on three different instances and we demonstrate how to use plotting and diagnostics utilities from the PyVRP library.