A manufacturing plant specializes in producing customized parts, where production is scheduled based on customer orders. The factory has five machines (M1-M5), each with distinct functions: M1 for cutting, M2 for drilling, M3 for welding, M4 for polishing, and M5 for final assembly. Currently, four orders (J1-J4) have been received, each requiring a fixed sequence of processing steps that must be completed on specific machines in a prescribed order. The processing steps, required machines, and execution times for each task are as follows:

J1: (1) Cutting on M1, taking 3 hours; (2) Drilling on M2, taking 2 hours; (3) Assembly on M5, taking 4 hours.  
J2: (1) Welding on M3, taking 4 hours; (2) Polishing on M4, taking 3 hours; (3) Assembly on M5, taking 2 hours.  
J3: (1) Drilling on M2, taking 3 hours; (2) Cutting on M1, taking 5 hours; (3) Polishing on M4, taking 2 hours.  
J4: (1) Cutting on M1, taking 2 hours; (2) Welding on M3, taking 3 hours; (3) Polishing on M4, taking 4 hours.  

Each machine can only handle one task at a time and cannot process multiple tasks simultaneously. Once a step begins processing, it cannot be paused or interrupted midway. How should the order processing be scheduled to minimize the total completion time for all tasks?

Problem type: ILP (Medium: 22 variables, 34 constraints, 82 nonzeros)

Mathematical model:
Set:
1. Machine
The set of machines, \( M = \{1, 2, 3, 4, 5\} \)
2. Job
The set of jobs, \( J = \{1, 2, 3, 4\} \)

Parameter:
1. JobProcess
[
[(1, 3), (2, 2), (5, 4)],
[(3, 4), (4, 3), (5, 2)],
[(2, 3), (1, 5), (4, 2)],
[(1, 2), (3, 3), (4, 4)]
]
2. MachineID
['M1', 'M2', 'M3', 'M4', 'M5']
3. JobID
['J1', 'J2', 'J3', 'J4']
4. BigM
A sufficiently large number, computed as the sum of all processing times.

Decision variable:
1. StartTime
Integer variable, \( StartTime_{j,k} \forall j \in J, k \in \{0, 1, ..., |JobProcess_j|-1\}\), where \( StartTime_{j,k} \) is the start time of the \( k^{th} \) process of job \( j \).
2. Makespan
Integer variable representing the maximum completion time of all jobs.
3. Indicator
Binary variable, \( Indicator_{j_1,k_1,j_2,k_2} \) for each pair of processes on the same machine, where value 1 means process (j_1,k_1) comes before (j_2,k_2), and 0 means (j_2,k_2) comes before (j_1,k_1).

Objective:
1. Minimize the makespan.
min: Makespan

Constraint:
1. The start time of the next process must be greater than or equal to the end time of the previous process.
StartTime_{j,k+1} \geq StartTime_{j,k} + JobProcess_j[k][1] \forall j \in J, k \in \{0, 1, ..., |JobProcess_j|-2\}

2. One machine can only process one job at a time (disjunctive constraints using binary indicators).
For each machine m and each pair of processes (j_1,k_1) and (j_2,k_2) that use machine m:
a. StartTime_{j_1,k_1} + JobProcess_{j_1}[k_1][1] \leq StartTime_{j_2,k_2} + BigM * Indicator_{j_1,k_1,j_2,k_2}
b. StartTime_{j_2,k_2} + JobProcess_{j_2}[k_2][1] \leq StartTime_{j_1,k_1} + BigM * (1 - Indicator_{j_1,k_1,j_2,k_2})

3. The start time of all processes must be non-negative.
StartTime_{j,k} \geq 0 \forall j \in J, k \in \{0, 1, ..., |JobProcess_j|-1\}

4. Makespan definition: makespan must be at least the completion time of every job.
Makespan \geq StartTime_{j,|JobProcess_j|-1} + JobProcess_j[|JobProcess_j|-1][1] \forall j \in J

Type:
Integer, Linear, Linear
ILP

