A delivery company has a fleet of vehicles at a certain location, consisting of 3 trucks. Each truck has a load limit of 1 ton (i.e., 1000 kg) and a speed of 30 km/h. The delivery fleet needs to provide pickup and delivery services for a group of customers. Each order has independent time windows for the pickup point and the delivery point. Below are the detailed customer order details:

| Order ID | Pickup Point (x, y) | Pickup Time Window | Delivery Point (x, y) | Delivery Time Window | Delivery Weight (kg) |
|----------|---------------------|---------------|---------------------|---------------|-------------------|
| 1        | (1.41, 2.64)        | [9:30, 10:00] | (8.56, 1.11)        | [9:40, 10:40] | 198               |
| 2        | (6.34, -4.45)       | [12:00, 12:30]| (3.63, -7.65)       | [12:10, 13:10]| 252               |
| 3        | (3.04, 7.82)        | [10:30, 11:00]| (-8.88, 9.6)        | [10:50, 11:50]| 94                |
| 4        | (8.18, -6.27)       | [12:00, 12:30]| (-4.31, -5.73)      | [12:20, 12:20]| 223               |
| 5        | (3.03, 2.34)        | [12:30, 13:00]| (7.93, 5.0)         | [12:40, 13:40]| 163               |
| 6        | (0.07, 9.32)        | [8:30, 9: 00] | (-7.54, -3.57)      | [9:00, 10:00] | 247               |
| 7        | (0.57, -1.09)       | [8:00, 8:30]  | (-5.15, -4.55)      | [8:10, 9:10]  | 133               |
| 8        | (8.87, 2.71)        | [14:00, 14:30]| (-9.87, 0.49)       | [14:30, 15:30]| 188               |
| 9        | (-3.97, -3.81)      | [10:00, 10:30]| (-3.91, -2.12)      | [10:00, 11:00]| 216               |
| 10       | (-0.08, -8.95)      | [9:30, 10:00] | (-6.29, -7.8)       | [9:40, 10:40] | 225               |

The delivery fleet departs from the depot at coordinates (0, 0), serves all orders, and then returns to the depot. How should the routes be planned to minimize the total distance traveled by the trucks? (Distances are calculated as straight-line distances.)

Problem type: ILP (Medium: 1365 variables, 3909 constraints, 16343 nonzeros)

Mathematical model:
Set:
1. Node_o
The set of pickup nodes, \( N_o = \{1, \dots, OrderNum\} \)
2. Node_d
The set of delivery nodes, \( N_d = \{OrderNum+1, \dots, OrderNum*2\} \)
3. Node
The set of nodes, \( N = \{0\} \cup N_o \cup N_d \)
2. Trucks
The set of trucks, \( K = \{1, 2, \dots, TruckNum\} \)

Parameter:
1. OrderNum
10
2. Coordinate
[(0, 0), (1.41, 2.64), (6.34, -4.45), (3.04, 7.82), (8.18, -6.27), (3.03, 2.34), (0.07, 9.32), (0.57, -1.09), (8.87, 2.71), (-3.97, -3.81), (-0.08, -8.95), (8.56, 1.11), (3.63, -7.65), (-8.88, 9.6), (-4.31, -5.73), (7.93, 5.0), (-7.54, -3.57), (-5.15, -4.55), (-9.87, 0.49), (-3.91, -2.12), (-6.29, -7.8)]
3. Demand
[0, 198, 252, 94, 223, 163, 247, 133, 188, 216, 225, -198, -252, -94, -223, -163, -247, -133, -188, -216, -225]
4. Timewindow
[(0, 1440), (570, 600), (720, 750), (630, 660), (720, 750), (750, 780), (510, 540), (480, 510), (840, 870), (600, 630), (570, 600), (580, 640), (730, 760), (650, 680), (720, 750), (780, 810), (540, 570), (490, 520), (870, 900), (630, 660), (580, 610)]
7. TruckNum
3
8. Speed
30
9. Maxload
1000
10. M
1e5

Decision variable:
1. X
Binary variable, \( x_{ijk} \forall i \in N, j \in N, k \in K\), where \( x_{ijk} = 1 \) if truck \( k \) departs from node \( i \) and arrives at node \( j \), otherwise \( x_{ijk} = 0 \).
2. T^a
Integer variable, \( t^a_{i} \forall i \in N\), where \( t^a_{i} \) is the time at which the truck arrives at node \( i \).
3. T^d
Integer variable, \( t^d_{i} \forall i \in N\), where \( t^d_{i} \) is the time at which the truck departs from node \( i \).
4. Load
Integer variable, \(Load_{ik} \forall i \in N, k \in K\), where \( Load_{ik} \) is the load of truck \( k \) departs node \( i \).

Objective:
1. Minimize the total distance traveled by the trucks.
min: \sum_{k \in K}\sum_{i \in N} \sum_{j \in N} Distance[i][j] * x_{ijk}
# Where \( Distance[i][j] \) is the Euclidean distance between node \( i \) and node \( j \).

