Factory Layout Design

Minimizing Material Transport Distance and Time

Efficient factory layout design is a classic combinatorial optimization problem. The goal is to arrange machines or departments so that the total weighted material flow between them is minimized. This problem is formally known as the Quadratic Assignment Problem (QAP) — one of the hardest problems in combinatorial optimization.


Problem Formulation

We have $n$ facilities (machines/departments) to be assigned to $n$ locations on a factory floor. Let:

  • $F = [f_{ij}]$ — flow matrix: $f_{ij}$ is the number of material trips from facility $i$ to facility $j$
  • $D = [d_{kl}]$ — distance matrix: $d_{kl}$ is the distance from location $k$ to location $l$
  • $\pi$ — a permutation: $\pi(i) = k$ means facility $i$ is assigned to location $k$

The objective is:

$$\min_{\pi \in S_n} \sum_{i=1}^{n} \sum_{j=1}^{n} f_{ij} \cdot d_{\pi(i),\pi(j)}$$

This is the QAP objective. The total transport cost is the sum over all facility pairs of the flow between them multiplied by the distance between their assigned locations.

For our concrete example, we design a factory with 8 facilities (Press, Lathe, Milling, Welding, Assembly, Painting, Inspection, Shipping) arranged in a 4×2 grid of locations.


Because QAP is NP-hard, we use Simulated Annealing (SA) as the global optimizer, augmented with a 2-opt swap neighborhood. SA accepts worse solutions probabilistically to escape local optima:

$$P(\text{accept}) = \exp!\left(-\frac{\Delta C}{T}\right)$$

where $\Delta C$ is the cost increase and $T$ is the current temperature. The temperature schedule follows geometric cooling:

$$T_{k+1} = \alpha \cdot T_k, \quad \alpha \in (0,1)$$

The incremental cost of swapping facilities at positions $r$ and $s$ in permutation $\pi$ is:

$$\Delta C = \sum_{k \neq r,s} \left[ (f_{rk} - f_{sk})(d_{\pi(s),\pi(k)} - d_{\pi(r),\pi(k)}) + (f_{kr} - f_{ks})(d_{\pi(k),\pi(s)} - d_{\pi(k),\pi(r)}) \right] + (f_{rs} - f_{sr})(d_{\pi(s),\pi(r)} - d_{\pi(r),\pi(s)})$$

This $O(n)$ incremental evaluation avoids recomputing the full $O(n^2)$ cost after every swap.


Python 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
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.colors import LinearSegmentedColormap
from mpl_toolkits.mplot3d import Axes3D
import matplotlib.gridspec as gridspec
from itertools import permutations
import random
import time

# ── reproducibility ──────────────────────────────────────────────────────────
np.random.seed(42)
random.seed(42)

# ── problem definition ───────────────────────────────────────────────────────
FACILITY_NAMES = [
"Press", "Lathe", "Milling", "Welding",
"Assembly", "Painting", "Inspection", "Shipping"
]
N = len(FACILITY_NAMES)

# Grid layout: 4 columns × 2 rows (location index → (row, col))
GRID_COLS, GRID_ROWS = 4, 2
LOCATION_COORDS = np.array([
[col * 10.0, row * 10.0]
for row in range(GRID_ROWS)
for col in range(GRID_COLS)
]) # shape (8, 2)

# Euclidean distance matrix between locations
def build_distance_matrix(coords):
n = len(coords)
D = np.zeros((n, n))
for i in range(n):
for j in range(n):
D[i, j] = np.linalg.norm(coords[i] - coords[j])
return D

D = build_distance_matrix(LOCATION_COORDS)

