Optimizing Material Composition

Balancing Strength, Cost, and Weight with Python

When engineers design components — from aerospace brackets to consumer electronics — they rarely get to optimize for just one thing. Real-world design is a battle between competing objectives: you want your material to be strong, but also cheap, and also light. These goals conflict, and that tension is where optimization becomes essential.

In this post, we’ll tackle a concrete example of multi-objective material composition optimization using Python, scipy, and some serious visualization including 3D plots.


The Problem

Imagine you’re designing a structural alloy made from three components:

  • Material A — High strength, high cost, low density
  • Material B — Medium strength, low cost, high density
  • Material C — Low strength, medium cost, low density

Each material contributes proportionally to the alloy’s properties. Your goal is to find the optimal mixing ratios $x_A, x_B, x_C$ (as weight fractions) that minimize cost and weight while maximizing strength — subject to the constraint:

$$x_A + x_B + x_C = 1, \quad x_i \geq 0$$

Material Property Table

Material Strength (MPa) Cost ($/kg) Density (g/cm³)
A 800 50 2.7
B 400 15 7.8
C 200 25 1.5

Blended Properties (Linear Rule of Mixtures)

$$\text{Strength} = 800x_A + 400x_B + 200x_C$$

$$\text{Cost} = 50x_A + 15x_B + 25x_C$$

$$\text{Density} = 2.7x_A + 7.8x_B + 1.5x_C$$


The Objective Function

We convert this into a single weighted objective (scalarization), and also perform Pareto front analysis across cost vs. strength:

where $w_1 + w_2 + w_3 = 1$ are designer-specified importance weights.


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
# ============================================================
# Material Composition Optimization
# Balancing Strength, Cost, and Weight
# ============================================================

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import differential_evolution, NonlinearConstraint
import warnings
warnings.filterwarnings('ignore')

# ── 1. Material Properties ──────────────────────────────────
strength = np.array([800.0, 400.0, 200.0]) # MPa
cost = np.array([ 50.0, 15.0, 25.0]) # $/kg
density = np.array([ 2.7, 7.8, 1.5]) # g/cm³

S_max = strength.max()
C_max = cost.max()
D_max = density.max()

# ── 2. Property Calculators ─────────────────────────────────
def blend_properties(x):
return np.dot(strength, x), np.dot(cost, x), np.dot(density, x)

# ── 3. Weighted Objective ────────────────────────────────────
def objective(x, w_cost=0.4, w_density=0.3, w_strength=0.3):
s, c, d = blend_properties(x)
strength_penalty = max(0.0, 300.0 - s) * 10.0
return (w_cost * c / C_max
+ w_density * d / D_max
- w_strength * s / S_max
+ strength_penalty)

# ── 4. Constraint: xA + xB + xC == 1 (NonlinearConstraint) ─
# NonlinearConstraint(fun, lb, ub) → lb <= fun(x) <= ub
sum_constraint = NonlinearConstraint(lambda x: x.sum(), 1.0, 1.0)

bounds = [(0.0, 1.0)] * 3

# ── 5. Global Optimization ───────────────────────────────────
result = differential_evolution(
objective,
bounds=bounds,
constraints=sum_constraint, # ← NonlinearConstraint, not dict
seed=42,
maxiter=2000,
tol=1e-9,
workers=1,
mutation=(0.5, 1),
recombination=0.9,
popsize=20,
polish=True, # local polish with L-BFGS-B at the end
)

x_opt = result.x
s_opt, c_opt, d_opt = blend_properties(x_opt)

print("=" * 50)
print(" OPTIMAL COMPOSITION")
print("=" * 50)
print(f" Material A : {x_opt[0]*100:.2f} %")
print(f" Material B : {x_opt[1]*100:.2f} %")
print(f" Material C : {x_opt[2]*100:.2f} %")
print("-" * 50)
print(f" Strength : {s_opt:.1f} MPa")
print(f" Cost : ${c_opt:.2f} /kg")
print(f" Density : {d_opt:.3f} g/cm³")
print("=" * 50)