Constraint:
1. Each pickup&delivery point must be visited exactly once by one truck.
\sum_{k \in K} \sum_{i \in N} x_{ijk} = 1 \forall j \in N/{0}
2. One order's pickup point and delivery point must be visited by the same truck.
\sum_{j \in N} x_{ijk} = \sum_{j \in N} x_{i+OrderNum,j,k} \forall i \in N_o, k \in K
3. Flow conservation for each point.
\sum_{j \in N} x_{ijk} = \sum_{j \in N} x_{j,i,k} \forall i \in N, k \in K
4. Each truck must leave the distribution center once.
\sum_{j \in N} x_{0jk} = 1 \forall k \in K
5. The order's pickup point must be visited before its delivery point.
t^a_{i} \leq t^d_{i+OrderNum} \forall i \in N_o
6. The truck must arrive at each pickup&delivery point within its time window.
t^a_{i} \geq Timewindow[i][0] \forall i \in N
t^a_{i} \leq Timewindow[i][1] \forall i \in N
7. The truck must leave each pickup&delivery point within its time window.
t^d_{i} \leq Timewindow[i][1] \forall i \in N
t^d_{i} \geq Timewindow[i][0] \forall i \in N
8. The truck's arrival time at each node must be more than or equal to the departure time from the previous node plus the travel time between the two nodes.
t^d_{i} + 60*\frac{Distance[i][j]}{Speed} - M * (1 - x_{ijk}) \leq t^a_{j} \forall i, j \in N, k \in K
9. The truck's load must not exceed its maximum load at every visited point.
Load_{ik} \leq Maxload \forall i \in N, k \in K
10. The truck's load must be updated at every visited point.
Load_{jk} \geq Load_{ik} + Demand[j] - M * (1 - x_{ijk}) \forall i, j \in N, k \in K
Load_{jk} \leq Load_{ik} + Demand[j] + M * (1 - x_{ijk}) \forall i, j \in N, k \in K
11. The truck's load must be initialized at the distribution center.
Load_{0k} = 0 \forall k \in K
12. Eliminate sub-tours.
\sum_{i \in N} \sum_{j \in N} x_{ijk} \leq |S| - 1 \forall S \subseteq N, 1 < |S| < |N|, k \in K

Type:
Integer, Linear, Linear
ILP


Gurobi implementation:
import gurobipy as gp
from gurobipy import GRB
import math