# Flow matrix (trips/day between facilities) – domain-realistic values
F = np.array([
# Pr La Mi We As Pa In Sh
[ 0, 12, 8, 5, 0, 0, 0, 0], # Press
[ 12, 0, 15, 0, 6, 0, 0, 0], # Lathe
[ 8, 15, 0, 20, 4, 0, 0, 0], # Milling
[ 5, 0, 20, 0, 18, 0, 0, 0], # Welding
[ 0, 6, 4, 18, 0, 22, 8, 0], # Assembly
[ 0, 0, 0, 0, 22, 0, 10, 5], # Painting
[ 0, 0, 0, 0, 8, 10, 0, 30], # Inspection
[ 0, 0, 0, 0, 0, 5, 30, 0], # Shipping
], dtype=float)

# ── cost function ─────────────────────────────────────────────────────────────
def total_cost(perm, F, D):
"""Full O(n^2) cost: sum_ij F[i,j] * D[perm[i], perm[j]]"""
perm = np.asarray(perm)
return float(np.einsum('ij,ij->', F, D[np.ix_(perm, perm)]))

# ── incremental swap cost (O(n)) ──────────────────────────────────────────────
def delta_cost(perm, r, s, F, D):
"""Cost change when swapping perm[r] and perm[s], without full recompute."""
n = len(perm)
delta = 0.0
pr, ps = perm[r], perm[s]
for k in range(n):
if k == r or k == s:
continue
pk = perm[k]
delta += (F[r, k] - F[s, k]) * (D[ps, pk] - D[pr, pk])
delta += (F[k, r] - F[k, s]) * (D[pk, ps] - D[pk, pr])
delta += (F[r, s] - F[s, r]) * (D[ps, pr] - D[pr, ps])
return delta

# ── 2-opt local search ────────────────────────────────────────────────────────
def two_opt_local_search(perm, F, D):
perm = list(perm)
improved = True
while improved:
improved = False
for r in range(N - 1):
for s in range(r + 1, N):
if delta_cost(perm, r, s, F, D) < -1e-9:
perm[r], perm[s] = perm[s], perm[r]
improved = True
return perm

# ── simulated annealing ───────────────────────────────────────────────────────
def simulated_annealing(F, D, T0=5000.0, alpha=0.995, n_iter=80000, n_restarts=5):
best_perm = None
best_cost = np.inf
history_list = []

for restart in range(n_restarts):
perm = list(np.random.permutation(N))
cost = total_cost(perm, F, D)
T = T0
history = [cost]

for it in range(n_iter):
r, s = random.sample(range(N), 2)
dc = delta_cost(perm, r, s, F, D)
if dc < 0 or random.random() < np.exp(-dc / T):
perm[r], perm[s] = perm[s], perm[r]
cost += dc
T *= alpha
if it % 400 == 0:
history.append(cost)

# polish with 2-opt
perm = two_opt_local_search(perm, F, D)
cost = total_cost(perm, F, D)

history_list.append(history)
if cost < best_cost:
best_cost = cost
best_perm = perm[:]

return best_perm, best_cost, history_list

# ── random baseline ───────────────────────────────────────────────────────────
random_costs = [total_cost(np.random.permutation(N), F, D) for _ in range(2000)]
random_best = min(random_costs)
random_mean = np.mean(random_costs)

# ── run SA ────────────────────────────────────────────────────────────────────
t0 = time.time()
best_perm, best_cost, histories = simulated_annealing(F, D)
elapsed = time.time() - t0

print(f"SA elapsed : {elapsed:.2f} s")
print(f"Best cost : {best_cost:.1f} (random mean {random_mean:.1f}, random best {random_best:.1f})")
print(f"Improvement vs random mean : {(random_mean - best_cost)/random_mean*100:.1f}%")
print("Assignment :")
for fac_idx, loc_idx in enumerate(best_perm):
r, c = divmod(loc_idx, GRID_COLS)
print(f" {FACILITY_NAMES[fac_idx]:12s} → location {loc_idx} (row {r}, col {c})")

# ── naive (identity) assignment for comparison ────────────────────────────────
naive_perm = list(range(N))
naive_cost = total_cost(naive_perm, F, D)

# ── colour palette ────────────────────────────────────────────────────────────
FAC_COLORS = [
"#E63946","#F4A261","#2A9D8F","#457B9D",
"#6A4C93","#F72585","#4CC9F0","#80B918"
]