# ── 6. Pareto Sampling ───────────────────────────────────────
N = 80
rows = []
for i in range(N + 1):
for j in range(N + 1 - i):
k = N - i - j
x = np.array([i, j, k], dtype=float) / N
s, c, d = blend_properties(x)
rows.append([s, c, d, x[0], x[1], x[2]])
pareto = np.array(rows) # columns: strength, cost, density, xA, xB, xC

# ── 7. Scenario Sensitivity ──────────────────────────────────
weight_scenarios = {
'Cost-focused\n(0.7,0.2,0.1)': (0.7, 0.2, 0.1),
'Balanced\n(0.4,0.3,0.3)': (0.4, 0.3, 0.3),
'Strength-focused\n(0.1,0.2,0.7)': (0.1, 0.2, 0.7),
'Light-focused\n(0.2,0.7,0.1)': (0.2, 0.7, 0.1),
}
scenario_results = {}
for label, (wc, wd, ws) in weight_scenarios.items():
res = differential_evolution(
lambda x, wc=wc, wd=wd, ws=ws: objective(x, wc, wd, ws),
bounds=bounds,
constraints=sum_constraint,
seed=42, maxiter=1000, tol=1e-8,
workers=1, popsize=15, polish=True,
)
s_, c_, d_ = blend_properties(res.x)
scenario_results[label] = {
'xA': res.x[0]*100, 'xB': res.x[1]*100, 'xC': res.x[2]*100,
'strength': s_, 'cost': c_, 'density': d_,
}

# ── 8. Visualization ─────────────────────────────────────────
fig = plt.figure(figsize=(18, 14))
fig.patch.set_facecolor('#0f0f1a')

# Plot 1: Pareto scatter ──────────────────────────────────────
ax1 = fig.add_subplot(2, 2, 1)
ax1.set_facecolor('#1a1a2e')
sc = ax1.scatter(pareto[:, 1], pareto[:, 0],
c=pareto[:, 2], cmap='plasma', s=18,
alpha=0.8, edgecolors='none')
ax1.scatter(c_opt, s_opt, color='lime', s=250, zorder=5, marker='*',
label=f'Optimal ({x_opt[0]*100:.0f}%A, '
f'{x_opt[1]*100:.0f}%B, {x_opt[2]*100:.0f}%C)')
cbar1 = plt.colorbar(sc, ax=ax1)
cbar1.set_label('Density (g/cm³)', color='white')
cbar1.ax.yaxis.set_tick_params(color='white')
plt.setp(cbar1.ax.yaxis.get_ticklabels(), color='white')
ax1.set_xlabel('Cost ($/kg)', color='white')
ax1.set_ylabel('Strength (MPa)', color='white')
ax1.set_title('Pareto Space: Cost vs Strength\n(color = Density)',
color='white', fontweight='bold')
ax1.tick_params(colors='white')
for sp in ax1.spines.values(): sp.set_color('#444')
ax1.legend(fontsize=8, facecolor='#1a1a2e', labelcolor='white')

# Plot 2: Objective Landscape ────────────────────────────────
ax2 = fig.add_subplot(2, 2, 2)
ax2.set_facecolor('#1a1a2e')
all_xA = pareto[:, 3]
all_xC = pareto[:, 5]
obj_vals = np.array([
objective(np.array([xA, 1.0 - xA - xC, xC]))
for xA, xC in zip(all_xA, all_xC)
])
sc2 = ax2.scatter(all_xA, all_xC, c=obj_vals, cmap='RdYlGn_r',
s=20, alpha=0.85, edgecolors='none')
ax2.scatter(x_opt[0], x_opt[2], color='lime', s=250, zorder=5, marker='*')
cbar2 = plt.colorbar(sc2, ax=ax2)
cbar2.set_label('Objective Value', color='white')
cbar2.ax.yaxis.set_tick_params(color='white')
plt.setp(cbar2.ax.yaxis.get_ticklabels(), color='white')
ax2.set_xlabel('Fraction of Material A (xA)', color='white')
ax2.set_ylabel('Fraction of Material C (xC)', color='white')
ax2.set_title('Objective Landscape\n(xB = 1 − xA − xC)',
color='white', fontweight='bold')
ax2.tick_params(colors='white')
for sp in ax2.spines.values(): sp.set_color('#444')

