A logistics company operates a two-echelon distribution network with 1 central depot (D), 3 intermediate warehouses (W1, W2, W3), and 15 customers (C1-C15). The depot has 3 trucks with a capacity of 50 units each for first-level transport. Each warehouse has an allocation of 2 smaller vehicles with a capacity of 20 units each for second-level delivery.

The goal is to assign customers to warehouses and plan optimal delivery routes for all vehicles to minimize the total distance traveled, subject to the following constraints:
- Each customer must be served by exactly one van.
- Each van must start and end its tour at its assigned warehouse.
- The total demand on any vehicle's route cannot exceed its capacity.
- The total demand flowing through an opened warehouse cannot exceed the capacity of the truck serving it.

**Distances (km):**
- **Depot to Warehouses:** Depot-W1: 30, Depot-W2: 40, Depot-W3: 35.
- **Between Warehouses:** W1-W2: 25, W1-W3: 20, W2-W3: 30.
- **Between Customers:** A full distance matrix is provided for travel between any two customers as follows:

| | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 | C10 | C11 | C12 | C13 | C14 | C15 |
|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| C1 | 0 | 15 | 24 | 11 | 20 | 9 | 20 | 11 | 5 | 18 | 15 | 24 | 12 | 10 | 10 |
| C2 | 15 | 0 | 18 | 21 | 13 | 12 | 5 | 12 | 15 | 24 | 18 | 11 | 22 | 19 | 13 |
| C3 | 24 | 18 | 0 | 6 | 13 | 11 | 24 | 11 | 13 | 10 | 11 | 18 | 19 | 13 | 11 |
| C4 | 11 | 21 | 6 | 0 | 11 | 16 | 12 | 18 | 11 | 19 | 10 | 11 | 14 | 19 | 10 |
| C5 | 20 | 13 | 13 | 11 | 0 | 14 | 24 | 10 | 16 | 21 | 16 | 11 | 14 | 17 | 15 |
| C6 | 9 | 12 | 11 | 16 | 14 | 0 | 19 | 18 | 23 | 19 | 18 | 10 | 15 | 11 | 12 |
| C7 | 20 | 5 | 24 | 12 | 24 | 19 | 0 | 16 | 11 | 17 | 12 | 17 | 13 | 17 | 12 |
| C8 | 11 | 12 | 11 | 18 | 10 | 18 | 16 | 0 | 18 | 13 | 12 | 17 | 18 | 13 | 16 |
| C9 | 5 | 15 | 13 | 11 | 16 | 23 | 11 | 18 | 0 | 13 | 24 | 21 | 18 | 18 | 11 |
| C10 | 18 | 24 | 10 | 19 | 21 | 19 | 17 | 13 | 13 | 0 | 12 | 22 | 12 | 19 | 17 |
| C11 | 15 | 18 | 11 | 10 | 16 | 18 | 12 | 12 | 24 | 12 | 0 | 16 | 18 | 15 | 13 |
| C12 | 24 | 11 | 18 | 11 | 11 | 10 | 17 | 17 | 21 | 22 | 16 | 0 | 19 | 19 | 13 |
| C13 | 12 | 22 | 19 | 14 | 14 | 15 | 13 | 18 | 18 | 12 | 18 | 19 | 0 | 13 | 18 |
| C14 | 10 | 19 | 13 | 19 | 17 | 11 | 17 | 13 | 18 | 19 | 15 | 19 | 13 | 0 | 18 |
| C15 | 10 | 13 | 11 | 10 | 15 | 12 | 12 | 16 | 11 | 17 | 13 | 13 | 18 | 18 | 0 |

- **Warehouses to Customers:** The specific distances are defined as follows:

| From | To | Dist | | From | To | Dist | | From | To | Dist |
| :--- | :--- | :--- |-|:--- | :--- | :--- |-|:--- | :--- | :--- |
| W1   | C1   | 12   | | W2   | C1   | 28   | | W3   | C1   | 18   |
| W1   | C2   | 8    | | W2   | C2   | 22   | | W3   | C2   | 25   |
| W1   | C3   | 15   | | W2   | C3   | 10   | | W3   | C3   | 9    |
| W1   | C4   | 11   | | W2   | C4   | 14   | | W3   | C4   | 19   |
| W1   | C5   | 7    | | W2   | C5   | 18   | | W3   | C5   | 24   |
| W1   | C6   | 14   | | W2   | C6   | 9    | | W3   | C6   | 16   |
| W1   | C7   | 10   | | W2   | C7   | 13   | | W3   | C7   | 12   |
| W1   | C8   | 9    | | W2   | C8   | 17   | | W3   | C8   | 21   |
| W1   | C9   | 13   | | W2   | C9   | 11   | | W3   | C9   | 7    |
| W1   | C10  | 6    | | W2   | C10  | 20   | | W3   | C10  | 23   |
| W1   | C11  | 14   | | W2   | C11  | 7    | | W3   | C11  | 10   |
| W1   | C12  | 9    | | W2   | C12  | 16   | | W3   | C12  | 17   |
| W1   | C13  | 11   | | W2   | C13  | 19   | | W3   | C13  | 22   |
| W1   | C14  | 7    | | W2   | C14  | 12   | | W3   | C14  | 14   |
| W1   | C15  | 13   | | W2   | C15  | 15   | | W3   | C15  | 8    |

**Demands (units):**
- C1:4, C2:7, C3:5, C4:6, C5:3, C6:8, C7:5, C8:6, C9:4, C10:7, C11:5, C12:6, C13:3, C14:8, C15:5.

Problem type: ILP (Medium: 2145 variables, 1695 constraints, 8622 nonzeros)

Mathematical model:
Sets:
1.  W: Set of potential warehouse locations. W = {W1, W2, W3}
2.  C: Set of customers. C = {C1, C2, ..., C15}
3.  N: Set of all nodes for routing, N = W U C.
4.  K: Set of available vans at each warehouse. K = {1, 2}

Parameters:
1.  demand_i: Demand of customer i, for i in C.
2.  dist_ij: Distance between node i and node j, for i, j in N. This combines customer-customer, warehouse-customer, and depot-warehouse distances.
3.  CAP_TRUCK: Capacity of a single truck from the depot (50 units).
4.  CAP_VAN: Capacity of a single van from a warehouse (20 units).
5.  NUM_VANS: Number of vans available at each warehouse (2).

Decision Variables:
1.  y_w: Binary variable. y_w = 1 if warehouse w is used, 0 otherwise. For w in W.
2.  z_iw: Binary variable. z_iw = 1 if customer i is assigned to warehouse w, 0 otherwise. For i in C, w in W.
3.  x_ijkw: Binary variable. x_ijkw = 1 if van k from warehouse w travels from node i to node j, 0 otherwise. For i, j in N, w in W, k in K.
4.  u_ikw: Continuous variable for subtour elimination (MTZ formulation). Represents the load accumulated by van k from warehouse w after visiting customer i. For i in C, w in W, k in K.

Objective Function:
Minimize the total distance traveled in both echelons.
1.  First-echelon distance: Cost of round trips from the depot to each opened warehouse.
2.  Second-echelon distance: Cost of all van routes from warehouses to customers.

Minimize Z = sum_{w in W} (2 * dist_depot_w * y_w) + sum_{i in N} sum_{j in N} sum_{w in W} sum_{k in K} (dist_ij * x_ijkw)

Constraints:
1.  Warehouse Activation: A customer can only be assigned to a warehouse if that warehouse is active (used).
    forall i in C, w in W:
    z_iw <= y_w

2.  Customer Assignment: Each customer must be assigned to exactly one warehouse.
    forall i in C:
    sum_{w in W} z_iw = 1

3.  Warehouse Capacity: The total demand of customers assigned to a warehouse cannot exceed the truck capacity.
    forall w in W:
    sum_{i in C} (demand_i * z_iw) <= CAP_TRUCK * y_w