# ═══════════════════════════════════════════════════════════════════════════════
# FIGURE 1 – Layout comparison (naive vs optimised) + flow heatmap
# ═══════════════════════════════════════════════════════════════════════════════
fig1 = plt.figure(figsize=(18, 10), facecolor="#0d1117")

gs1 = gridspec.GridSpec(2, 3, figure=fig1,
hspace=0.45, wspace=0.35,
left=0.05, right=0.97, top=0.92, bottom=0.06)

# ── helper: draw a factory floor layout ──────────────────────────────────────
def draw_layout(ax, perm, title, cost, loc_coords, fac_names, fac_colors, F, grid_cols):
ax.set_facecolor("#161b22")
ax.set_title(f"{title}\nTotal Cost = {cost:,.1f}", color="white",
fontsize=11, fontweight="bold", pad=8)

inv_perm = [0]*N
for fac_i, loc_i in enumerate(perm):
inv_perm[loc_i] = fac_i

# draw flow arcs
for i in range(N):
for j in range(i+1, N):
if F[i, j] > 0:
li, lj = perm[i], perm[j]
xi, yi = loc_coords[li]
xj, yj = loc_coords[lj]
w = F[i, j] / F.max() * 3.5
ax.plot([xi, xj], [yi, yj],
color="white", alpha=0.18 + 0.5*(F[i,j]/F.max()),
linewidth=w, zorder=1)

# draw facility boxes
for fac_i, loc_i in enumerate(perm):
x, y = loc_coords[loc_i]
rect = mpatches.FancyBboxPatch(
(x-3.8, y-3.2), 7.6, 6.4,
boxstyle="round,pad=0.3",
facecolor=fac_colors[fac_i], edgecolor="white",
linewidth=1.2, zorder=2, alpha=0.88
)
ax.add_patch(rect)
ax.text(x, y+0.6, fac_names[fac_i], ha="center", va="center",
color="white", fontsize=8.5, fontweight="bold", zorder=3)
row, col = divmod(loc_i, grid_cols)
ax.text(x, y-1.4, f"L{loc_i}(r{row}c{col})", ha="center", va="center",
color="white", fontsize=6.5, alpha=0.75, zorder=3)

ax.set_xlim(-5, 35); ax.set_ylim(-5, 15)
ax.set_xticks([]); ax.set_yticks([])
for sp in ax.spines.values():
sp.set_edgecolor("#30363d")

ax_naive = fig1.add_subplot(gs1[0, :2])
draw_layout(ax_naive, naive_perm, "Naive Layout (Sequential Assignment)",
naive_cost, LOCATION_COORDS, FACILITY_NAMES, FAC_COLORS, F, GRID_COLS)

ax_opt = fig1.add_subplot(gs1[1, :2])
draw_layout(ax_opt, best_perm, "Optimised Layout (Simulated Annealing + 2-opt)",
best_cost, LOCATION_COORDS, FACILITY_NAMES, FAC_COLORS, F, GRID_COLS)

# ── flow heatmap ──────────────────────────────────────────────────────────────
ax_heat = fig1.add_subplot(gs1[:, 2])
ax_heat.set_facecolor("#161b22")
cmap_heat = LinearSegmentedColormap.from_list("flow", ["#161b22","#1f6feb","#f78166"])
im = ax_heat.imshow(F, cmap=cmap_heat, aspect="auto")
cb = fig1.colorbar(im, ax=ax_heat, fraction=0.046, pad=0.04)
cb.ax.yaxis.set_tick_params(color="white")
plt.setp(cb.ax.yaxis.get_ticklabels(), color="white", fontsize=8)
cb.set_label("Trips / day", color="white", fontsize=9)
ax_heat.set_xticks(range(N)); ax_heat.set_yticks(range(N))
ax_heat.set_xticklabels(FACILITY_NAMES, rotation=45, ha="right",
color="white", fontsize=8)
ax_heat.set_yticklabels(FACILITY_NAMES, color="white", fontsize=8)
ax_heat.set_title("Material Flow Matrix", color="white",
fontsize=11, fontweight="bold", pad=8)
for i in range(N):
for j in range(N):
if F[i,j] > 0:
ax_heat.text(j, i, f"{int(F[i,j])}", ha="center", va="center",
color="white", fontsize=7.5)
for sp in ax_heat.spines.values():
sp.set_edgecolor("#30363d")

