pythonor-toolsvehicle-routinggoogle-route-optimization-api

Guidance required with Python VRP Time Window Problem (using Google OR Tools)


I'm trying to solve the Vehicle Route Optimisation with Time Window constraints using Google's OR Tools in Python. The problem here I'm solving is - I want to deliver packages to the customer, where the customers selects a specific time window for when they wish to receive the package (for examples between 10 AM to 11 AM). I have included a time_matrix for all 16 locations + depot. The code executes but it seems like the answer I'm getting isn't correct. I have included my code and the output I'm getting below.

Any help is really appreciated. I'm a beginner with Python and working on a project to optimise route with time window constraint.

Below, time_matrix table: is the time in minutes from one customer to other, time_windows table: here, depot is open from 9AM to 16(4PM) during the day. So I considered the start time as 0min and end time as 420min (60min * 7hours). And likewise calculated time for each customer

from ortools.constraint_solver import routing_enums_pb2 
from ortools.constraint_solver import pywrapcp
import pandas as pd

def create_data_model():
    data = {}
    data["time_matrix"] = [
        [0, 13, 9, 3, 7, 7, 10, 9, 6, 7, 7, 3, 8, 11, 9, 4, 4],
        [13, 0, 3, 9, 6, 6, 5, 3, 5, 4, 5, 7, 4, 7, 4, 10, 11],
        [9, 3, 0, 7, 5, 5, 4, 1, 4, 4, 4, 5, 3, 5, 3, 6, 8],
        [3, 9, 7, 0, 4, 6, 7, 8, 5, 6, 6, 2, 4, 9, 8, 4, 3],
        [7, 6, 5, 4, 0, 1, 4, 5, 6, 3, 2, 4, 1, 6, 4, 4, 4],
        [7, 6, 5, 6, 1, 0, 5, 7, 6, 2, 2, 4, 2, 6, 4, 4, 4],
        [10, 5, 4, 7, 4, 5, 0, 5, 7, 5, 5, 7, 5, 2, 2, 7, 5],
        [9, 3, 1, 8, 5, 7, 5, 0, 3, 5, 4, 5, 4, 6, 4, 4, 8],
        [6, 5, 4, 5, 6, 6, 7, 3, 0, 6, 6, 4, 6, 8, 7, 3, 9],
        [7, 4, 4, 6, 3, 2, 5, 5, 6, 0, 2, 3, 4, 5, 4, 3, 6],
        [7, 5, 4, 6, 2, 2, 5, 4, 6, 2, 0, 4, 3, 5, 3, 4, 5],
        [3, 7, 5, 2, 4, 4, 7, 5, 4, 3, 4, 0, 5, 9, 5, 2, 4],
        [8, 4, 3, 4, 1, 2, 5, 4, 6, 4, 3, 5, 0, 7, 5, 6, 4],
        [11, 7, 5, 9, 6, 6, 2, 6, 8, 5, 5, 9, 7, 0, 3, 8, 8],
        [9, 4, 3, 8, 4, 4, 2, 4, 7, 4, 3, 5, 5, 3, 0, 5, 8],
        [4, 10, 6, 4, 4, 4, 7, 4, 3, 3, 4, 2, 6, 8, 5, 0, 6],
        [4, 11, 8, 3, 4, 4, 5, 8, 9, 6, 5, 4, 4, 8, 8, 6, 0],
    ]
    data["time_windows"] = [
        (0, 420),       # depot 9 - 16
        (120, 180),     # Customer 1 between: 11 - 12
        (60, 120),      # Customer 2 between: 10 - 11
        (240, 300),     # Customer 3 between: 13 - 14
        (60, 120),      # Customer 4 between: 10 - 11 
        (60, 120),      # Customer 5 between: 10 - 11
        (240, 300),     # Customer 6 between: 13 - 14
        (300, 360),     # Customer 7 between: 14 - 15
        (300, 360),     # Customer 8 between: 14 - 15
        (180 , 240),    # Customer 9 between: 12 - 13
        (120, 180),     # Customer 10 between: 11 - 12
        (300, 360),     # Customer 11 between: 14 - 15
        (240, 300),     # Customer 12 between: 13 - 14
        (240, 300),     # Customer 13 between: 13 - 14
        (120, 180),     # Customer 14 between: 11 - 12 
        (60, 120),      # Customer 15 between: 10 - 11
        (60, 120),      # Customer 16 between: 10 - 11
        ]
    data["num_vehicles"] = 4
    data["depot"] = 0
    return data