4.  Van Assignment: If a customer is assigned to a warehouse, it must be visited by exactly one van from that warehouse.
    forall i in C, w in W:
    sum_{k in K} sum_{j in N, j != i} x_ijwk = z_iw

5.  Flow Conservation: Each van that enters a customer node must also leave it.
    forall i in C, w in W, k in K:
    sum_{j in N, j != i} x_jiwk - sum_{j in N, j != i} x_ijwk = 0

6.  Route Start: Each van used must depart from its assigned warehouse.
    forall w in W, k in K:
    sum_{j in C} x_w,j,k,w <= 1

7.  Route End: Each van used must return to its assigned warehouse.
    forall w in W, k in K:
    sum_{i in C} x_i,w,k,w <= 1

8.  Start-End Equality: A van only returns to the warehouse if it departed from it.
    forall w in W, k in K:
    sum_{j in C} x_w,j,k,w = sum_{i in C} x_i,w,k,w

9.  Van Capacity: The total demand served by any single van cannot exceed its capacity.
    forall w in W, k in K:
    sum_{i in C} (demand_i * sum_{j in N, j != i} x_ijwk) <= CAP_VAN

10. Subtour Elimination (MTZ): Prevents routes that do not include the warehouse.
    forall i in C, j in C (i != j), w in W, k in K:
    u_ikw - u_jkw + demand_i <= (CAP_VAN + demand_i) * (1 - x_ijkw)

11. Load Bounds: Defines the bounds for the load variables.
    forall i in C, w in W, k in K:
    demand_i * sum_{j in N, j != i} x_ijwk <= u_ikw <= CAP_VAN * sum_{j in N, j != i} x_ijwk

Type:
Mixed, Linear, Linear
MILP

Gurobi implementation:
import gurobipy as gp
from gurobipy import GRB
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
import numpy as np