Gurobi implementation:
import gurobipy as gp
from gurobipy import GRB
import json
import os
machines = list(range(1, 6))  # M = {1, 2, 3, 4, 5}
jobs = list(range(1, 5))  # J = {1, 2, 3, 4}

# JobProcess[j-1] contains a list of tuples (machine, process_time) for job j
job_processes = [
    [(1, 3), (2, 2), (5, 4)],  # Job 1 processes
    [(3, 4), (4, 3), (5, 2)],  # Job 2 processes
    [(2, 3), (1, 5), (4, 2)],  # Job 3 processes
    [(1, 2), (3, 3), (4, 4)]  # Job 4 processes
]

machine_id = ['M1', 'M2', 'M3', 'M4', 'M5']
job_id = ['J1', 'J2', 'J3', 'J4']

def solve_job_shop_scheduling(job_processes=[[(1, 3), (2, 2), (5, 4)], [(3, 4), (4, 3), (5, 2)], [(2, 3), (1, 5), (4, 2)], [(1, 2), (3, 3), (4, 4)]]):
    # Data
    machines = list(range(1, 6))
    jobs = list(range(1, 5))

    # Create a new model
    model = gp.Model("JobShopScheduling")

    # Calculate a big-M value (an upper bound on the makespan)
    big_M = sum(duration for job in job_processes for _, duration in job)

    # Create decision variables
    start_times = {}
    for j in range(len(jobs)):
        for k in range(len(job_processes[j])):
            start_times[j, k] = model.addVar(vtype=GRB.INTEGER,
                                             lb=0,
                                             name=f"StartTime_{j+1}_{k+1}")

    # Create makespan variable
    makespan = model.addVar(vtype=GRB.INTEGER, name="Makespan")

    # Set objective
    model.setObjective(makespan, GRB.MINIMIZE)

    # Constraint 1: The start time of the next process must be greater than or equal to the end time of the previous process
    for j in range(len(jobs)):
        for k in range(len(job_processes[j]) - 1):
            model.addConstr(
                start_times[j,
                            k + 1] >= start_times[j, k] + job_processes[j][k][1],
                f"PrecedenceJob_{j+1}_Process_{k+1}_to_{k+2}")

    # Constraint 2: One machine can only process one job at a time
    for m in machines:
        # Find all processes that use this machine
        processes_on_machine = []
        for j in range(len(jobs)):
            for k in range(len(job_processes[j])):
                if job_processes[j][k][0] == m:
                    processes_on_machine.append((j, k))

        # Add non-overlap constraints for each pair of processes on this machine
        for i in range(len(processes_on_machine)):
            for j in range(i + 1, len(processes_on_machine)):
                j1, k1 = processes_on_machine[i]
                j2, k2 = processes_on_machine[j]

                # Either j1,k1 finishes before j2,k2 starts OR j2,k2 finishes before j1,k1 starts
                indicator = model.addVar(
                    vtype=GRB.BINARY,
                    name=f"Indicator_{j1+1}_{k1+1}_{j2+1}_{k2+1}")
                model.addConstr(
                    start_times[j1, k1] + job_processes[j1][k1][1]
                    <= start_times[j2, k2] + big_M * (1 - indicator),
                    f"NoOverlap1_{j1+1}_{k1+1}_{j2+1}_{k2+1}")
                model.addConstr(
                    start_times[j2, k2] + job_processes[j2][k2][1]
                    <= start_times[j1, k1] + big_M * indicator,
                    f"NoOverlap2_{j1+1}_{k1+1}_{j2+1}_{k2+1}")

    # Constraint 3: The start time of the first process must be greater than or equal to 0
    for j in range(len(jobs)):
        model.addConstr(start_times[j, 0] >= 0, f"NonNegativeStart_{j+1}")

    # Constraint for makespan: makespan is the maximum completion time among all jobs
    for j in range(len(jobs)):
        last_process = len(job_processes[j]) - 1
        model.addConstr(
            makespan
            >= start_times[j, last_process] + job_processes[j][last_process][1],
            f"MakespanDef_{j+1}")

    # Solve the model
    model.optimize()

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

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


Solution status: OPTIMAL
Optimal objective value: 13