Tackling a Complex Bin Packing Problem with Python

Bin packing is one of the classic NP-hard combinatorial optimization problems. In its simplest form, you want to fit items of varying sizes into the fewest possible bins of fixed capacity. But real-world logistics is messier — items come with weights and volumes, bins have multiple capacity constraints, certain items are incompatible with each other, and some items are fragile and must be placed on top. This post walks through a richly-constrained variant and solves it with Python using both a heuristic and an ILP formulation.


Problem Setup

Imagine a logistics company shipping packages from a warehouse. They have a fleet of identical trucks, each with:

  • Weight capacity: $W_{max} = 100$ kg
  • Volume capacity: $V_{max} = 80$ litres

There are $n = 20$ items to ship. Each item $i$ has:

  • Weight $w_i \in [2, 20]$ kg
  • Volume $v_i \in [2, 15]$ litres
  • A fragility flag $f_i \in {0, 1}$ (fragile items must not be placed under non-fragile ones — enforced as: if a bin contains any fragile item, the sum of non-fragile item weights in that bin is capped)
  • Incompatibility pairs: certain pairs $(i, j)$ cannot share a bin (e.g., chemicals that must not mix)

Mathematical Formulation

Let $x_{ij} \in {0,1}$ denote whether item $i$ is assigned to bin $j$, and $y_j \in {0,1}$ whether bin $j$ is used.

$$\min \sum_{j=1}^{m} y_j$$

Subject to:

$$\sum_{j=1}^{m} x_{ij} = 1 \quad \forall i \tag{1}$$

$$\sum_{i=1}^{n} w_i x_{ij} \leq W_{max} \cdot y_j \quad \forall j \tag{2}$$

$$\sum_{i=1}^{n} v_i x_{ij} \leq V_{max} \cdot y_j \quad \forall j \tag{3}$$

$$x_{ij} + x_{kj} \leq 1 \quad \forall (i,k) \in \mathcal{I},\ \forall j \tag{4}$$

$$\sum_{i \in \mathcal{F}} x_{ij} \cdot w_i^{NF} \leq F_{cap} \cdot y_j \quad \forall j \tag{5}$$

$$x_{ij} \in {0,1},\quad y_j \in {0,1} \tag{6}$$

Where $\mathcal{I}$ is the set of incompatible pairs and $\mathcal{F}$ is the set of bins containing fragile items.


Python Implementation

Install dependencies first:

1
!pip install pulp --quiet
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
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from mpl_toolkits.mplot3d import Axes3D
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
import pulp
import time
import warnings
warnings.filterwarnings("ignore")

# ──────────────────────────────────────────────
# 1. PROBLEM DATA
# ──────────────────────────────────────────────
np.random.seed(42)
n_items = 20
W_MAX = 100 # kg per bin
V_MAX = 80 # L per bin
F_CAP = 60 # max non-fragile weight (kg) in a bin that has fragile items

weights = np.random.randint(2, 21, size=n_items).tolist()
volumes = np.random.randint(2, 16, size=n_items).tolist()
fragile = [1 if i in {2, 5, 11, 14, 17} else 0 for i in range(n_items)]

# Incompatible pairs (items that cannot share a bin)
incompatible = [(0, 3), (1, 7), (4, 9), (6, 12), (10, 18), (13, 19)]

print("Item data:")
print(f"{'Item':>5} {'Weight':>7} {'Volume':>7} {'Fragile':>8}")
for i in range(n_items):
print(f"{i:>5} {weights[i]:>7} {volumes[i]:>7} {fragile[i]:>8}")

# ──────────────────────────────────────────────
# 2. HEURISTIC: FIRST-FIT DECREASING (FFD)
# with constraint awareness
# ──────────────────────────────────────────────
def ffd_solve(weights, volumes, fragile, incompatible, W_MAX, V_MAX, F_CAP):
"""
First-Fit Decreasing heuristic that respects weight, volume,
fragility, and incompatibility constraints.
"""
# Sort items by weight descending (primary) then volume (secondary)
order = sorted(range(len(weights)),
key=lambda i: (weights[i] + volumes[i]), reverse=True)

bins_w = [] # total weight per bin
bins_v = [] # total volume per bin
bins_f = [] # True if bin has a fragile item
bins_nfw = [] # sum of non-fragile weights in bin (for fragility cap)
bins_items = [] # list of items in each bin
assignment = [-1] * len(weights)