def solve_PickupDeliveryVRP(
    order_num=10,
    truck_num=3,
    speed=30,
    maxload=1000,
    M=1e5,
    coordinates = [(0, 0), (1.41, 2.64), (6.34, -4.45), (3.04, 7.82),
                (8.18, -6.27), (3.03, 2.34), (0.07, 9.32), (0.57, -1.09),
                (8.87, 2.71), (-3.97, -3.81), (-0.08, -8.95), (8.56, 1.11),
                (3.63, -7.65), (-8.88, 9.6), (-4.31, -5.73), (7.93, 5.0),
                (-7.54, -3.57), (-5.15, -4.55), (-9.87, 0.49), (-3.91, -2.12),
                (-6.29, -7.8)],
    demands = [
        0, 198, 252, 94, 223, 163, 247, 133, 188, 216, 225, -198, -252, -94, -223,
        -163, -247, -133, -188, -216, -225
    ],
    time_windows = [(0, 1440), (570, 600), (720, 750), (630, 660), (720, 750),
                    (750, 780), (510, 540), (480, 510), (840, 870), (600, 630),
                    (570, 600), (580, 640), (730, 760), (650, 680), (720, 750),
                    (780, 810), (540, 570), (490, 520), (870, 900), (630, 660),
                    (580, 610)]
):
    # --- Model Initialization ---
    model = gp.Model("PickupDeliveryVRP")

    # Define sets
    nodes = range(order_num * 2 + 1)  # 0 is depot, 1-10 are pickup, 11-20 are delivery
    pickup_nodes = range(1, order_num + 1)
    trucks = range(truck_num)

    # Calculate distance matrix
    def calculate_distance(coord1, coord2):
        return math.sqrt((coord1[0] - coord2[0])**2 + (coord1[1] - coord2[1])**2)


    distance = {}
    for i in nodes:
        for j in nodes:
            if i != j:
                distance[i, j] = calculate_distance(coordinates[i], coordinates[j])

    # --- Decision variables ---
    # x[i,j,k] = 1 if truck k travels from i to j
    x = {}
    for i in nodes:
        for j in nodes:
            if i != j:
                for k in trucks:
                    x[i, j, k] = model.addVar(vtype=GRB.BINARY,
                                            name=f'x_{i}_{j}_{k}')

    # t_a[i] = arrival time at node i
    t_a = {}
    for i in nodes:
        t_a[i] = model.addVar(lb=time_windows[i][0],
                            ub=time_windows[i][1],
                            name=f't_a_{i}')

    # t_d[i] = departure time from node i
    t_d = {}
    for i in nodes:
        t_d[i] = model.addVar(lb=time_windows[i][0],
                            ub=time_windows[i][1],
                            name=f't_d_{i}')

    # load[i,k] = load of truck k when departing from node i
    load = {}
    for i in nodes:
        for k in trucks:
            load[i, k] = model.addVar(lb=0, ub=maxload, name=f'load_{i}_{k}')

    # --- Objective function ---
    # minimize total distance
    objective = gp.quicksum(distance[i, j] * x[i, j, k] for i in nodes
                            for j in nodes for k in trucks if i != j)
    model.setObjective(objective, GRB.MINIMIZE)

    # --- Constraints ---
    # Constraint 1: Each pickup & delivery point must be visited exactly once by one truck
    for j in nodes:
        if j != 0:  # Skip depot
            model.addConstr(gp.quicksum(x[i, j, k] for i in nodes for k in trucks
                                        if i != j) == 1,
                            name=f'visit_to_{j}')

    # Constraint 2: One order's pickup and delivery points must be visited by the same truck
    for i in pickup_nodes:
        for k in trucks:
            model.addConstr(gp.quicksum(x[i, j, k] for j in nodes
                                        if j != i) == gp.quicksum(
                                            x[i + order_num, j, k] for j in nodes
                                            if j != i + order_num),
                            name=f'same_truck_{i}_{k}')

    # Constraint 3: Flow conservation for each point
    for i in nodes:
        for k in trucks:
            model.addConstr(gp.quicksum(x[i, j, k] for j in nodes
                                        if j != i) == gp.quicksum(x[j, i, k]
                                                                for j in nodes
                                                                if j != i),
                            name=f'flow_cons_{i}_{k}')

    # Constraint 4: Each truck must leave the depot once
    for k in trucks:
        model.addConstr(gp.quicksum(x[0, j, k] for j in nodes if j != 0) == 1,
                        name=f'truck_{k}_leaves_depot')

    # Constraint 5: The order's pickup point must be visited before its delivery point
    for i in pickup_nodes:
        model.addConstr(t_a[i] <= t_d[i + order_num],
                        name=f'pickup_before_delivery_{i}')

    # Constraints 6 & 7: Time window constraints (already handled by variable bounds)

    # Constraint 8: Time consistency constraints
    for i in nodes:
        for j in nodes:
            if i != j:
                for k in trucks:
                    # If truck k goes from i to j, arrival time at j must be after departure from i plus travel time
                    model.addConstr(t_d[i] + 60 * distance[i, j] / speed - M *
                                    (1 - x[i, j, k]) <= t_a[j],
                                    name=f'time_cons_{i}_{j}_{k}')

    # Constraint 9: The truck's load must not exceed its maximum load (handled by variable bounds)

    # Constraint 10: The truck's load must be updated at every visited point
    for i in nodes:
        for j in nodes:
            if i != j:
                for k in trucks:
                    model.addConstr(load[j, k] >= load[i, k] + demands[j] - M *
                                    (1 - x[i, j, k]),
                                    name=f'load_update_lb_{i}_{j}_{k}')
                    model.addConstr(load[j, k] <= load[i, k] + demands[j] + M *
                                    (1 - x[i, j, k]),
                                    name=f'load_update_ub_{i}_{j}_{k}')

    # Constraint 11: The truck's load must be initialized at the depot
    for k in trucks:
        model.addConstr(load[0, k] == 0, name=f'initial_load_{k}')


    # Constraint 12: Eliminate sub-tours (using lazy constraints approach)
    def find_subtours(edges):
        # Create adjacency list
        adj = {i: [] for i in nodes}
        for i, j in edges:
            adj[i].append(j)

        # Find all subtours
        unvisited = set(nodes)
        subtours = []
        while unvisited:
            current = next(iter(unvisited))
            subtour = []
            while current in unvisited:
                unvisited.remove(current)
                subtour.append(current)
                next_nodes = adj.get(current, [])
                if not next_nodes:
                    break
                current = next_nodes[0]
            if len(subtour) < len(nodes):
                subtours.append(subtour)
        return subtours


    def subtour_cb(model, where):
        if where == GRB.Callback.MIPSOL:
            # Get values of binary variables
            x_vals = model.cbGetSolution(model._vars)
            for k in trucks:
                edges = [
                    (i, j) for i in nodes for j in nodes
                    if i != j and (i, j, k) in x_vals and x_vals[i, j, k] > 0.5
                ]

                subtours = find_subtours(edges)

                # Add subtour elimination constraints
                for S in subtours:
                    if len(S) < len(
                            nodes
                    ) and 0 not in S:  # Exclude depot from subtour check
                        model.cbLazy(
                            gp.quicksum(x[i, j, k] for i in S for j in S
                                        if i != j) <= len(S) - 1)


    # Enable lazy constraints
    model._vars = x
    model.Params.lazyConstraints = 1

    # Set a time limit (optional)
    model.Params.timeLimit = 3600  # 1 hour

    # Optimize model with callback
    model.optimize(subtour_cb)

    if model.status == GRB.OPTIMAL:
        return {"status": "optimal", "obj": model.objVal}
    else:
        return {"status": f"{model.status}"}


if __name__ == "__main__":
    result = solve_PickupDeliveryVRP()
    print(result)

Solution status: OPTIMAL
Optimal objective value: 172.4960492825