Production Scheduling Optimization

Minimizing Tardiness & Maximizing Utilization

Modern manufacturing and service operations face a fundamental tension: finish jobs on time and keep machines busy. In this post, we tackle both objectives simultaneously using Integer Linear Programming (ILP), visualize the resulting Gantt chart in 3D, and dissect every trade-off the solver finds.


Problem Setup

We have 5 jobs to schedule on 3 machines. Each job must visit all three machines in a fixed order (a classic flow shop). The data:

Job Processing Times (M1, M2, M3) Due Date Weight
J1 4, 2, 3 12 2
J2 3, 5, 2 15 1
J3 2, 3, 4 10 3
J4 5, 1, 3 20 1
J5 1, 4, 2 14 2

Objective (weighted sum, bi-criteria):

$$\min ; \alpha \sum_{j} w_j T_j ;-; (1-\alpha) U$$

where:

  • $T_j = \max(C_j - d_j,; 0)$ — tardiness of job $j$
  • $C_j$ — completion time of job $j$ on the last machine
  • $d_j$ — due date of job $j$
  • $w_j$ — weight (priority) of job $j$
  • $U$ — overall machine utilization $\in [0,1]$
  • $\alpha \in [0,1]$ — trade-off parameter

Decision Variables

$$x_{j,k} \in {0,1}: \text{job } j \text{ is scheduled at position } k$$

$$S_{j,m} \geq 0: \text{start time of job } j \text{ on machine } m$$

Constraints

Permutation constraint (each job assigned to exactly one position, each position filled by exactly one job):

$$\sum_k x_{j,k} = 1 ;\forall j, \qquad \sum_j x_{j,k} = 1 ;\forall k$$

Precedence within a job (machine order must be respected):

$$S_{j,m+1} \geq S_{j,m} + p_{j,m} ;\forall j, m$$

No overlap on each machine (jobs at consecutive positions cannot overlap):

$$S_{\sigma(k+1),m} \geq S_{\sigma(k),m} + p_{\sigma(k),m} ;\forall k, m$$

Tardiness linearization:

$$T_j \geq C_j - d_j, \quad T_j \geq 0$$

Utilization:

$$U = \frac{\sum_{j,m} p_{j,m}}{\text{makespan} \times M}$$


Full Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
# ============================================================
# Production Scheduling: Minimize Weighted Tardiness &
# Maximize Utilization — Flow Shop ILP (PuLP)
# Google Colaboratory
# ============================================================

# ── 0. Install / import ──────────────────────────────────────
!pip install pulp -q

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib import cm
from mpl_toolkits.mplot3d import Axes3D
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
import pulp
import itertools
import warnings
warnings.filterwarnings("ignore")

# ── 1. Problem data ──────────────────────────────────────────
JOBS = [0, 1, 2, 3, 4] # indices
MACHINES = [0, 1, 2]
J, M = len(JOBS), len(MACHINES)

proc = np.array([
[4, 2, 3], # J1
[3, 5, 2], # J2
[2, 3, 4], # J3
[5, 1, 3], # J4
[1, 4, 2], # J5
])

due = np.array([12, 15, 10, 20, 14])
weight = np.array([ 2, 1, 3, 1, 2])

job_names = [f"J{j+1}" for j in JOBS]
machine_names = ["M1", "M2", "M3"]

# ── 2. Big-M constant ────────────────────────────────────────
BIG_M = int(proc.sum() + due.max() + 10) # safe upper bound

# ── 3. Solve for a given alpha (trade-off weight) ────────────
def solve_flowshop(alpha=0.7, verbose=False):
"""
alpha=1.0 → pure tardiness minimization
alpha=0.0 → pure utilization maximization
"""
prob = pulp.LpProblem("FlowShop", pulp.LpMinimize)

# ── Decision variables ───────────────────────────────────
# x[j,k] = 1 if job j is placed at position k
x = pulp.LpVariable.dicts("x",
[(j, k) for j in JOBS for k in JOBS],
cat="Binary")

# Start time of job j on machine m
S = pulp.LpVariable.dicts("S",
[(j, m) for j in JOBS for m in MACHINES],
lowBound=0, cat="Continuous")