incompat_map = {}
for (a, b) in incompatible:
incompat_map.setdefault(a, set()).add(b)
incompat_map.setdefault(b, set()).add(a)

for i in order:
placed = False
for b in range(len(bins_w)):
# Check weight
if bins_w[b] + weights[i] > W_MAX:
continue
# Check volume
if bins_v[b] + volumes[i] > V_MAX:
continue
# Check incompatibility
conflicts = incompat_map.get(i, set())
if conflicts & set(bins_items[b]):
continue
# Check fragility cap
has_fragile_after = bins_f[b] or (fragile[i] == 1)
nfw_after = bins_nfw[b] + (weights[i] if fragile[i] == 0 else 0)
if has_fragile_after and nfw_after > F_CAP:
continue
# Place item
bins_w[b] += weights[i]
bins_v[b] += volumes[i]
bins_f[b] = has_fragile_after
bins_nfw[b] = nfw_after
bins_items[b].append(i)
assignment[i] = b
placed = True
break

if not placed:
# Open new bin
bins_w.append(weights[i])
bins_v.append(volumes[i])
bins_f.append(fragile[i] == 1)
bins_nfw.append(weights[i] if fragile[i] == 0 else 0)
bins_items.append([i])
assignment[i] = len(bins_w) - 1

return assignment, bins_items, bins_w, bins_v

t0 = time.time()
ffd_assign, ffd_bins, ffd_bw, ffd_bv = ffd_solve(
weights, volumes, fragile, incompatible, W_MAX, V_MAX, F_CAP)
ffd_time = time.time() - t0
print(f"\nFFD heuristic: {len(ffd_bins)} bins used ({ffd_time*1000:.1f} ms)")

# ──────────────────────────────────────────────
# 3. ILP SOLVER (PuLP + CBC)
# Column-generation style: only open as many
# bins as FFD found (upper bound) to keep
# the model tractable.
# ──────────────────────────────────────────────
def ilp_solve(weights, volumes, fragile, incompatible, W_MAX, V_MAX, F_CAP,
n_bins_ub):
n = len(weights)
m = n_bins_ub # upper bound on bins needed
prob = pulp.LpProblem("BinPacking", pulp.LpMinimize)

# Decision variables
x = [[pulp.LpVariable(f"x_{i}_{j}", cat="Binary")
for j in range(m)] for i in range(n)]
y = [pulp.LpVariable(f"y_{j}", cat="Binary") for j in range(m)]

# Objective
prob += pulp.lpSum(y)

# (1) Every item assigned to exactly one bin
for i in range(n):
prob += pulp.lpSum(x[i][j] for j in range(m)) == 1

# (2) Weight capacity
for j in range(m):
prob += pulp.lpSum(weights[i] * x[i][j] for i in range(n)) <= W_MAX * y[j]

# (3) Volume capacity
for j in range(m):
prob += pulp.lpSum(volumes[i] * x[i][j] for i in range(n)) <= V_MAX * y[j]

# (4) Incompatibility
incompat_map = {}
for (a, b) in incompatible:
incompat_map.setdefault(a, set()).add(b)
incompat_map.setdefault(b, set()).add(a)
for (a, b) in incompatible:
for j in range(m):
prob += x[a][j] + x[b][j] <= 1

# (5) Fragility constraint
# If ANY fragile item is in bin j, non-fragile weight <= F_CAP
# Linearisation: introduce z_j = 1 if bin j has fragile item
z = [pulp.LpVariable(f"z_{j}", cat="Binary") for j in range(m)]
fragile_items = [i for i in range(n) if fragile[i] == 1]
nonfragile_items = [i for i in range(n) if fragile[i] == 0]

for j in range(m):
# z_j >= x_{i,j} for each fragile item i → z_j=1 if any fragile item present
for i in fragile_items:
prob += z[j] >= x[i][j]
# Non-fragile weight in bin j <= F_CAP * y_j + W_MAX*(1-z_j)
# When z_j=1 (fragile present): nf_weight <= F_CAP
# When z_j=0 (no fragile): nf_weight <= W_MAX (no extra restriction)
prob += (pulp.lpSum(weights[i] * x[i][j] for i in nonfragile_items)
<= F_CAP * z[j] + W_MAX * (1 - z[j]))