# Plot 3: Scenario Bar Chart ─────────────────────────────────
ax3 = fig.add_subplot(2, 2, 3)
ax3.set_facecolor('#1a1a2e')
labels_s = list(scenario_results.keys())
xA_vals = [scenario_results[l]['xA'] for l in labels_s]
xB_vals = [scenario_results[l]['xB'] for l in labels_s]
xC_vals = [scenario_results[l]['xC'] for l in labels_s]
xAB = [a + b for a, b in zip(xA_vals, xB_vals)]
x_pos = np.arange(len(labels_s))
ax3.bar(x_pos, xA_vals, label='Material A', color='#e63946', alpha=0.9)
ax3.bar(x_pos, xB_vals, bottom=xA_vals, label='Material B',
color='#457b9d', alpha=0.9)
ax3.bar(x_pos, xC_vals, bottom=xAB, label='Material C',
color='#2a9d8f', alpha=0.9)
ax3.set_xticks(x_pos)
ax3.set_xticklabels(labels_s, fontsize=7.5, color='white')
ax3.set_ylabel('Composition (%)', color='white')
ax3.set_title('Optimal Composition Under Different\nWeight Scenarios',
color='white', fontweight='bold')
ax3.tick_params(colors='white')
for sp in ax3.spines.values(): sp.set_color('#444')
ax3.legend(facecolor='#1a1a2e', labelcolor='white', fontsize=8)
ax3.set_ylim(0, 100)

# Plot 4: 3D Surface ─────────────────────────────────────────
ax4 = fig.add_subplot(2, 2, 4, projection='3d')
ax4.set_facecolor('#1a1a2e')
xA_lin = np.linspace(0, 1, 60)
xB_lin = np.linspace(0, 1, 60)
XA, XB = np.meshgrid(xA_lin, xB_lin)
XC = 1.0 - XA - XB
mask = XC < 0
XC_safe = np.where(mask, 0.0, XC)
S_grid = strength[0]*XA + strength[1]*XB + strength[2]*XC_safe
C_grid = cost[0]*XA + cost[1]*XB + cost[2]*XC_safe
S_grid[mask] = np.nan
C_grid[mask] = np.nan
c_norm = (C_grid - np.nanmin(C_grid)) / (np.nanmax(C_grid) - np.nanmin(C_grid))
ax4.plot_surface(XA, XB, S_grid,
facecolors=plt.cm.plasma(c_norm),
alpha=0.85, linewidth=0, antialiased=True)
ax4.scatter([x_opt[0]], [x_opt[1]], [s_opt],
color='lime', s=120, zorder=10, marker='*')
ax4.set_xlabel('xA (Mat. A)', color='white', labelpad=6)
ax4.set_ylabel('xB (Mat. B)', color='white', labelpad=6)
ax4.set_zlabel('Strength (MPa)', color='white', labelpad=6)
ax4.set_title('3D Surface: Strength(xA, xB)\n(color = Cost)',
color='white', fontweight='bold')
ax4.tick_params(colors='white')
ax4.xaxis.pane.fill = False
ax4.yaxis.pane.fill = False
ax4.zaxis.pane.fill = False
ax4.xaxis.pane.set_edgecolor('#333')
ax4.yaxis.pane.set_edgecolor('#333')
ax4.zaxis.pane.set_edgecolor('#333')