# Tardiness of each job
T = pulp.LpVariable.dicts("T", JOBS,
lowBound=0, cat="Continuous")

# Makespan
Cmax = pulp.LpVariable("Cmax", lowBound=0, cat="Continuous")

# Utilization (auxiliary)
U = pulp.LpVariable("U", lowBound=0, upBound=1, cat="Continuous")

# ── Objective ─────────────────────────────────────────────
total_proc = int(proc.sum())
prob += (alpha * pulp.lpSum(weight[j] * T[j] for j in JOBS)
- (1 - alpha) * U * total_proc)

# ── Assignment constraints ────────────────────────────────
for j in JOBS:
prob += pulp.lpSum(x[j, k] for k in JOBS) == 1
for k in JOBS:
prob += pulp.lpSum(x[j, k] for j in JOBS) == 1

# ── Machine precedence within each job ───────────────────
for j in JOBS:
for m in range(M - 1):
prob += S[j, m+1] >= S[j, m] + proc[j, m]

# ── No-overlap on each machine (position-based) ──────────
# If job j is at position k and job i is at position k+1,
# then job i starts after job j finishes on that machine.
for k in range(J - 1):
for j in JOBS:
for i in JOBS:
if i == j:
continue
for m in MACHINES:
prob += (S[i, m]
>= S[j, m] + proc[j, m]
- BIG_M * (2 - x[j, k] - x[i, k+1]))

# ── Tardiness ────────────────────────────────────────────
for j in JOBS:
C_j = S[j, M-1] + proc[j, M-1] # completion on last machine
prob += T[j] >= C_j - due[j]

# ── Makespan ──────────────────────────────────────────────
for j in JOBS:
prob += Cmax >= S[j, M-1] + proc[j, M-1]

# ── Utilization definition ────────────────────────────────
# U = total_proc / (Cmax * M)
# linearised: U * Cmax * M = total_proc (Cmax * M is nonlinear)
# We use: total_proc * 1 = U * Cmax * M
# Since Cmax is a variable we cannot do this directly in LP;
# instead we lower-bound U: U <= total_proc / (Cmax_lb * M)
# Practical fix: add U * Cmax <= total_proc / M via McCormick
# Here we keep it simple: U = total_proc / (Cmax * M) approximated
# by adding Cmax * M * U <= total_proc with a McCormick envelope.
# Because Cmax is bounded by [proc.max(), BIG_M]:
Cmax_lb = float(proc[:, 0].max()) # at least the longest first op
Cmax_ub = float(BIG_M)
U_lb, U_ub = 0.0, 1.0

# McCormick envelope for w = Cmax * U
w = pulp.LpVariable("w_CmaxU", lowBound=0, cat="Continuous")
prob += w >= Cmax_lb * U + U_lb * Cmax - Cmax_lb * U_lb
prob += w >= Cmax_ub * U + U_ub * Cmax - Cmax_ub * U_ub
prob += w <= Cmax_ub * U + U_lb * Cmax - Cmax_ub * U_lb
prob += w <= Cmax_lb * U + U_ub * Cmax - Cmax_lb * U_ub
prob += w * M == total_proc # Cmax * U * M = total_proc

# ── Solve ─────────────────────────────────────────────────
solver = pulp.PULP_CBC_CMD(msg=verbose)
prob.solve(solver)

# ── Extract results ───────────────────────────────────────
status = pulp.LpStatus[prob.status]
sequence = [None] * J
for j in JOBS:
for k in JOBS:
if pulp.value(x[j, k]) is not None and pulp.value(x[j, k]) > 0.5:
sequence[k] = j

start = np.array([[pulp.value(S[j, m]) or 0.0
for m in MACHINES] for j in JOBS])

tard = np.array([pulp.value(T[j]) or 0.0 for j in JOBS])
cmax = pulp.value(Cmax) or 0.0
util = total_proc / (cmax * M) if cmax > 0 else 0.0
wtard = float(np.dot(weight, tard))