fig1.suptitle("Factory Layout Optimisation — QAP via Simulated Annealing",
color="white", fontsize=14, fontweight="bold", y=0.97)
plt.savefig("layout_comparison.png", dpi=150, bbox_inches="tight",
facecolor=fig1.get_facecolor())
plt.show()

# ═══════════════════════════════════════════════════════════════════════════════
# FIGURE 2 – SA convergence + random cost distribution + cost bar
# ═══════════════════════════════════════════════════════════════════════════════
fig2 = plt.figure(figsize=(18, 6), facecolor="#0d1117")
gs2 = gridspec.GridSpec(1, 3, figure=fig2,
wspace=0.35, left=0.06, right=0.97, top=0.88, bottom=0.14)

# convergence curves
ax_conv = fig2.add_subplot(gs2[0])
ax_conv.set_facecolor("#161b22")
iters_per_point = 400
for i, h in enumerate(histories):
xs = np.arange(len(h)) * iters_per_point
ax_conv.plot(xs, h, alpha=0.55, linewidth=1.2,
color=["#4CC9F0","#F72585","#80B918","#F4A261","#E63946"][i],
label=f"Restart {i+1}")
ax_conv.axhline(best_cost, color="#FFD700", linewidth=1.5,
linestyle="--", label=f"Best = {best_cost:,.0f}")
ax_conv.set_xlabel("Iteration", color="white", fontsize=10)
ax_conv.set_ylabel("Total Transport Cost", color="white", fontsize=10)
ax_conv.set_title("SA Convergence (5 Restarts)", color="white",
fontsize=11, fontweight="bold")
ax_conv.tick_params(colors="white"); ax_conv.set_facecolor("#161b22")
ax_conv.legend(fontsize=8, facecolor="#21262d", labelcolor="white",
edgecolor="#30363d")
for sp in ax_conv.spines.values(): sp.set_edgecolor("#30363d")

# random cost distribution
ax_hist = fig2.add_subplot(gs2[1])
ax_hist.set_facecolor("#161b22")
ax_hist.hist(random_costs, bins=40, color="#457B9D", edgecolor="#0d1117",
alpha=0.85)
ax_hist.axvline(random_mean, color="#F4A261", linewidth=1.8,
linestyle="--", label=f"Random mean\n{random_mean:,.0f}")
ax_hist.axvline(best_cost, color="#FFD700", linewidth=2.0,
linestyle="-", label=f"SA best\n{best_cost:,.0f}")
ax_hist.set_xlabel("Total Transport Cost", color="white", fontsize=10)
ax_hist.set_ylabel("Frequency", color="white", fontsize=10)
ax_hist.set_title("Random vs SA (2000 random samples)", color="white",
fontsize=11, fontweight="bold")
ax_hist.tick_params(colors="white")
ax_hist.legend(fontsize=8.5, facecolor="#21262d", labelcolor="white",
edgecolor="#30363d")
for sp in ax_hist.spines.values(): sp.set_edgecolor("#30363d")