def print_solution(data, manager, routing, solution):
    print(f"Objective: {solution.ObjectiveValue()}")
    time_dimension = routing.GetDimensionOrDie("Time")
    total_time = 0
    routes = []

    for vehicle_id in range(data["num_vehicles"]):
        index = routing.Start(vehicle_id)
        route = []
        plan_output = f"Route for vehicle {vehicle_id}:\n"
        while not routing.IsEnd(index):
            time_var = time_dimension.CumulVar(index)
            node = manager.IndexToNode(index)
            route.append((node, solution.Min(time_var), solution.Max(time_var)))
            plan_output += (
                f"{node} Time({solution.Min(time_var)},{solution.Max(time_var)}) -> "
            )
            index = solution.Value(routing.NextVar(index))
        time_var = time_dimension.CumulVar(index)
        node = manager.IndexToNode(index)
        route.append((node, solution.Min(time_var), solution.Max(time_var)))
        plan_output += (
            f"{node} Time({solution.Min(time_var)},{solution.Max(time_var)})\n"
        )
        plan_output += f"Time of the route: {solution.Min(time_var)}min\n"
        print(plan_output)
        total_time += solution.Min(time_var)
        routes.append(route)

    print(f"Total time of all routes: {total_time}min")

    # Exporting the solution to an Excel file
    all_routes = []
    for vehicle_id, route in enumerate(routes):
        for node, start_time, end_time in route:
            all_routes.append((vehicle_id, node, start_time, end_time))

    df = pd.DataFrame(all_routes, columns=["Vehicle ID", "Node", "Start Time", "End Time"])
    df.to_excel("PATH", index=False)
    print("Solution exported to solution.xlsx")

def main():

    data = create_data_model()
    manager = pywrapcp.RoutingIndexManager(len(data["time_matrix"]),data["num_vehicles"],data["depot"])
    routing = pywrapcp.RoutingModel(manager)
    def time_callback(from_index, to_index):
        from_node = manager.IndexToNode(from_index)
        to_node = manager.IndexToNode(to_index)
        return data["time_matrix"][from_node][to_node]

    transit_callback_index = routing.RegisterTransitCallback(time_callback) 
    routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index) 

# Adding a time window constraint
    time = "Time"                       
    routing.AddDimension(
        transit_callback_index,         
        30,                             # maximum allowed waiting time
        60,                             # maximum time allowed for a vehicle's journey
        False,                          # the starting time does not have to be zero
        time,                           
    )
    time_dimension = routing.GetDimensionOrDie(time)

# Add time window constraints for each customer except depot
    for location_idx, time_window in enumerate(data["time_windows"]):           
        if location_idx == data["depot"]:                                       
            continue
        index = manager.NodeToIndex(location_idx)                               
        time_dimension.CumulVar(index).SetRange(time_window[0], time_window[1])

# Add time window constraints for each VEHICLE
    depot_idx = data["depot"]
    for vehicle_id in range(data["num_vehicles"]):                              
        index = routing.Start(vehicle_id)                                       
        time_dimension.CumulVar(index).SetRange(data["time_windows"][depot_idx][0], data["time_windows"][depot_idx][1])

    for i in range(data["num_vehicles"]):
        routing.AddVariableMinimizedByFinalizer(time_dimension.CumulVar(routing.Start(i)))  
        routing.AddVariableMinimizedByFinalizer(time_dimension.CumulVar(routing.End(i)))

    search_parameters = pywrapcp.DefaultRoutingSearchParameters()
    search_parameters.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC

    solution = routing.SolveWithParameters(search_parameters)

    if solution:
        print_solution(data, manager, routing, solution)

if __name__ == "__main__":
    main()

When I execute this code, I get the below output, which doesn't look right to me.


Route for vehicle 0:
0 Time(0,0) -> 0 Time(0,0)
Time of the route: 0min

Route for vehicle 1:
0 Time(0,0) -> 0 Time(0,0)
Time of the route: 0min

Route for vehicle 2:
0 Time(0,0) -> 0 Time(0,0)
Time of the route: 0min