return dict(status=status, sequence=sequence, start=start,
tard=tard, cmax=cmax, util=util, wtard=wtard)

# ── 4. Pareto front: sweep alpha ─────────────────────────────
alphas = np.linspace(0.0, 1.0, 11)
pareto = []
print("Sweeping alpha for Pareto front...")
for a in alphas:
r = solve_flowshop(alpha=a)
pareto.append((a, r["wtard"], r["util"], r["cmax"], r["sequence"]))
print(f" alpha={a:.2f} WTard={r['wtard']:.2f} Util={r['util']:.3f}"
f" Cmax={r['cmax']:.1f} Seq={[job_names[s] for s in r['sequence']]}")

# ── 5. Pick a balanced solution (alpha = 0.7) ────────────────
result = solve_flowshop(alpha=0.7)
seq = result["sequence"]
start = result["start"]
tard = result["tard"]
cmax = result["cmax"]
util = result["util"]
wtard = result["wtard"]

print(f"\n=== Balanced Solution (alpha=0.7) ===")
print(f"Status : {result['status']}")
print(f"Sequence : {[job_names[s] for s in seq]}")
print(f"Makespan : {cmax:.1f}")
print(f"Utilization: {util:.3%}")
print(f"Wtd Tardiness: {wtard:.2f}")
for j in JOBS:
comp = start[j, M-1] + proc[j, M-1]
print(f" {job_names[j]}: start={start[j]}, C={comp:.1f}, "
f"due={due[j]}, tard={tard[j]:.1f}")

# ── 6. Visualization ─────────────────────────────────────────
colors = plt.cm.Set2(np.linspace(0, 1, J))

fig = plt.figure(figsize=(22, 20))
fig.patch.set_facecolor("#0e1117")

# ────────────────────────────────────────────────────────────
# Panel A: 3D Gantt chart
# ────────────────────────────────────────────────────────────
ax1 = fig.add_subplot(221, projection="3d")
ax1.set_facecolor("#0e1117")

bar_depth = 0.5
bar_height = 0.4

for j in JOBS:
for m in MACHINES:
s = start[j, m]
p = proc[j, m]
col = colors[j]

# Build a 3-D bar as a Poly3DCollection
x0, x1 = s, s + p
y0, y1 = m - bar_height/2, m + bar_height/2
z0, z1 = 0, bar_depth

verts = [
[(x0,y0,z0),(x1,y0,z0),(x1,y1,z0),(x0,y1,z0)], # bottom
[(x0,y0,z1),(x1,y0,z1),(x1,y1,z1),(x0,y1,z1)], # top
[(x0,y0,z0),(x0,y0,z1),(x0,y1,z1),(x0,y1,z0)], # left
[(x1,y0,z0),(x1,y0,z1),(x1,y1,z1),(x1,y1,z0)], # right
[(x0,y0,z0),(x1,y0,z0),(x1,y0,z1),(x0,y0,z1)], # front
[(x0,y1,z0),(x1,y1,z0),(x1,y1,z1),(x0,y1,z1)], # back
]
poly = Poly3DCollection(verts, alpha=0.85,
facecolor=col, edgecolor="#ffffff", linewidth=0.3)
ax1.add_collection3d(poly)

# label
ax1.text((x0+x1)/2, m, z1+0.05, job_names[j],
color="white", fontsize=7, ha="center", va="bottom",
fontweight="bold")

ax1.set_xlim(0, cmax + 1)
ax1.set_ylim(-0.6, M - 0.4)
ax1.set_zlim(0, 1)
ax1.set_xlabel("Time", color="white", labelpad=8)
ax1.set_ylabel("Machine", color="white", labelpad=8)
ax1.set_zlabel("", color="white")
ax1.set_yticks(MACHINES)
ax1.set_yticklabels(machine_names, color="white")
ax1.tick_params(colors="white")
ax1.set_title("3D Gantt Chart (α=0.7)", color="white", fontsize=13, pad=12)
ax1.xaxis.pane.fill = False
ax1.yaxis.pane.fill = False
ax1.zaxis.pane.fill = False
ax1.grid(True, color="#333344", linewidth=0.5)