# Symmetry-breaking: use lower-index bins first
for j in range(m - 1):
prob += y[j] >= y[j + 1]

t0 = time.time()
solver = pulp.PULP_CBC_CMD(msg=0, timeLimit=120)
prob.solve(solver)
elapsed = time.time() - t0

# Extract solution
assignment = [-1] * n
bins_items = []
used = []
for j in range(m):
if pulp.value(y[j]) and pulp.value(y[j]) > 0.5:
items_in_bin = [i for i in range(n) if pulp.value(x[i][j]) > 0.5]
bins_items.append(items_in_bin)
for i in items_in_bin:
assignment[i] = len(used)
used.append(j)

status = pulp.LpStatus[prob.status]
obj = int(pulp.value(prob.objective) + 0.5)
return assignment, bins_items, status, obj, elapsed

t0 = time.time()
ilp_assign, ilp_bins, ilp_status, ilp_obj, ilp_time = ilp_solve(
weights, volumes, fragile, incompatible, W_MAX, V_MAX, F_CAP,
n_bins_ub=len(ffd_bins))
print(f"ILP solver: {ilp_obj} bins used ({ilp_time:.2f} s) status={ilp_status}")

# ──────────────────────────────────────────────
# 4. VALIDATION
# ──────────────────────────────────────────────
def validate(bins_items, weights, volumes, fragile, incompatible, W_MAX, V_MAX, F_CAP):
incompat_set = set(map(frozenset, incompatible))
errors = []
for b, items in enumerate(bins_items):
tw = sum(weights[i] for i in items)
tv = sum(volumes[i] for i in items)
has_frag = any(fragile[i] for i in items)
nfw = sum(weights[i] for i in items if fragile[i] == 0)
if tw > W_MAX:
errors.append(f"Bin {b}: weight {tw} > {W_MAX}")
if tv > V_MAX:
errors.append(f"Bin {b}: volume {tv} > {V_MAX}")
if has_frag and nfw > F_CAP:
errors.append(f"Bin {b}: fragility cap violated nfw={nfw}")
for i in items:
for k in items:
if i < k and frozenset({i,k}) in incompat_set:
errors.append(f"Bin {b}: incompatible items {i},{k}")
return errors

ffd_errors = validate(ffd_bins, weights, volumes, fragile, incompatible, W_MAX, V_MAX, F_CAP)
ilp_errors = validate(ilp_bins, weights, volumes, fragile, incompatible, W_MAX, V_MAX, F_CAP)
print(f"\nFFD validation: {'✓ OK' if not ffd_errors else ffd_errors}")
print(f"ILP validation: {'✓ OK' if not ilp_errors else ilp_errors}")

# ──────────────────────────────────────────────
# 5. VISUALISATIONS
# ──────────────────────────────────────────────

# --- Helper colours ---
CMAP = plt.cm.get_cmap("tab20", n_items)

# ── Fig 1: Stacked bar — weight & volume usage per bin (ILP solution) ──
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle("ILP Solution — Per-Bin Capacity Usage", fontsize=14, fontweight="bold")

for ax, metric, cap, label, color in zip(
axes,
[weights, volumes],
[W_MAX, V_MAX],
["Weight (kg)", "Volume (L)"],
["#4C72B0", "#DD8452"]):

bottoms = np.zeros(len(ilp_bins))
for i in range(n_items):
vals = []
for b, items in enumerate(ilp_bins):
vals.append(weights[i] if i in items and metric is weights
else volumes[i] if i in items else 0)
ax.bar(range(len(ilp_bins)), vals, bottom=bottoms,
color=CMAP(i), edgecolor="white", linewidth=0.5)
bottoms += np.array(vals)

ax.axhline(cap, color="red", linestyle="--", linewidth=1.5, label=f"Capacity = {cap}")
ax.set_xticks(range(len(ilp_bins)))
ax.set_xticklabels([f"Bin {b}" for b in range(len(ilp_bins))], rotation=30)
ax.set_ylabel(label)
ax.set_title(label + " distribution")
ax.legend()
ax.grid(axis="y", alpha=0.3)

plt.tight_layout()
plt.savefig("fig1_bin_usage.png", dpi=150)
plt.show()

# ── Fig 2: Item scatter — weight vs volume, coloured by bin ──
fig, ax = plt.subplots(figsize=(9, 6))
bin_colors = plt.cm.get_cmap("Set2", len(ilp_bins))

