A courier company needs to plan an optimal single-delivery route for one of its couriers. The route starts at a central depot, serves 10 customers, and then returns to the depot. The courier's travel speed is constant at 30 km/h, and the travel cost is ¥5 per kilometer.
Each customer has a desired "service time window." If the courier's actual arrival time deviates from the center of the customer's desired time window, the company incurs a "customer satisfaction penalty" cost. This cost is proportional to the square of the time deviation, calculated by the formula: $Cost = 0.2 \times (\text{Actual Arrival Time} - \text{Time Window Center})^2$.
The company's objective is to determine a unique customer visit sequence and calculate a precise arrival time for each customer to minimize the cost. The courier departs at 8:30 AM from the depot, located at coordinates (0,0). All relevant data are listed in the table below.

Table 1: Customer Data and Service Requirements

| Location ID | Location Type | Position (x, y) km | Service Time (minutes) | Desired Service Window |
|:---:|:---:|:---:|:---:|:---:|
| 1 | Customer | (3, 5) | 5 | 9:30 - 10:00 |
| 2 | Customer | (5, 8) | 8 | 10:00 - 10:20 |
| 3 | Customer | (2, 1) | 5 | 9:00 - 9:20 |
| 4 | Customer | (8, 6) | 10 | 10:30 - 11:00 |
| 5 | Customer | (4, 2) | 6 | 9:45 - 10:15 |
| 6 | Customer | (9, 8) | 7 | 11:15 - 11:40 |
| 7 | Customer | (1, 9) | 9 | 10:10 - 10:40 |
| 8 | Customer | (1, 3) | 5 | 9:15 - 9:40 |
| 9 | Customer | (6, 4) | 12 | 10:20 - 10:50 |
| 10 | Customer | (7, 1) | 6 | 11:00 - 11:20 |

Problem type: NLP (Small: 143 variables, 255 constraints, 853 nonzeros)

Mathematical model:
Set:
1. Locations
The set of all locations, \( N = \{0, 1, 2, 3, 4, 5\} \), where 0 is the depot and {1, 2, 3, 4, 5} is the set of customers C.
2. Arcs
The set of possible travel arcs between locations, \( A = \{(i, j) | i, j \in N, i \neq j\} \).

Parameter:
1. Position
# The (x, y) coordinates for each location in kilometers.
{0: (0,0), 1: (3,5), 2: (5,8), 3: (2,1), 4: (8,6), 5: (4,2), 6: (9,8), 7: (1,9), 8: (1,3), 9: (6,4), 10: (7,1)}
2. Distance
# The Euclidean distance between location i and j, calculated from Position. Distance_ij = sqrt((x_i - x_j)^2 + (y_i - y_j)^2).
Distance_ij,  ∀ (i, j) ∈ A
3. TravelTime
# The time in minutes to travel from location i to j. TravelTime_ij = (Distance_ij / 30 km/h) * 60 min/h.
TravelTime_ij, ∀ (i, j) ∈ A
4. ServiceTime
# The service time required at each customer location in minutes.
{1: 5, 2: 8, 3: 5, 4: 10, 5: 6, 6: 7, 7: 9, 8: 5, 9: 12, 10: 6}
5. TimeWindowCenter
# The center of the desired service time window for each customer, in minutes from midnight. (e.g., 9:30 = 570)
{1: 585, 2: 610, 3: 550, 4: 645, 5: 600, 6: 687.5, 7: 625, 8: 567.5, 9: 635, 10: 660}
6. DepartureTime
# The departure time from the depot (location 0) in minutes from midnight.
510 # (8:30 AM)
7. TravelCostRate
# The cost per kilometer of travel.
5 # in currency units
8. PenaltyCoefficient
# The coefficient for calculating the customer satisfaction penalty.
0.2