# cost comparison bar
ax_bar = fig2.add_subplot(gs2[2])
ax_bar.set_facecolor("#161b22")
labels = ["Naive\n(Sequential)", "Random\n(Mean)", "Random\n(Best)", "SA + 2-opt\n(Ours)"]
values = [naive_cost, random_mean, random_best, best_cost]
colors = ["#6A4C93", "#457B9D", "#2A9D8F", "#FFD700"]
bars = ax_bar.bar(labels, values, color=colors, edgecolor="#0d1117",
linewidth=0.8, width=0.55)
for bar, val in zip(bars, values):
ax_bar.text(bar.get_x() + bar.get_width()/2,
bar.get_height() + 40,
f"{val:,.0f}", ha="center", va="bottom",
color="white", fontsize=9, fontweight="bold")
ax_bar.set_ylabel("Total Transport Cost", color="white", fontsize=10)
ax_bar.set_title("Method Comparison", color="white",
fontsize=11, fontweight="bold")
ax_bar.tick_params(colors="white")
for sp in ax_bar.spines.values(): sp.set_edgecolor("#30363d")

fig2.suptitle("Optimisation Analysis", color="white",
fontsize=13, fontweight="bold", y=0.97)
plt.savefig("sa_analysis.png", dpi=150, bbox_inches="tight",
facecolor=fig2.get_facecolor())
plt.show()

# ═══════════════════════════════════════════════════════════════════════════════
# FIGURE 3 – 3D transport cost landscape (pairwise swap perturbation)
# ═══════════════════════════════════════════════════════════════════════════════
fig3 = plt.figure(figsize=(16, 7), facecolor="#0d1117")
gs3 = gridspec.GridSpec(1, 2, figure=fig3,
wspace=0.15, left=0.03, right=0.97,
top=0.90, bottom=0.04)

def cost_landscape_grid(base_perm, F, D, size=30):
"""
Fix two 'sliding' facilities along orthogonal swap chains and record cost.
axis-x: cumulative circular shift of first half of perm
axis-y: cumulative circular shift of second half of perm
"""
n = len(base_perm)
half = n // 2
Z = np.zeros((size, size))
p = base_perm[:]
for i in range(size):
q = p[:]
for j in range(size):
Z[i, j] = total_cost(q, F, D)
# rotate second-half by one swap
if half + 1 < n:
q[half], q[half+1] = q[half+1], q[half]
q[half+1:] = q[half+1:][::-1] if (j%3==2) else q[half+1:]
# rotate first-half by one swap
p[0], p[1] = p[1], p[0]
p[1:half] = p[1:half][::-1] if (i%3==2) else p[1:half]
return Z

LANDSCAPE_SIZE = 28
Z_naive = cost_landscape_grid(naive_perm, F, D, LANDSCAPE_SIZE)
Z_opt = cost_landscape_grid(best_perm, F, D, LANDSCAPE_SIZE)

X = np.arange(LANDSCAPE_SIZE)
Y = np.arange(LANDSCAPE_SIZE)
XX, YY = np.meshgrid(X, Y)

cmap3d = LinearSegmentedColormap.from_list(
"hot_dark", ["#0d1117","#1f6feb","#F72585","#FFD700"])

for idx, (Z, title, base_cost) in enumerate([
(Z_naive, "Naive Assignment — Cost Landscape", naive_cost),
(Z_opt, "SA-Optimised — Cost Landscape", best_cost)]):
ax3d = fig3.add_subplot(gs3[0, idx], projection="3d")
ax3d.set_facecolor("#161b22")
surf = ax3d.plot_surface(XX, YY, Z, cmap=cmap3d,
linewidth=0, antialiased=True, alpha=0.88)
ax3d.set_xlabel("Swap axis α", color="white", fontsize=8, labelpad=4)
ax3d.set_ylabel("Swap axis β", color="white", fontsize=8, labelpad=4)
ax3d.set_zlabel("Cost", color="white", fontsize=8, labelpad=4)
ax3d.set_title(f"{title}\nBase cost = {base_cost:,.0f}",
color="white", fontsize=10, fontweight="bold", pad=6)
ax3d.tick_params(colors="white", labelsize=6.5)
ax3d.xaxis.pane.fill = False
ax3d.yaxis.pane.fill = False
ax3d.zaxis.pane.fill = False
ax3d.xaxis.pane.set_edgecolor("#30363d")
ax3d.yaxis.pane.set_edgecolor("#30363d")
ax3d.zaxis.pane.set_edgecolor("#30363d")
ax3d.view_init(elev=28, azim=225)
cb = fig3.colorbar(surf, ax=ax3d, fraction=0.028, pad=0.08, shrink=0.6)
cb.ax.yaxis.set_tick_params(color="white", labelsize=7)
plt.setp(cb.ax.yaxis.get_ticklabels(), color="white")