Route for vehicle 3:
0 Time(0,0) -> 11 Time(3,3) -> 15 Time(5,5) -> 8 Time(8,8) -> 7 Time(11,11) -> 2 Time(12,12) -> 12 Time(15,15) -> 4 Time(16,16) -> 5 Time(17,17) -> 10 Time(19,19) -> 9 Time(21,21) -> 1 Time(25,25) -> 14 Time(29,29) -> 13 Time(32,32) -> 6 Time(34,34) -> 16 Time(39,39) -> 3 Time(42,42) -> 0 Time(45,45)
Time of the route: 45min

Total time of all routes: 45min


Solution

  • Issue Workflow

    There is no feasible / valid solution ever and a general rule in optimization is: don't process a solution without checking that it's status is valid! Anything can happen then (obviously depends on the solver-implementation) including indeterministic reading of uninitialized memory!

      /// Returns the current status of the routing model.
      Status status() const { return status_; }
    
      /// Status of the search.
      enum Status {
        /// Problem not solved yet (before calling RoutingModel::Solve()).
        ROUTING_NOT_SOLVED,
        /// Problem solved successfully after calling RoutingModel::Solve().
        ROUTING_SUCCESS,
        /// Problem solved successfully after calling RoutingModel::Solve(), except
        /// that a local optimum has not been reached. Leaving more time would allow
        /// improving the solution.
        ROUTING_PARTIAL_SUCCESS_LOCAL_OPTIMUM_NOT_REACHED,
        /// No solution found to the problem after calling RoutingModel::Solve().
        ROUTING_FAIL,
        /// Time limit reached before finding a solution with RoutingModel::Solve().
        ROUTING_FAIL_TIMEOUT,
        /// Model, model parameters or flags are not valid.
        ROUTING_INVALID,
        /// Problem proven to be infeasible.
        ROUTING_INFEASIBLE,
        /// Problem has been solved to optimality.
        ROUTING_OPTIMAL
      };
    

    Try:

    solution = routing.SolveWithParameters(search_parameters)
    print(routing.status())
    

    With 9.7 the internal cp_solver will recognize your infeasibility during modelling! You don't even get to solve the problem then!

        return _pywrapcp.IntExpr_SetRange(self, l, u)
    Exception: CP Solver fail
    

    Issue Modelling

    With your time-windows configured as such, the globally earliest visit is at tick 60 (e.g. # Customer 2).

    As you need to visit AND return, you will need > 60 ticks for each of your visits (because the first column in your matrix is strictly positive).

    But with:

        time = "Time"                       
        routing.AddDimension(
            transit_callback_index,         
            30,                             # maximum allowed waiting time
            60,                             # maximum time allowed for a vehicle's journey
            False,                          # the starting time does not have to be zero
            time,                           
        )
    

    you basically limit the solvers horizon to 60 ticks.

    'capacity' (here 60) is the upper bound of the cumul variables.

    There is now a constraint which enforces the following:

      /// Methods to add dimensions to routes; dimensions represent quantities
      /// accumulated at nodes along the routes. They represent quantities such as
      /// weights or volumes carried along the route, or distance or times.
      /// Quantities at a node are represented by "cumul" variables and the increase
      /// or decrease of quantities between nodes are represented by "transit"
      /// variables. These variables are linked as follows:
      /// if j == next(i), cumul(j) = cumul(i) + transit(i, j) + slack(i)
      /// where slack is a positive slack variable (can represent waiting times for
      /// a time dimension).
      /// Setting the value of fix_start_cumul_to_zero to true will force the
      /// "cumul" variable of the start node of all vehicles to be equal to 0.
    
      /// Creates a dimension where the transit variable is constrained to be
      /// equal to evaluator(i, next(i)); 'slack_max' is the upper bound of the
      /// slack variable and 'capacity' is the upper bound of the cumul variables.
      /// 'name' is the name used to reference the dimension; this name is used to
      /// get cumul and transit variables from the routing model.
    

    You cannot visit any node anymore and returning within 60 ticks. But your dimension-setup needs you to do it! (As you did not setup any disjunctions, every node-visit is mandatory)

    Changing it to the following just for demo will show a solution with Status==1==ROUTING_SUCCESS i guess (although it does make little sense for you):

        time = "Time"                       
        routing.AddDimension(
            transit_callback_index,         
            3000,                             # !!!
            6000,                             # !!!
            False,                          # the starting time does not have to be zero
            time,                           
        )