pythonoptimizationor-toolsvehicle-routing

OR-tools routing optimization node compatibility


I am trying to solve a capacitated routing problem where I have a set of nodes which require different amounts and different types of items.
In addition I want to allow node drops, because all nodes with one type of item might still exceed the vehicle capacity and thus would lead to no solution.
However eventually all nodes should be served so I use an iterative approach where I was treating each item type as individual routing problem.
But I was wondering if one could use disjunctions or something similar to solve the 'global' routing problem. Any help on whether this is possible is appreciated.

Example:
Node 1 - item A - demand 10
Node 2 - item A - demand 10
Node 3 - item A - demand 12
Node 4 - item B - demand 10
Node 5 - item B - demand 10

vehicle I - capacity 20
vehicle II - capacity 10

My approach:
First solve for item A: vehicle I serves node 1 & 2, node 3 is dropped, save dropped nodes for later iteration
Then solve for item B: vehicle I serves nodes 4 & 5, vehicle II is idle Solve for remaining node 3: vehicle I serves node 3

EDIT I adjusted my approach to fit @mizux answer. Below the code:

EDIT2 Fixed a bug where the demand callback function from the first loops would still reference the product_index variable and thus return the wrong demand. Fix by using functools.partial.

import functools
from ortools.constraint_solver import pywrapcp, routing_enums_pb2

class CVRP():
    def __init__(self, data):
        # assert all(data['demands'] < max(data['vehicle_capacities'])) # if any demand exceeds cap no solution possible
        self.data = data

        self.vehicle_names_internal = [f'{i}:{j}' for j in data['products'] for i in data['vehicle_names']]
        self.manager = pywrapcp.RoutingIndexManager(len(data['distance_matrix']), len(self.vehicle_names_internal), data['depot'])
        self.routing = pywrapcp.RoutingModel(self.manager)

        transit_callback_id = self.routing.RegisterTransitCallback(self._dist_callback)      
        self.routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_id)
        
        # set up dimension for each product type for vehicle capacity constraint
        for product_index, product in enumerate(data['products']):
            dem_product_callback = functools.partial(self._dem_callback_generic, product_index=product_index)
            dem_callback_id = self.routing.RegisterUnaryTransitCallback(dem_product_callback)
            vehicle_product_capacity = [0 for i in range(len(self.vehicle_names_internal))]
            vehicle_product_capacity[product_index*data['num_vehicles']:product_index*data['num_vehicles']+data['num_vehicles']] = data['vehicle_capacities']
            print(product_index, product)
            print(self.vehicle_names_internal)
            print(vehicle_product_capacity)
            self.routing.AddDimensionWithVehicleCapacity(
                dem_callback_id,
                0,
                vehicle_product_capacity,
                True,
                f'capacity_{product}',
                )

        # disjunction (allow node drops)
        penalty = int(self.data['distance_matrix'].sum()+1) # penalty needs to be higher than total travel distance in order to only drop locations if not other feasible solution
        for field_pos_idx_arr in self.data['disjunctions']: 
            self.routing.AddDisjunction([self.manager.NodeToIndex(i) for i in field_pos_idx_arr], penalty)

        
    def _dist_callback(self, i, j):
        return self.data['distance_matrix'][self.manager.IndexToNode(i)][self.manager.IndexToNode(j)]
    
    def _dem_callback_generic(self, i, product_index):
        node = self.manager.IndexToNode(i)
        if node == self.data['depot']:
            return 0
        else:
            return self.data['demands'][node, product_index]

    def solve(self, verbose=False):    
        search_parameters = pywrapcp.DefaultRoutingSearchParameters()
        search_parameters.first_solution_strategy = (
            routing_enums_pb2.FirstSolutionStrategy.AUTOMATIC)
        search_parameters.local_search_metaheuristic = (
            routing_enums_pb2.LocalSearchMetaheuristic.AUTOMATIC)
        search_parameters.time_limit.FromSeconds(30)

        self.solution = self.routing.SolveWithParameters(search_parameters)

Solution

    1. You should create two capacity dimensions, one for each type, At each location you increase the relevant dimension.

    2. You can duplicate your vehicle for each item type i.e.:

      • v0, Vehicle 1 Type A with: capacity A: 20, capacity B: 0
      • v1, Vehicle 1 Type B with: capacity A: 0, capacity B: 20
      • v2, Vehicle 2 Type A with: capacity A: 10, capacity B: 0
      • v3, Vehicle 2 Type B with: capacity A: 0, capacity B: 10

      note: you can replicate it to allow multi-trips

    3. You can create a "gate" node to allow only one vehicle configuration. e.g. To only allow v0 or v1 to do some visit

      v0_start = routing.Start(0)
      v0_end = routing.End(0)
      v1_start = routing.Start(1)
      v1_end = routing.End(1)
      gate_index = manager.NodeToIndex(gate_index)
      routing.NextVar(v0_start).setValues[gate_index, v0_end]
      routing.NextVar(v1_start).setValues[gate_index, v1_end]
      

      Since node can only be visited once, one vehicle among v0 and v1 can pass by the gate node while the other has no choice but to go to end node i.e. empty route you can remove when post processing the assignment.

    4. You can also add a vehicle FixedCost to incentive solver to use vehicle II if it is cheaper than vehicle I etc...

    5. Add each location to a disjunction so the solver can drop them if needed

      location_index = manager.NodeToIndex(location_id)
      routing.AddDisjunction(
        [location_index], # locations
        penalty,
        max_cardinality=1 # you can omit it since it is already 1 by default
      )