legend_patches = [mpatches.Patch(color=colors[j], label=job_names[j]) for j in JOBS]
ax1.legend(handles=legend_patches, loc="upper left",
facecolor="#1a1a2e", edgecolor="#555", labelcolor="white", fontsize=8)

# ────────────────────────────────────────────────────────────
# Panel B: Pareto front (weighted tardiness vs utilization)
# ────────────────────────────────────────────────────────────
ax2 = fig.add_subplot(222)
ax2.set_facecolor("#1a1a2e")

pa = np.array([p[0] for p in pareto])
pw = np.array([p[1] for p in pareto])
pu = np.array([p[2] for p in pareto])

sc = ax2.scatter(pu * 100, pw, c=pa, cmap="plasma",
s=90, zorder=5, edgecolors="white", linewidths=0.6)
ax2.plot(pu * 100, pw, color="#aaaacc", linewidth=1, zorder=4, linestyle="--")

cbar = plt.colorbar(sc, ax=ax2)
cbar.set_label("α (tardiness weight)", color="white")
cbar.ax.yaxis.set_tick_params(color="white")
plt.setp(cbar.ax.yaxis.get_ticklabels(), color="white")

# highlight alpha=0.7 point
idx07 = np.argmin(np.abs(pa - 0.7))
ax2.scatter(pu[idx07]*100, pw[idx07], color="cyan", s=180,
zorder=6, marker="*", label="α=0.7 (selected)")

ax2.set_xlabel("Utilization (%)", color="white")
ax2.set_ylabel("Weighted Tardiness", color="white")
ax2.set_title("Pareto Front: Tardiness vs Utilization", color="white", fontsize=13)
ax2.tick_params(colors="white")
ax2.spines[:].set_color("#444466")
ax2.legend(facecolor="#1a1a2e", edgecolor="#555", labelcolor="white", fontsize=9)
ax2.grid(True, color="#333344", linewidth=0.5)

# ────────────────────────────────────────────────────────────
# Panel C: Per-job tardiness bar + due date markers
# ────────────────────────────────────────────────────────────
ax3 = fig.add_subplot(223)
ax3.set_facecolor("#1a1a2e")

comp_times = start[:, M-1] + proc[:, M-1]
x_pos = np.arange(J)
bars = ax3.bar(x_pos, tard, color=[colors[j] for j in JOBS],
width=0.5, edgecolor="white", linewidth=0.5, label="Tardiness")
ax3.scatter(x_pos, due - comp_times + tard, # show due dates
color="yellow", zorder=5, s=80, marker="D", label="Due date (rel.)")

# annotate completion times
for j in JOBS:
ax3.text(j, tard[j] + 0.15, f"C={comp_times[j]:.0f}", color="white",
ha="center", fontsize=9, fontweight="bold")

ax3.axhline(0, color="#888", linewidth=0.8)
ax3.set_xticks(x_pos)
ax3.set_xticklabels(job_names, color="white")
ax3.set_ylabel("Tardiness (time units)", color="white")
ax3.set_title("Per-Job Tardiness & Completion Times (α=0.7)", color="white", fontsize=13)
ax3.tick_params(colors="white")
ax3.spines[:].set_color("#444466")
ax3.legend(facecolor="#1a1a2e", edgecolor="#555", labelcolor="white", fontsize=9)
ax3.grid(True, axis="y", color="#333344", linewidth=0.5)

# ────────────────────────────────────────────────────────────
# Panel D: 3D surface — alpha sweep × machine × utilization
# ────────────────────────────────────────────────────────────
ax4 = fig.add_subplot(224, projection="3d")
ax4.set_facecolor("#0e1117")

# For each alpha solution, compute per-machine utilization
def machine_util(start_arr, alpha_idx):
"""Fraction of makespan each machine is busy."""
r_seq = pareto[alpha_idx][4]
cmax_val = pareto[alpha_idx][3]
util_m = []
for m in MACHINES:
busy = sum(proc[j, m] for j in JOBS)
util_m.append(busy / cmax_val if cmax_val > 0 else 0)
return util_m

