1

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)
the_man_in_black
  • 423
  • 6
  • 15
  • In your example, the total demand exceeds the total capacity of your vehicles. You won't find a solution that serves all nodes. If you can send vehicles on multiple tours, I suggest you increase the number of vehicles by copying the existing vehicles. Can you add more information on the "disjunction" you are expecting, e.g. only one type of item per vehicle? – larsl Feb 19 '21 at 13:02
  • I know, thats why I allow to drop nodes (with a penalty) and set up a new problem with the remaining, unvisited nodes. Regarding the disjunctions: each vehicle can only load one type of item and there cannot be a route with nodes that need to be served different items. However I do not know how to exactly represent that in terms of code. – the_man_in_black Feb 19 '21 at 14:06

1 Answers1

2
  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
    )
    
Mizux
  • 8,222
  • 7
  • 32
  • 48
  • I am assuming the "gate" location can be arbitrary, e.g. exactly the same as the depot? – the_man_in_black Feb 22 '21 at 08:33
  • 1
    yup, usually you have `depot->gate: 0`, `gate->any == depot->any`, so when displaying the solution you can remove the gate and display `depot->Next(gate)` – Mizux Feb 22 '21 at 09:24
  • I adjusted my approach to your answer. I am having one dimension per product type and have the demand callback return value set to 0 for nodes which need a different product type. However now the solver returns a route which serves all the nodes with the wrong product and hence 0 load. Do I need to set their demand for an unfitting product to infinity? – the_man_in_black Feb 22 '21 at 14:22
  • 1
    you should provide use a gist with the data so we can debug it, you can also reach us in the or-tools mailing list or on our discord (chat badge on the README.md of the github project) – Mizux Feb 23 '21 at 09:47
  • It is working now thanks to the bugfix I mention in EDIT2 – the_man_in_black Feb 23 '21 at 10:05