A regional distribution center needs to create a delivery schedule for 4 customer orders using 3 available trucks. Each customer order is a "job" that consists of 3 separate "packages" or "operations," one for each truck. The time required to process each package on its designated truck is known.

The center wants to determine the start time for every single package to minimize the **makespan**, which is the total time elapsed until the very last package across all trucks is delivered.

The scheduling must adhere to two critical constraints:
1.  **Truck Constraint:** Each truck can only process one package at a time.
2.  **Customer Constraint:** For a single customer, their different packages (on different trucks) cannot be processed simultaneously.

The processing times (in hours) are as follows:
| Customer / Job | Package for T1 | Package for T2 | Package for T3 |
|:--------------:|:--------------:|:--------------:|:--------------:|
| C1             | 2              | 3              | 1              |
| C2             | 4              | 2              | 3              |
| C3             | 1              | 5              | 2              |
| C4             | 3              | 2              | 4              |

Problem type: MILP (Medium: 97 variables, 72 constraints, 204 nonzeros)

Mathematical model:
Set:
1. Customers / Jobs
   The set of Customers, \( J = \{C1, C2, C3, C4\} \)
2. Trucks / Machines
   The set of Trucks, \( M = \{T1, T2, T3\} \)

Parameter:
1. ProcessingTime
   The time \( p_{jm} \) required for the package of job \( j \) on machine \( m \).
   | Customer / Job | Package for T1 | Package for T2 | Package for T3 |
   | :--- | :--- | :--- | :--- |
   | C1 | 2 | 3 | 1 |
   | C2 | 4 | 2 | 3 |
   | C3 | 1 | 5 | 2 |
   | C4 | 3 | 2 | 4 |
   
Decision variable:
1. StartTime
   Continuous variable \( s_{jm} \geq 0 \), the start time of the package for job \( j \) on machine \( m \).
2. Makespan
   Continuous variable \( C_{max} \geq 0 \), representing the total completion time.
3. MachineSequence
   Binary variable \( x_{ijm} \in \{0,1\} \). If two jobs `i` and `j` are on the same machine `m`, this variable decides their order: `x_ijm = 1` if `i` comes before `j`.
4. JobSequence
   Binary variable \( y_{jmn} \in \{0,1\} \). For a single job `j`, this variable decides the order of its operations on machines `m` and `n`: `y_jmn = 1` if the operation on `m` comes before the operation on `n`.

Objective:
Minimize the makespan.
min: \( C_{max} \)

Constraint:
1. Machine Non-Overlap: For any two jobs `i` and `j` on the same machine `m`, their processing intervals cannot overlap.
   \( s_{im} + p_{im} \leq s_{jm} + M \cdot (1 - x_{ijm}), \quad \forall i, j \in J, i \neq j, m \in M \)
   \( s_{jm} + p_{jm} \leq s_{im} + M \cdot x_{ijm}, \quad \forall i, j \in J, i \neq j, m \in M \)
2. Job Non-Overlap: For any single job `j`, its operations on two different machines `m` and `n` cannot overlap.
   \( s_{jm} + p_{jm} \leq s_{jn} + M \cdot (1 - y_{jmn}), \quad \forall j \in J, m, n \in M, m \neq n \)
   \( s_{jn} + p_{jn} \leq s_{jm} + M \cdot y_{jmn}, \quad \forall j \in J, m, n \in M, m \neq n \)
3. Makespan Definition: The makespan must be greater than or equal to the completion time of every single package.
   \( s_{jm} + p_{jm} \leq C_{max}, \quad \forall j \in J, m \in M \)

Type:
Mixed, Linear, Linear
MILP

Gurobi implementation:
import gurobipy as gp
from gurobipy import GRB

def solve_open_shop_scheduling(
    jobs=['C1', 'C2', 'C3', 'C4'],
    machines=['T1', 'T2', 'T3'],
    processing_time={
        'C1': {'T1': 2, 'T2': 3, 'T3': 1},
        'C2': {'T1': 4, 'T2': 2, 'T3': 3},
        'C3': {'T1': 1, 'T2': 5, 'T3': 2},
        'C4': {'T1': 3, 'T2': 2, 'T3': 4}
    }
):
    """
    Solves a classic Open Shop Scheduling problem to minimize makespan.
    """
    model = gp.Model("OpenShopScheduling")

    M = sum(processing_time[j][m] for j in jobs for m in machines)

    s = model.addVars(jobs, machines, vtype=GRB.CONTINUOUS, name="start_time")
    C_max = model.addVar(vtype=GRB.CONTINUOUS, name="makespan")
    x = model.addVars(jobs, jobs, machines, vtype=GRB.BINARY, name="machine_sequence")
    y = model.addVars(jobs, machines, machines, vtype=GRB.BINARY, name="job_sequence")

    model.setObjective(C_max, GRB.MINIMIZE)

    for j in jobs:
        for m in machines:
            model.addConstr(s[j, m] + processing_time[j][m] <= C_max, f"makespan_{j}_{m}")

        for m1_idx, m1 in enumerate(machines):
            for m2_idx, m2 in enumerate(machines):
                if m1_idx < m2_idx:
                    model.addConstr(
                        s[j, m1] + processing_time[j][m1] <= s[j, m2] + M * (1 - y[j, m1, m2]),
                        f"job_non_overlap1_{j}_{m1}_{m2}")
                    model.addConstr(
                        s[j, m2] + processing_time[j][m2] <= s[j, m1] + M * y[j, m1, m2],
                        f"job_non_overlap2_{j}_{m1}_{m2}")

    for m in machines:
        for j1_idx, j1 in enumerate(jobs):
            for j2_idx, j2 in enumerate(jobs):
                if j1_idx < j2_idx:
                    model.addConstr(
                        s[j1, m] + processing_time[j1][m] <= s[j2, m] + M * (1 - x[j1, j2, m]),
                        f"machine_non_overlap1_{j1}_{j2}_{m}")
                    model.addConstr(
                        s[j2, m] + processing_time[j2][m] <= s[j1, m] + M * x[j1, j2, m],
                        f"machine_non_overlap2_{j1}_{j2}_{m}")

    model.optimize()

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

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


Solution status: OPTIMAL
Optimal objective value: 12