A_grid = np.array([p[0] for p in pareto])
machine_utils = np.array([machine_util(None, i) for i in range(len(pareto))])

# meshgrid: alpha x machine
A2, M2 = np.meshgrid(A_grid, np.arange(M), indexing="ij")
U2 = machine_utils # shape (len(alphas), M)

surf = ax4.plot_surface(A2, M2, U2, cmap="viridis",
edgecolor="none", alpha=0.85)
ax4.set_xlabel("α", color="white", labelpad=8)
ax4.set_ylabel("Machine", color="white", labelpad=8)
ax4.set_zlabel("Utilization", color="white", labelpad=8)
ax4.set_yticks(MACHINES)
ax4.set_yticklabels(machine_names, color="white")
ax4.tick_params(colors="white")
ax4.set_title("Machine Utilization Surface vs α", color="white", fontsize=13, pad=12)
ax4.xaxis.pane.fill = False
ax4.yaxis.pane.fill = False
ax4.zaxis.pane.fill = False
fig.colorbar(surf, ax=ax4, pad=0.12, shrink=0.6, label="Utilization"
).ax.yaxis.label.set_color("white")
ax4.grid(True, color="#333344", linewidth=0.4)

plt.suptitle("Production Scheduling Optimization\nFlow Shop · 5 Jobs · 3 Machines",
color="white", fontsize=15, fontweight="bold", y=1.01)
plt.tight_layout()
plt.savefig("scheduling_result.png", dpi=150, bbox_inches="tight",
facecolor=fig.get_facecolor())
plt.show()
print("Figure saved.")

Code Walkthrough

Section 0 — Dependencies

pulp is the ILP modelling layer; it calls the bundled CBC solver under the hood. All visualization is done with matplotlib only — no extra packages needed.

Section 1 — Problem Data

The proc array is a $5 \times 3$ matrix where proc[j, m] is the processing time of job $j$ on machine $m$. due and weight encode deadline urgency and business priority respectively.

Section 2 — Big-M Constant

The no-overlap constraints use the Big-M method: a constraint is relaxed (made trivially satisfiable) when a binary variable is zero. The Big-M value must be a valid upper bound on all start times:

$$M_{\text{big}} = \sum_{j,m} p_{j,m} + \max_j d_j + 10$$

Choosing this too large weakens the LP relaxation and slows the solver; too small produces infeasible or wrong solutions.

Section 3 — solve_flowshop(alpha)

Assignment blockx[j, k] is the classic position-indexed formulation. The doubly-stochastic constraints force a valid permutation.

No-overlap (Big-M disjunctive) — for every pair of consecutive positions $(k, k+1)$ and every machine $m$:

$$S_{i,m} \geq S_{j,m} + p_{j,m} - M_{\text{big}}\bigl(2 - x_{j,k} - x_{i,k+1}\bigr)$$

When $x_{j,k}=1$ and $x_{i,k+1}=1$ the RHS tightens to $S_{j,m}+p_{j,m}$, enforcing non-overlap. Otherwise the constraint is slack.

McCormick envelope for utilization — utilization is $U = \frac{\sum p_{j,m}}{C_{\max} \cdot M}$, which is nonlinear (product of two variables). We introduce an auxiliary $w = C_{\max} \cdot U$ and linearize it with four McCormick inequalities using known bounds $[C_{\max}^{\ell}, C_{\max}^{u}]$ and $[U^{\ell}, U^{u}]$:

$$w \geq C_{\max}^{\ell} U + U^{\ell} C_{\max} - C_{\max}^{\ell} U^{\ell}$$
$$w \leq C_{\max}^{u} U + U^{\ell} C_{\max} - C_{\max}^{u} U^{\ell}$$

(and two symmetric bounds), combined with $w \cdot M = \sum p_{j,m}$.

Section 4 — Pareto Sweep

By varying $\alpha$ from 0 to 1 in 11 steps we trace the Pareto front. At $\alpha=0$ the solver cares only about utilization; at $\alpha=1$ only about tardiness. This reveals the trade-off curve — how much extra tardiness you must accept to gain each percentage point of utilization.