def solve_two_echelon_vrp():
    """
    Solves a model for the Two-Echelon Vehicle Routing Problem.
    This version correctly incorporates all constraints from the problem description,
    with comments and output in English.
    """
    console = Console()
    console.print(Panel("[bold blue]Enhanced Two-Echelon VRP Solver[/bold blue]"))

    # ========== 1. Data Preparation ==========
    # --- Define sets ---
    depot = 'D0'
    warehouses = ['W1', 'W2', 'W3']
    customers = [f'C{i}' for i in range(1, 16)]
    nodes = warehouses + customers  # All nodes for the second-echelon VRP
    
    # --- Define vehicle counts and capacities ---
    num_trucks = 3
    num_vans_per_warehouse = 2
    # Total number of vans available in the fleet
    all_vans = [f'V{i}' for i in range(1, len(warehouses) * num_vans_per_warehouse + 1)]

    truck_capacity = 50
    van_capacity = 20
    
    # --- Customer demands ---
    demands = {
        'C1':4, 'C2':7, 'C3':5, 'C4':6, 'C5':3, 'C6':8, 'C7':5, 'C8':6, 
        'C9':4, 'C10':7, 'C11':5, 'C12':6, 'C13':3, 'C14':8, 'C15':5
    }

    # --- Distance Matrix ---
    # Note: The problem provides D-W, W-W, and W-C distances, but not C-C (customer-to-customer).
    # We use a fixed random seed to generate C-C distances for reproducibility.
    dist = {}
    
    # Echelon 1: Depot to Warehouses
    dist.update({ (depot, 'W1'): 30, ('W1', depot): 30, (depot, 'W2'): 40, ('W2', depot): 40, (depot, 'W3'): 35, ('W3', depot): 35 })
    
    # Distances between warehouses (loaded for completeness, though not used in routes)
    dist.update({ ('W1', 'W2'): 25, ('W2', 'W1'): 25, ('W1', 'W3'): 20, ('W3', 'W1'): 20, ('W2', 'W3'): 30, ('W3', 'W2'): 30 })

    # Echelon 2: Warehouses to Customers
    wc_dist_data = {
        'W1': [12, 8, 15, 11, 7, 14, 10, 9, 13, 6, 14, 9, 11, 7, 13],
        'W2': [28, 22, 10, 14, 18, 9, 13, 17, 11, 20, 7, 16, 19, 12, 15],
        'W3': [18, 25, 9, 19, 24, 16, 12, 21, 7, 23, 10, 17, 22, 14, 8]
    }
    for w_idx, w in enumerate(warehouses):
        for c_idx, c in enumerate(customers):
            d = wc_dist_data[w][c_idx]
            dist[w, c] = d
            dist[c, w] = d # Distances are symmetric

    # Echelon 2: Customer to Customer (generated with a fixed seed)
    np.random.seed(42)
    for i in range(len(customers)):
        for j in range(i, len(customers)):
            c1, c2 = customers[i], customers[j]
            if i == j:
                dist[c1, c2] = 0
            else:
                d = np.random.randint(5, 25)
                dist[c1, c2] = d
                dist[c2, c1] = d

    # ========== 2. Model Creation ==========
    model = gp.Model("Fully_Constrained_2E_VRP")

    # ========== 3. Decision Variables ==========
    z = model.addVars(warehouses, vtype=GRB.BINARY, name="warehouse_open")
    y = model.addVars(customers, all_vans, vtype=GRB.BINARY, name="customer_served_by_van")
    assign_vw = model.addVars(all_vans, warehouses, vtype=GRB.BINARY, name="van_at_warehouse")
    x = model.addVars(nodes, nodes, all_vans, vtype=GRB.BINARY, name="van_route")
    
    # ========== 4. Objective Function ==========
    first_echelon_cost = gp.quicksum(2 * dist[depot, w] * z[w] for w in warehouses)
    second_echelon_cost = gp.quicksum(dist[i, j] * x[i, j, v] for i in nodes for j in nodes for v in all_vans if i != j)
    model.setObjective(first_echelon_cost + second_echelon_cost, GRB.MINIMIZE)

    # ========== 5. Constraints ==========
    # --- Assignment Constraints ---
    model.addConstrs((y.sum(c, '*') == 1 for c in customers), "C1_customer_one_van")
    model.addConstrs((assign_vw.sum(v, '*') <= 1 for v in all_vans), "C2a_van_one_warehouse")
    model.addConstrs((assign_vw[v, w] <= z[w] for v in all_vans for w in warehouses), "C2b_van_assign_to_open_wh")

    # --- Warehouse Vehicle Limit Constraint (NEW) ---
    model.addConstrs((assign_vw.sum('*', w) <= num_vans_per_warehouse for w in warehouses), "C3_warehouse_van_limit")
    
    # --- Truck Fleet Size Constraint (NEW) ---
    model.addConstr(z.sum() <= num_trucks, "C4_truck_fleet_size")
    
    # --- Capacity Constraints ---
    model.addConstrs((gp.quicksum(demands[c] * y[c, v] for c in customers) <= van_capacity for v in all_vans), "C5a_van_capacity")
    for w in warehouses:
        total_demand_at_wh = gp.quicksum(demands[c] * y[c, v] * assign_vw[v, w] for c in customers for v in all_vans)
        model.addConstr(total_demand_at_wh <= truck_capacity * z[w], f"C5b_truck_capacity_{w}")
    
    # --- Routing & Linking Constraints ---
    model.addConstrs((gp.quicksum(x[i, c, v] for i in nodes if i != c) == y[c, v] for c in customers for v in all_vans), "C6a_link_visit_in")
    model.addConstrs((gp.quicksum(x[c, j, v] for j in nodes if j != c) == y[c, v] for c in customers for v in all_vans), "C6b_link_visit_out")
    
    for v in all_vans:
        # A van must depart from its assigned warehouse
        model.addConstrs((gp.quicksum(x[w, j, v] for j in customers) <= assign_vw[v, w] for w in warehouses), f"C7a_van_departs_from_assigned_wh_{v}")
        # Each van can start at most one tour
        model.addConstr(gp.quicksum(x[w, j, v] for w in warehouses for j in customers) <= 1, f"C7b_van_departs_once_{v}")
        # The van must return to the same warehouse it departed from
        model.addConstr(gp.quicksum(x[w, j, v] for w in warehouses for j in customers) == 
                        gp.quicksum(x[i, w, v] for w in warehouses for i in customers), f"C7c_van_returns_{v}")
        
    # --- Subtour Elimination Constraints (MTZ) ---
    u = model.addVars(customers, all_vans, vtype=GRB.CONTINUOUS, name="subtour_elim_u")
    for v in all_vans:
        for i in customers:
            model.addConstr(u[i, v] <= van_capacity, f"C8a_u_upper_bound_{i}_{v}")
            model.addConstr(u[i, v] >= demands[i] * y[i, v], f"C8b_u_lower_bound_{i}_{v}")
            for j in customers:
                if i != j:
                    model.addConstr(u[i, v] - u[j, v] + van_capacity * x[i, j, v] <= van_capacity - demands[j] * y[j,v], f"C8c_subtour_elim_{i}_{j}_{v}")

    # ========== 6. Solve ==========
    model.Params.MIPGap = 0.01   # Set a 1% optimality gap tolerance
    model.optimize()

    # ========== 7. Display Results ==========
    if model.Status == GRB.OPTIMAL or (model.Status == GRB.TIME_LIMIT and model.SolCount > 0):
        status = "Optimal Solution Found" if model.Status == GRB.OPTIMAL else f"Time Limit Reached - Best Solution Found"
        console.print(Panel(f"[bold green]{status}!\nMinimized Total Distance: {model.ObjVal:.2f} km[/bold green]"))

        open_hubs = [w for w in warehouses if z[w].X > 0.5]
        console.print(f"\n[bold]Opened Warehouses:[/bold] {', '.join(open_hubs) or 'None'}")

        route_table = Table(title="[bold]Vehicle Routes and Load Details[/bold]")
        route_table.add_column("Vehicle", style="cyan", justify="center")
        route_table.add_column("Type", style="yellow", justify="center")
        route_table.add_column("Route", style="magenta", width=60)
        route_table.add_column("Load/Capacity", style="green", justify="right")
        route_table.add_column("Distance (km)", style="red", justify="right")

        for w in open_hubs:
            wh_demand = sum(demands[c] * y[c,v].X * assign_vw[v,w].X for c in customers for v in all_vans)
            route_table.add_row(f"Truck (for {w})", "Echelon 1", f"{depot} -> {w} -> {depot}", f"{wh_demand:.0f}/{truck_capacity}", f"{2*dist[depot, w]:.1f}")
        
        for v in all_vans:
            for w in warehouses:
                if assign_vw[v, w].X > 0.5:
                    tour_edges = [(i, j) for i in nodes for j in nodes if i != j and x[i, j, v].X > 0.5]
                    if not tour_edges: continue
                    
                    # Reconstruct the tour starting from the warehouse
                    tour = [w]
                    current_node = w
                    
                    while True:
                        found_next = False
                        for i, j in tour_edges:
                            if i == current_node and j not in tour:
                                tour.append(j)
                                current_node = j
                                found_next = True
                                break
                        if not found_next:
                            break # All connected edges have been added
                    
                    if tour[-1] != w: tour.append(w) # Ensure the tour is closed

                    route_str = " -> ".join(tour)
                    route_load = sum(demands.get(c, 0) for c in tour if c in customers)
                    route_dist = sum(dist.get((i, j), 0) for i, j in tour_edges)
                    route_table.add_row(v, "Echelon 2", route_str, f"{route_load}/{van_capacity}", f"{route_dist:.1f}")
        console.print(route_table)
    else:
        console.print(f"[bold red]No feasible solution found. Gurobi Status Code: {model.Status}[/bold red]")

if __name__ == "__main__":
    solve_two_echelon_vrp()


Solution status: OPTIMAL
Optimal objective value: 275