for b, items in enumerate(ilp_bins):
xs = [weights[i] for i in items]
ys = [volumes[i] for i in items]
ax.scatter(xs, ys, color=bin_colors(b), s=120, zorder=3,
edgecolors="black", linewidths=0.6,
label=f"Bin {b} (W={int(ffd_bw[b] if b < len(ffd_bw) else 0)})")
for i, (xi, yi) in zip(items, zip(xs, ys)):
marker = "★" if fragile[i] else ""
ax.annotate(f"{i}{marker}", (xi, yi),
textcoords="offset points", xytext=(5, 4), fontsize=8)

ax.set_xlabel("Weight (kg)", fontsize=12)
ax.set_ylabel("Volume (L)", fontsize=12)
ax.set_title("Item Distribution by Bin Assignment\n(★ = fragile item)", fontsize=13)
ax.legend(loc="upper left", fontsize=8, ncol=2)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.savefig("fig2_item_scatter.png", dpi=150)
plt.show()

# ── Fig 3: 3D Bar — (bin, dimension) → utilisation % ──
fig = plt.figure(figsize=(13, 8))
ax3 = fig.add_subplot(111, projection="3d")

nb = len(ilp_bins)
xpos = np.arange(nb)
ypos_w = np.zeros(nb)
ypos_v = np.ones(nb)

w_util = np.array([sum(weights[i] for i in b) / W_MAX * 100 for b in ilp_bins])
v_util = np.array([sum(volumes[i] for i in b) / V_MAX * 100 for b in ilp_bins])

dx = 0.4
dz = 0.0

# Weight bars
ax3.bar3d(xpos - 0.25, ypos_w, dz, dx, 0.4, w_util,
color="#4C72B0", alpha=0.85, label="Weight utilisation")
# Volume bars
ax3.bar3d(xpos - 0.25, ypos_v, dz, dx, 0.4, v_util,
color="#DD8452", alpha=0.85, label="Volume utilisation")

ax3.set_xlabel("Bin index", labelpad=10)
ax3.set_ylabel("Dimension\n(0=Weight, 1=Volume)", labelpad=10)
ax3.set_zlabel("Utilisation (%)", labelpad=10)
ax3.set_title("3D Bin Utilisation — Weight vs Volume", fontsize=13, pad=15)
ax3.set_xticks(xpos)
ax3.set_xticklabels([f"B{b}" for b in range(nb)])
ax3.set_yticks([0, 1])
ax3.set_yticklabels(["Weight", "Volume"])

# Capacity plane at 100 %
xx, yy = np.meshgrid([xpos[0] - 0.5, xpos[-1] + 0.5], [0 - 0.5, 1 + 0.5])
zz = np.full_like(xx, 100.0)
ax3.plot_surface(xx, yy, zz, alpha=0.15, color="red")
ax3.text(xpos[-1] + 0.3, 0.5, 102, "100% cap", color="red", fontsize=9)

blue_patch = mpatches.Patch(color="#4C72B0", label="Weight util %")
orange_patch = mpatches.Patch(color="#DD8452", label="Volume util %")
ax3.legend(handles=[blue_patch, orange_patch], loc="upper left")
plt.tight_layout()
plt.savefig("fig3_3d_utilisation.png", dpi=150)
plt.show()

# ── Fig 4: Comparison — FFD vs ILP bins, with constraint breakdown ──
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
fig.suptitle("FFD Heuristic vs ILP Optimal — Bin Packing Comparison", fontsize=13, fontweight="bold")

for ax, solution, title in zip(axes,
[ffd_bins, ilp_bins],
[f"FFD Heuristic ({len(ffd_bins)} bins)",
f"ILP Optimal ({len(ilp_bins)} bins)"]):
nb_ = len(solution)
w_vals = [sum(weights[i] for i in b) for b in solution]
v_vals = [sum(volumes[i] for i in b) for b in solution]
f_vals = [int(any(fragile[i] for i in b)) * 15 for b in solution]

bar_w = ax.bar(range(nb_), w_vals, color="#4C72B0", alpha=0.7, label="Weight (kg)")
bar_v = ax.bar(range(nb_), v_vals, color="#DD8452", alpha=0.5,
bottom=w_vals, label="Volume (L)")