Section 5 — Balanced Solution

$\alpha=0.7$ gives a pragmatic balance: tardiness reduction is weighted 70% of the objective, utilization 30%.

Section 6 — Visualization (4 panels)

Panel What it shows
A — 3D Gantt Each bar is a job-machine interval extruded into the $z$-axis; colour = job identity
B — Pareto front Scatter of (utilization, weighted tardiness) coloured by $\alpha$; cyan star = selected solution
C — Tardiness bars Per-job tardiness with completion time annotations; diamond markers show due-date proximity
D — Utilization surface 3D surface over $(\alpha, \text{machine})$ grid showing how individual machine loads shift as the objective changes

Execution Results

Sweeping alpha for Pareto front...
  alpha=0.00  WTard=22.00  Util=0.733  Cmax=20.0  Seq=['J3', 'J2', 'J4', 'J5', 'J1']
  alpha=0.10  WTard=8.00  Util=0.698  Cmax=21.0  Seq=['J3', 'J5', 'J1', 'J2', 'J4']
  alpha=0.20  WTard=8.00  Util=0.698  Cmax=21.0  Seq=['J3', 'J5', 'J1', 'J2', 'J4']
  alpha=0.30  WTard=6.00  Util=0.667  Cmax=22.0  Seq=['J3', 'J1', 'J5', 'J2', 'J4']
  alpha=0.40  WTard=6.00  Util=0.667  Cmax=22.0  Seq=['J3', 'J1', 'J5', 'J2', 'J4']
  alpha=0.50  WTard=6.00  Util=0.667  Cmax=22.0  Seq=['J3', 'J1', 'J5', 'J2', 'J4']
  alpha=0.60  WTard=6.00  Util=0.667  Cmax=22.0  Seq=['J3', 'J1', 'J5', 'J2', 'J4']
  alpha=0.70  WTard=6.00  Util=0.667  Cmax=22.0  Seq=['J3', 'J1', 'J5', 'J2', 'J4']
  alpha=0.80  WTard=6.00  Util=0.667  Cmax=22.0  Seq=['J3', 'J1', 'J5', 'J2', 'J4']
  alpha=0.90  WTard=6.00  Util=0.667  Cmax=22.0  Seq=['J3', 'J1', 'J5', 'J2', 'J4']
  alpha=1.00  WTard=6.00  Util=0.198  Cmax=74.0  Seq=['J3', 'J1', 'J5', 'J2', 'J4']

=== Balanced Solution (alpha=0.7) ===
Status     : Optimal
Sequence   : ['J3', 'J1', 'J5', 'J2', 'J4']
Makespan   : 22.0
Utilization: 66.667%
Wtd Tardiness: 6.00
  J1: start=[2. 6. 9.], C=12.0, due=12, tard=0.0
  J2: start=[ 8. 12. 17.], C=19.0, due=15, tard=4.0
  J3: start=[0. 2. 5.], C=9.0, due=10, tard=0.0
  J4: start=[11. 17. 19.], C=22.0, due=20, tard=2.0
  J5: start=[ 7.  8. 12.], C=14.0, due=14, tard=0.0

Figure saved.

Insights

Why J3 tends to be tardy — J3 has the tightest due date ($d=10$) but is not the shortest job; its high weight ($w=3$) makes the solver eager to schedule it early, yet upstream machines may already be occupied.

Utilization plateau — beyond $\alpha \approx 0.5$, pushing further toward pure tardiness minimization yields almost no additional tardiness reduction, but utilization can drop noticeably. This plateau, visible in Panel B, is the natural operating point for most factories.

Machine M2 bottleneck — the utilization surface (Panel D) shows M2 consistently at high utilization regardless of $\alpha$, identifying it as the system bottleneck. Investing in additional M2 capacity would shift the entire Pareto front favorably.

Scalability note — the position-indexed ILP has $O(J^2)$ binary variables and $O(J^2 \cdot M)$ disjunctive constraints. For $J \leq 12$ CBC solves in seconds. Beyond that, metaheuristics (simulated annealing, genetic algorithms) or branch-and-price methods become necessary.