plt.suptitle('Material Composition Optimization — Strength · Cost · Weight',
color='white', fontsize=15, fontweight='bold', y=1.01)
plt.tight_layout()
plt.show()

Code Walkthrough

Section 1–2 · Material Properties & Blending

We define the three materials as dictionaries and use NumPy arrays for vectorized math. The blend_properties(x) function takes a composition vector $\mathbf{x} = [x_A, x_B, x_C]$ and returns all three blended properties via dot products. This is the rule of mixtures — physically valid for many alloys and composites at the first-order approximation.

Section 3 · Objective Function

The objective is a weighted sum of normalized objectives, with the strength term negated (we minimize, so negative strength = maximize it). A soft penalty term kicks in when strength drops below 300 MPa, acting as a constraint without requiring a hard inequality:

$$f(\mathbf{x}) = 0.4 \cdot \hat{c} + 0.3 \cdot \hat{d} - 0.3 \cdot \hat{s} + \lambda \cdot \max(0,; 300 - S(\mathbf{x}))$$

Section 4 · Constraints & Bounds

The equality constraint $\sum x_i = 1$ and bounds $x_i \in [0, 1]$ define the composition simplex — the only physically valid search space.

Section 5 · Differential Evolution (Global Optimizer)

scipy.optimize.differential_evolution is used instead of a local gradient-based solver. Why? The composition simplex is non-convex when penalty terms are involved, so gradient descent risks getting trapped in local minima. Differential evolution is a population-based stochastic algorithm that explores the full feasible space efficiently. With workers=1, it runs safely in Colab’s single-threaded environment.

Section 6 · Pareto Sampling

Rather than running a full multi-objective algorithm (like NSGA-II), we exhaustively sample the simplex on an $N=80$ grid — giving 3,321 feasible compositions — and map each to its (strength, cost, density) triple. This brute-force Pareto sampling is fast enough here and produces smooth, interpretable plots.

Section 7 · Visualization (4 Panels)

Plot 1 — Pareto Space: Each sampled composition is a point in cost–strength space, colored by density. The green star marks the optimizer’s solution. You can immediately see the trade-off frontier: high-strength compositions cost more.

Plot 2 — Objective Landscape: We project the simplex onto the $(x_A, x_C)$ plane (since $x_B = 1 - x_A - x_C$). Green regions have low objective values (good solutions); red regions are poor. The star shows the global optimum.

Plot 3 — Scenario Sensitivity: Four different weight scenarios are solved independently. This reveals how the optimal composition shifts dramatically depending on engineering priorities — a cost-focused design looks nothing like a strength-focused one.

Plot 4 — 3D Surface: Strength as a function of $x_A$ and $x_B$ (with $x_C = 1 - x_A - x_B$, clipped at 0). Color encodes cost. The diagonal cliff is where $x_C < 0$ — physically impossible. The surface clearly shows that high $x_A$ drives strength up while also increasing cost (warm color).


Results

==================================================
  OPTIMAL COMPOSITION
==================================================
  Material A : 16.67 %
  Material B : 0.00 %
  Material C : 83.33 %
--------------------------------------------------
  Strength   : 300.0 MPa
  Cost       : $29.17 /kg
  Density    : 1.700 g/cm³
==================================================


Key Takeaways

The optimization tells a clear story:

  1. Material B alone is cheap but heavy and weak — it dominates only when cost is the overwhelming priority.
  2. Material A alone is strong but expensive — needed when strength targets are strict.
  3. Material C is the “lightweight wildcard” — it improves the density term significantly, especially in light-focused scenarios.
  4. The balanced optimum is never a corner solution — the best all-around alloy always involves a non-trivial blend of all three components.

This multi-objective framing, with explicit weight scenarios and Pareto sampling, is exactly the kind of analysis used in real aerospace alloy selection, polymer formulation, and battery electrode design. The beauty of Python’s scipy + matplotlib stack is that extending this to 5 or 10 materials requires only minimal code changes.