Decision variable:
1. Path
Binary variable, \( x_{ij} \), \( \forall (i, j) \in A \). \( x_{ij} = 1 \) if the courier travels directly from location i to location j, and 0 otherwise.
2. ArrivalTime
Continuous variable, \( t_i \), \( \forall i \in N \). The time at which the courier arrives at location i, in minutes from midnight.
3. Sequence
Integer variable, \( u_i \), \( \forall i \in C \). An auxiliary variable to eliminate sub-tours, representing the position of customer i in the sequence.

Objective:
1. Minimize the sum of total travel cost and total satisfaction penalty cost.
min: \( \sum_{i \in N} \sum_{j \in N, i \neq j} \text{TravelCostRate} \cdot \text{Distance}_{ij} \cdot x_{ij} + \sum_{i \in C} \text{PenaltyCoefficient} \cdot (t_i - \text{TimeWindowCenter}_i)^2 \)

Constraint:
1. The courier must leave from the depot to exactly one customer.
\( \sum_{j \in C} x_{0j} = 1 \)
2. The courier must arrive at the depot from exactly one customer.
\( \sum_{i \in C} x_{i0} = 1 \)
3. Each customer is visited exactly once.
\( \sum_{j \in N, j \neq i} x_{ji} = 1, \forall i \in C \)
\( \sum_{j \in N, j \neq i} x_{ij} = 1, \forall i \in C \)
4. Time flow constraint. If the courier travels from i to j, the arrival time at j must be at least the arrival time at i plus service time at i and travel time from i to j. (M is a large number).
\( t_j \geq t_i + \text{ServiceTime}_i + \text{TravelTime}_{ij} - M \cdot (1 - x_{ij}), \forall i \in C, j \in C, i \neq j \)
5. Time flow constraint for travel from the depot.
\( t_j \geq t_0 + \text{TravelTime}_{0j} - M \cdot (1 - x_{0j}), \forall j \in C \)
6. The arrival time at the depot is the departure time.
\( t_0 = \text{DepartureTime} \)
7. Sub-tour elimination constraint (MTZ formulation).
\( u_i - u_j + |C| \cdot x_{ij} \leq |C| - 1, \forall i, j \in C, i \neq j \)
8. Sequence variable bounds.
\( 1 \leq u_i \leq |C|, \forall i \in C \)

Type:
Mixed-Integer, Non-linear, Linear
NLP

Gurobi implementation:
import gurobipy as gp
from gurobipy import GRB
from typing import List, Dict, Tuple
import math