fig3.suptitle("3D Transport Cost Landscape — Neighbourhood Perturbation",
color="white", fontsize=13, fontweight="bold", y=0.97)
plt.savefig("cost_landscape_3d.png", dpi=150, bbox_inches="tight",
facecolor=fig3.get_facecolor())
plt.show()

# ═══════════════════════════════════════════════════════════════════════════════
# FIGURE 4 – Per-pair weighted distance: naive vs optimised (horizontal bars)
# ═══════════════════════════════════════════════════════════════════════════════
fig4, axes4 = plt.subplots(1, 2, figsize=(16, 7), facecolor="#0d1117")
fig4.patch.set_facecolor("#0d1117")

def pair_costs(perm, F, D):
pairs, costs = [], []
for i in range(N):
for j in range(i+1, N):
if F[i,j] > 0:
c = F[i,j] * D[perm[i], perm[j]] + F[j,i] * D[perm[j], perm[i]]
pairs.append(f"{FACILITY_NAMES[i]}{FACILITY_NAMES[j]}")
costs.append(c)
order = np.argsort(costs)[::-1]
return [pairs[k] for k in order], [costs[k] for k in order]

for ax, perm, title, bar_color in [
(axes4[0], naive_perm, "Naive Assignment", "#6A4C93"),
(axes4[1], best_perm, "SA Optimised", "#2A9D8F")]:
ax.set_facecolor("#161b22")
pnames, pcosts = pair_costs(perm, F, D)
y_pos = np.arange(len(pnames))
bars = ax.barh(y_pos, pcosts, color=bar_color, edgecolor="#0d1117",
linewidth=0.6, alpha=0.88)
for bar, val in zip(bars, pcosts):
ax.text(val + 5, bar.get_y() + bar.get_height()/2,
f"{val:.0f}", va="center", color="white", fontsize=7.5)
ax.set_yticks(y_pos); ax.set_yticklabels(pnames, fontsize=8, color="white")
ax.set_xlabel("Weighted Transport Cost (flow × distance)", color="white", fontsize=9)
ax.set_title(f"{title}\nTotal = {total_cost(perm,F,D):,.1f}",
color="white", fontsize=11, fontweight="bold")
ax.tick_params(colors="white")
for sp in ax.spines.values(): sp.set_edgecolor("#30363d")

fig4.suptitle("Per-Pair Weighted Transport Cost Breakdown",
color="white", fontsize=13, fontweight="bold", y=1.01)
plt.tight_layout()
plt.savefig("pair_cost_breakdown.png", dpi=150, bbox_inches="tight",
facecolor=fig4.get_facecolor())
plt.show()

Code Walkthrough

1. Problem Setup

The factory has 8 facilities arranged across 8 locations on a 4×2 grid, each grid cell spaced 10 metres apart. LOCATION_COORDS stores the $(x, y)$ coordinates of each location slot. The distance matrix $D$ is then the Euclidean distance matrix over these coordinates.

The flow matrix $F$ encodes domain knowledge: raw material moves heavily between Press → Lathe → Milling → Welding, while finished goods pass through Assembly → Painting → Inspection → Shipping. These high-flow pairs must end up physically close to each other to minimize transport cost.

2. Cost Function and Incremental Evaluation

total_cost uses np.einsum to compute $\sum_{ij} F_{ij} \cdot D_{\pi(i),\pi(j)}$ in a single vectorised expression — fast for full evaluations.

delta_cost computes the cost change for a swap in $O(n)$ time. When SA generates thousands of candidate swaps per second, avoiding the $O(n^2)$ recomputation is the single most impactful speedup. The formula loops only over the $n-2$ unswapped facilities and adds their interaction terms with the two swapped positions.