for b in range(nb_):
if any(fragile[i] for i in solution[b]):
ax.annotate("🔺", (b, w_vals[b] + v_vals[b] + 2), ha="center", fontsize=11)

ax.axhline(W_MAX, color="blue", linestyle=":", linewidth=1.2, label=f"W_max={W_MAX}")
ax.axhline(W_MAX + V_MAX, color="orange", linestyle=":", linewidth=1.2,
label=f"W+V max={W_MAX+V_MAX}")
ax.set_xticks(range(nb_))
ax.set_xticklabels([f"B{b}" for b in range(nb_)], fontsize=9)
ax.set_ylabel("kg / L (stacked)")
ax.set_title(title)
ax.legend(fontsize=8)
ax.grid(axis="y", alpha=0.3)
ax.text(0.98, 0.02, "🔺 = fragile item present",
transform=ax.transAxes, fontsize=8, ha="right", va="bottom")

plt.tight_layout()
plt.savefig("fig4_ffd_vs_ilp.png", dpi=150)
plt.show()
print("# ←
# ── Fig 5: 3D scatter — item weight × volume × bin index ──
fig = plt.figure(figsize=(11, 8))
ax5 = fig.add_subplot(111, projection="3d")

bin_cmap = plt.cm.get_cmap("tab10", len(ilp_bins))

for b, items in enumerate(ilp_bins):
xs = [weights[i] for i in items]
ys = [volumes[i] for i in items]
zs = [b] * len(items)
frags = [fragile[i] for i in items]
for xi, yi, zi, fi, it in zip(xs, ys, zs, frags, items):
marker = "^" if fi else "o"
ax5.scatter(xi, yi, zi, color=bin_cmap(b),
marker=marker, s=140, edgecolors="k", linewidths=0.6, depthshade=True)
ax5.text(xi, yi, zi + 0.05, str(it), fontsize=7, color="black")

ax5.set_xlabel("Weight (kg)")
ax5.set_ylabel("Volume (L)")
ax5.set_zlabel("Bin index")
ax5.set_title("3D Item Map: Weight × Volume × Bin\n(▲ = fragile, ● = standard)", fontsize=12)
ax5.set_zticks(range(len(ilp_bins)))
ax5.set_zticklabels([f"Bin {b}" for b in range(len(ilp_bins))], fontsize=8)
plt.tight_layout()
plt.savefig("fig5_3d_item_map.png", dpi=150)
plt.show()

# ──────────────────────────────────────────────
# 6. SUMMARY TABLE
# ──────────────────────────────────────────────
print("\n" + "="*60)
print("FINAL SUMMARY")
print("="*60)
print(f"{'Method':<20} {'Bins':>6} {'Time':>10} {'Status':>12}")
print("-"*60)
print(f"{'FFD Heuristic':<20} {len(ffd_bins):>6} {ffd_time*1000:>8.1f}ms {'feasible':>12}")
print(f"{'ILP (CBC)':<20} {ilp_obj:>6} {ilp_time:>8.2f}s {ilp_status:>12}")
print("="*60)

print("\nILP Bin detail:")
print(f"{'Bin':>4} {'Items':>30} {'Weight':>8} {'Volume':>8} {'Fragile':>8}")
for b, items in enumerate(ilp_bins):
tw = sum(weights[i] for i in items)
tv = sum(volumes[i] for i in items)
hf = "yes" if any(fragile[i] for i in items) else "no"
print(f"{b:>4} {str(items):>30} {tw:>8} {tv:>8} {hf:>8}")

Code Walkthrough

Section 1 — Problem Data

Twenty items are generated with numpy using a fixed seed for reproducibility. Each item carries a weight, a volume, and a fragility flag. Five items ({2, 5, 11, 14, 17}) are marked fragile and six incompatible pairs are declared. The constant F_CAP = 60 kg is the maximum non-fragile weight allowed in any bin that also contains a fragile item.

Section 2 — First-Fit Decreasing (FFD) Heuristic

Items are sorted by combined weight + volume in descending order. For each item, the algorithm scans existing open bins in order and places the item in the first bin where all four constraints are satisfied simultaneously:

  • Residual weight capacity $\geq w_i$
  • Residual volume capacity $\geq v_i$
  • No incompatible item already in the bin
  • Fragility cap respected after placement

If no bin accepts the item, a new bin is opened. FFD runs in $O(n^2)$ in the worst case but is extremely fast in practice — here it completes in single-digit milliseconds.

Section 3 — ILP Formulation (PuLP + CBC)

The ILP uses the FFD bin count as an upper bound $m$ on the number of bins opened, which dramatically reduces the search space. The key modelling decisions are:

Fragility linearisation. Constraint (5) involves a conditional: “cap non-fragile weight only if a fragile item is present.” This non-linearity is linearised by introducing a binary indicator $z_j$:

$$z_j \geq x_{ij} \quad \forall i \in \mathcal{F},\ \forall j$$

$$\sum_{i \notin \mathcal{F}} w_i x_{ij} \leq F_{cap} \cdot z_j + W_{max}(1 - z_j) \quad \forall j$$

When $z_j = 1$ (fragile item present) the RHS is $F_{cap}$; when $z_j = 0$ the RHS relaxes to $W_{max}$, effectively removing the restriction.

Symmetry breaking. The constraint $y_j \geq y_{j+1}$ forces bins to be used in increasing index order, eliminating the permutation symmetry that inflates the ILP search tree.

Section 4 — Validation

Each solution is independently verified against all four constraint types. This catches any constraint modelling bugs before visualisation.

Sections 5–6 — Visualisations and Summary

Five figures are generated:

Figure What it shows
Fig 1 Stacked bar of item-level weight and volume contributions per bin
Fig 2 2D scatter of items in weight–volume space, coloured by bin
Fig 3 3D bar chart of weight vs volume utilisation per bin
Fig 4 Side-by-side FFD vs ILP comparison with fragile-bin markers
Fig 5 3D scatter of items mapped onto weight × volume × bin axes

Visualisation Commentary

Fig 1 makes over-packed bins immediately visible — any bar reaching the red dashed line is at capacity. You can inspect which individual items are contributing to congestion.

Fig 3 is the most informative for operational use. The twin 3D bars per bin reveal which dimension is the binding constraint. Bins where weight is near 100% but volume is only 50% suggest weight-dense items; the opposite suggests bulky but light cargo.

Fig 4 directly compares FFD and ILP. FFD is an excellent warm start — the gap is typically just one bin on random instances, but on highly-constrained instances the ILP can yield meaningful savings.

Fig 5 gives an intuitive 3D map of the packing. Items that are heavy and voluminous cluster at the bottom-right and tend to be spread across multiple bins; small items fill in the residual capacity of higher-indexed bins.


Results

Item data:
 Item  Weight  Volume  Fragile
    0       8      13        0
    1      16      13        0
    2      12      15        1
    3       9      15        0
    4       8      15        0
    5      20       4        1
    6      12      13        0
    7      12       8        0
    8       5       5        0
    9       9      10        0
   10       4       4        0
   11       3       6        1
   12      13       4        0
   13       7       8        0
   14       3       6        1
   15       2      10        0
   16      13       8        0
   17      13       3        1
   18      18       5        0
   19      11      10        0

FFD heuristic: 3 bins used  (0.2 ms)
ILP solver:    3 bins used  (0.12 s)  status=Optimal

FFD validation: ✓ OK
ILP validation: ✓ OK





============================================================
FINAL SUMMARY
============================================================
Method                 Bins       Time       Status
------------------------------------------------------------
FFD Heuristic             3      0.2ms     feasible
ILP (CBC)                 3     0.12s      Optimal
============================================================

ILP Bin detail:
 Bin                          Items   Weight   Volume  Fragile
   0 [5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17]       91       68      yes
   1           [0, 1, 4, 6, 18, 19]       73       69       no
   2                     [2, 3, 16]       34       38      yes

Key Takeaways

The multi-dimensional, multi-constraint bin packing problem is a powerful model for real logistics. Three lessons emerge from this experiment:

First, the FFD heuristic with constraint awareness is a strong practical tool. It runs in milliseconds and produces near-optimal solutions — often matching the ILP on random instances.

Second, the fragility linearisation pattern ($z_j \geq x_{ij}$) is a reusable template for any conditional capacity constraint in ILP. Whenever you need “apply constraint $X$ only if condition $Y$ holds,” this is your tool.

Third, 3D visualisation of bin utilisation (Fig 3 and Fig 5) is far more informative than flat bar charts for understanding why bins are opened. Identifying which dimension is the bottleneck directly informs procurement decisions — whether to acquire bins with more weight capacity, more volume, or both.