def solve_courier_routing(
    Customers: List[int] = [i for i in range(1, 11)],
    Depot: int = 0,
    Positions: Dict[int, Tuple[float, float]] = {
        0: (0, 0), 1: (3, 5), 2: (5, 8), 3: (2, 1), 4: (8, 6), 5: (4, 2), 6: (9, 8), 7: (1, 9), 8: (1, 3), 9: (6, 4), 10: (7, 1)
    },
    ServiceTime: Dict[int, int] = {1: 5, 2: 8, 3: 5, 4: 10, 5: 6, 6: 7, 7: 9, 8: 5, 9: 12, 10: 6, 0: 0},
    TimeWindows: Dict[int, Tuple[str, str]] = {
        1: ("9:30", "10:00"), 2: ("10:00", "10:20"), 3: ("9:00", "9:20"),
        4: ("10:30", "11:00"), 5: ("9:45", "10:15"), 6: ("11:15", "11:40"),
        7: ("10:10", "10:40"), 8: ("9:15", "9:40"), 9: ("10:20", "10:50"),
        10: ("11:00", "11:20")
    },
    DepartureTimeStr: str = "8:30",
    TravelSpeed_kmh: float = 30.0,
    TravelCostRate_per_km: float = 5.0,
    PenaltyCoefficient: float = 0.2
):
    """
    Solves the courier routing problem with soft time windows and a quadratic penalty function.
    This version includes corrections for sub-tour elimination.
    """
    # --- Data Pre-processing ---
    Locations = [Depot] + Customers
    
    def time_to_minutes(t_str: str) -> int:
        h, m = map(int, t_str.split(':'))
        return h * 60 + m

    DepartureTime = time_to_minutes(DepartureTimeStr)
    TimeWindowCenter = {
        c: (time_to_minutes(tw[0]) + time_to_minutes(tw[1])) / 2
        for c, tw in TimeWindows.items()
    }

    def euclidean_distance(p1, p2):
        return math.sqrt((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2)

    Distances = {
        (i, j): euclidean_distance(Positions[i], Positions[j])
        for i in Locations for j in Locations if i != j
    }
    
    TravelTimes = {
        (i, j): (Distances[i, j] / TravelSpeed_kmh) * 60
        for i, j in Distances
    }
    
    # A large number for Big-M constraints
    M = 24 * 60 # Max minutes in a day

    # --- Model Initialization ---
    model = gp.Model("CourierRoutingWithTimeWindows")

    # --- Decision Variables ---
    x = model.addVars(Locations, Locations, name="Path", vtype=GRB.BINARY)
    t = model.addVars(Locations, name="ArrivalTime", vtype=GRB.CONTINUOUS, lb=0)
    u = model.addVars(Locations, name="Sequence", vtype=GRB.CONTINUOUS, lb=0)

    # --- Objective Function ---
    travel_cost = gp.quicksum(TravelCostRate_per_km * Distances[i, j] * x[i, j] 
                              for i in Locations for j in Locations if i != j)
    
    penalty_cost = gp.quicksum(PenaltyCoefficient * (t[i] - TimeWindowCenter[i]) * (t[i] - TimeWindowCenter[i])
                               for i in Customers)

    model.setObjective(travel_cost + penalty_cost, GRB.MINIMIZE)

    # --- Constraints ---
    model.addConstrs((x[i, i] == 0 for i in Locations), "NoSelfLoops")
    model.addConstr(gp.quicksum(x[Depot, j] for j in Customers) == 1, "LeaveDepot")
    model.addConstr(gp.quicksum(x[i, Depot] for i in Customers) == 1, "ReturnToDepot")
    model.addConstrs((gp.quicksum(x[i, j] for i in Locations if i != j) == 1 for j in Customers), "EnterCustomerOnce")
    model.addConstrs((gp.quicksum(x[j, i] for i in Locations if i != j) == 1 for j in Customers), "LeaveCustomerOnce")
    model.addConstr(t[Depot] == DepartureTime, "SetDepartureTime")

    # Constraint 5: Time flow constraints 
    for i in Locations:
        for j in Customers:
            if i == j:
                continue
            model.addConstr(t[j] >= t[i] + ServiceTime[i] + TravelTimes[i, j] - M * (1 - x[i, j]), f"TimeFlow_{i}_{j}")
    
    # Constraint 6: Sub-tour elimination (MTZ)
    model.addConstr(u[Depot] == 0, "MTZ_Depot_Start")
    M_u = len(Customers) + 1 # A sufficiently large number for the u sequence

    for i in Locations:
        for j in Customers:
            if i == j:
                continue
            # If courier travels from i to j, then u[j] must be at least u[i] + 1
            model.addConstr(u[j] >= u[i] + 1 - M_u * (1 - x[i, j]), f"MTZ_Flow_{i}_{j}")
    
    # Add bounds on u for customers to strengthen the formulation
    model.addConstrs((u[i] >= 1 for i in Customers), "MTZ_Lower_Bound")
    model.addConstrs((u[i] <= len(Customers) for i in Customers), "MTZ_Upper_Bound")
    
    # --- Solve the Model ---
    model.optimize()

    # --- Return Results ---
    if model.Status == GRB.OPTIMAL:
        return {"status": "optimal", "obj": model.ObjVal}
    else:
        return {"status": f"{model.Status}"}


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


Solution status: OPTIMAL
Optimal objective value: 480.57577334344387