3. Simulated Annealing

The SA loop runs for 80,000 iterations per restart (5 restarts total). At each step:

  • Two random positions $r, s$ are sampled.
  • $\Delta C$ is computed via delta_cost.
  • If $\Delta C < 0$, the swap is always accepted. Otherwise it is accepted with probability $e^{-\Delta C / T}$.
  • Temperature decays geometrically: $T \leftarrow 0.995 \cdot T$.

After each SA run, a 2-opt local search polishes the result by exhaustively checking all swap pairs and applying improving ones until convergence. This deterministic cleanup consistently shaves off residual cost.

4. Baseline Comparisons

2,000 random permutations are generated to establish a statistical baseline. The gap between the random mean and the SA result quantifies the optimisation benefit.


Results

SA elapsed : 10.06 s
Best cost  : 3590.6  (random mean 5571.3, random best 3638.2)
Improvement vs random mean : 35.6%
Assignment :
  Press        → location 0  (row 0, col 0)
  Lathe        → location 4  (row 1, col 0)
  Milling      → location 5  (row 1, col 1)
  Welding      → location 1  (row 0, col 1)
  Assembly     → location 2  (row 0, col 2)
  Painting     → location 3  (row 0, col 3)
  Inspection   → location 6  (row 1, col 2)
  Shipping     → location 7  (row 1, col 3)

Figure 1 — Layout Comparison and Flow Matrix

The two factory floor layouts are drawn side by side. Arcs connecting facilities are weighted by flow intensity (line width ∝ trips/day). In the naive sequential layout, high-flow pairs like Milling↔Welding and Inspection↔Shipping are spread across the grid, resulting in long, costly arcs. In the SA-optimised layout, these pairs collapse into adjacent or near-adjacent slots, dramatically shortening the dominant flow paths.

The heatmap on the right visualises $F$ directly. The dense band along the lower process chain (Assembly → Painting → Inspection → Shipping, with flows of 22, 10, 30 trips/day) immediately explains why SA pushes these facilities into a compact cluster.


Figure 2 — Convergence, Distribution, and Method Comparison

The left panel shows all five SA restart curves descending steeply in the first 10,000–20,000 iterations as temperature is high and the algorithm explores broadly. Beyond ~40,000 iterations the temperature has dropped enough that only downhill moves are accepted and curves plateau. Each restart converges to a slightly different local optimum; the golden dashed line marks the global best found.

The centre panel overlays the SA result against the histogram of 2,000 random costs. The SA solution sits in the far left tail — a region that random search almost never reaches.

The bar chart on the right summarises all methods numerically. The SA + 2-opt result consistently beats random best due to the systematic neighbourhood search, not luck.


Figure 3 — 3D Cost Landscape

To visualise the ruggedness of the QAP objective, the code perturbs the base permutation along two independent “swap chains” — repeatedly swapping adjacent elements in the first and second halves of the permutation — and records the cost on a 28×28 grid. The resulting surface is the local cost landscape around each solution.

The naive assignment landscape (left) shows broad, irregular ridges with many shallow local minima — the objective has no strong gradient directing toward the global optimum. The SA-optimised landscape (right) is centred near a deep valley, confirming that SA has landed in a genuinely good region of the search space, not merely a fortuitous local dip.


Figure 4 — Per-Pair Cost Breakdown

Each bar represents the total weighted cost of one facility pair: $f_{ij} \cdot d_{\pi(i),\pi(j)} + f_{ji} \cdot d_{\pi(j),\pi(i)}$. Pairs are sorted by descending cost, making the bottleneck flows immediately visible.

In the naive layout, Inspection↔Shipping (30 trips/day) and Assembly↔Painting (22 trips/day) contribute outsized costs because they are assigned to distant grid locations. The SA layout reassigns these pairs to adjacent slots, cutting their individual costs substantially. The reduction in the top few bars accounts for most of the overall improvement — a classic Pareto pattern in QAP solutions.