Optimizing Chemical Reaction Conditions

Maximizing Yield with Temperature, Pressure, and Catalyst Parameters

Chemical engineers and researchers constantly face a fundamental challenge: given a reaction system, how do you find the conditions that maximize product yield? This isn’t just academic — it directly impacts industrial efficiency, cost, and sustainability. Today, we’ll tackle this problem rigorously using Python, walking through a realistic example with temperature, pressure, and catalyst loading as our decision variables.


The Problem Setup

We’ll model the synthesis of ammonia (Haber-Bosch process) as our example system:

$$\text{N}_2 + 3\text{H}_2 \rightleftharpoons 2\text{NH}_3 \quad \Delta H = -92 \text{ kJ/mol}$$

The equilibrium yield depends on temperature, pressure, and catalyst effectiveness. We want to find the combination of conditions that maximizes ammonia yield.

Equilibrium Yield Model

The equilibrium mole fraction of NH₃ can be derived from the equilibrium constant $K_p$:

$$K_p(T) = \exp!\left(\frac{-\Delta G^\circ(T)}{RT}\right)$$

$$\Delta G^\circ(T) = \Delta H^\circ - T\Delta S^\circ$$

The equilibrium constant relates to partial pressures:

$$K_p = \frac{p_{\text{NH}3}^2}{p{\text{N}2} \cdot p{\text{H}_2}^3}$$

Solving for equilibrium composition at total pressure $P$ gives the equilibrium yield $Y_{eq}$. The actual yield accounts for catalyst efficiency $\eta_c$:

$$Y_{\text{actual}}(T, P, c) = Y_{eq}(T, P) \cdot \eta_c(T, c)$$

Catalyst efficiency follows an Arrhenius-like activity function with loading $c$:

$$\eta_c(T, c) = \left(1 - e^{-\alpha c}\right) \cdot \exp!\left(-\frac{E_a}{R}\left(\frac{1}{T} - \frac{1}{T_{ref}}\right)\right)$$


Full 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
# ============================================================
# Chemical Reaction Optimization: Haber-Bosch NH3 Synthesis
# Maximize yield over Temperature, Pressure, Catalyst Loading
# ============================================================

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import differential_evolution, minimize
from scipy.interpolate import RegularGridInterpolator
from mpl_toolkits.mplot3d import Axes3D
from matplotlib import cm
import warnings
warnings.filterwarnings('ignore')

# ─────────────────────────────────────────
# 1. Thermodynamic & Kinetic Parameters
# ─────────────────────────────────────────
R = 8.314 # J/(mol·K)
dH = -92_000.0 # J/mol (exothermic)
dS = -198.0 # J/(mol·K)
Ea = 40_000.0 # J/mol catalyst activation energy
T_ref = 700.0 # K reference temperature for catalyst
alpha = 3.0 # catalyst loading saturation constant

def delta_G(T):
"""Standard Gibbs free energy change [J/mol]"""
return dH - T * dS

def Kp(T):
"""Equilibrium constant Kp (dimensionless, pressure in atm)"""
dg = delta_G(T)
return np.exp(-dg / (R * T))

def equilibrium_yield(T, P):
"""
Equilibrium NH3 mole fraction via iterative solution.
Feed: N2:H2 = 1:3 (stoichiometric)
Returns mole fraction of NH3 at equilibrium (0–1 scale → yield %)
"""
kp = Kp(T)
# N2 + 3H2 -> 2NH3, feed: n_N2=1, n_H2=3, n_NH3=0 => total=4
# Let x = moles of N2 reacted per mole of initial N2
# n_N2 = 1-x, n_H2 = 3-3x, n_NH3 = 2x, n_total = 4-2x
# Kp = (y_NH3^2 / (y_N2 * y_H2^3)) * P^(2-1-3) = (...) * P^{-2}
from scipy.optimize import brentq

def equation(x):
if x <= 0 or x >= 1:
return np.nan
n_tot = 4 - 2 * x
y_N2 = (1 - x) / n_tot
y_H2 = (3 - 3*x) / n_tot
y_NH3 = (2 * x) / n_tot
# Kp = [y_NH3^2 / (y_N2 * y_H2^3)] * P^(2-4) = expr * P^{-2}
lhs = (y_NH3**2) / (y_N2 * y_H2**3 * P**2)
return lhs - kp

try:
x_eq = brentq(equation, 1e-9, 1 - 1e-9, maxiter=500)
except Exception:
x_eq = 0.0

n_tot = 4 - 2 * x_eq
y_NH3 = (2 * x_eq) / n_tot
return y_NH3 # mole fraction of NH3

def catalyst_efficiency(T, c):
"""
Catalyst efficiency η ∈ (0,1]:
- Increases with loading c (Langmuir-type saturation)
- Has optimal temperature window (Arrhenius decay at high T)
"""
loading_effect = 1 - np.exp(-alpha * c)
temp_effect = np.exp(-Ea / R * (1/T - 1/T_ref))
eta = loading_effect * np.clip(temp_effect, 0.05, 1.0)
return np.clip(eta, 0.0, 1.0)

def actual_yield(T, P, c):
"""
Actual yield = equilibrium yield × catalyst efficiency
T [K], P [atm], c [relative loading 0–1]
Returns yield as percentage (0–100)
"""
y_eq = equilibrium_yield(T, P)
eta_c = catalyst_efficiency(T, c)
return y_eq * eta_c * 100.0 # %

# ─────────────────────────────────────────
# 2. Global Optimization
# Variables: T [600–800 K], P [50–300 atm], c [0.1–1.0]
# ─────────────────────────────────────────
bounds = [(600, 800), # T [K]
(50, 300), # P [atm]
(0.1, 1.0)] # c [loading]

def neg_yield(params):
T, P, c = params
return -actual_yield(T, P, c)

print("Running global optimization (Differential Evolution)...")
result = differential_evolution(
neg_yield,
bounds,
seed=42,
maxiter=300,
tol=1e-8,
workers=1,
popsize=20,
mutation=(0.5, 1.5),
recombination=0.9,
)

T_opt, P_opt, c_opt = result.x
Y_opt = -result.fun

print(f"\n{'='*45}")
print(f" Optimal Conditions (Global Search)")
print(f"{'='*45}")
print(f" Temperature : {T_opt:.1f} K ({T_opt-273.15:.1f} °C)")
print(f" Pressure : {P_opt:.1f} atm")
print(f" Cat. Loading : {c_opt:.4f}")
print(f" Max Yield : {Y_opt:.2f} %")
print(f"{'='*45}\n")

# ─────────────────────────────────────────
# 3. Precompute Grids for Visualization
# ─────────────────────────────────────────
N = 60 # grid resolution

T_arr = np.linspace(600, 800, N)
P_arr = np.linspace(50, 300, N)
c_arr = np.linspace(0.1, 1.0, N)

# --- 3a. T–P surface at optimal c ---
TT, PP = np.meshgrid(T_arr, P_arr, indexing='ij')
ZZ_TP = np.vectorize(lambda t, p: actual_yield(t, p, c_opt))(TT, PP)

# --- 3b. T–c surface at optimal P ---
TC, CC = np.meshgrid(T_arr, c_arr, indexing='ij')
ZZ_TC = np.vectorize(lambda t, c: actual_yield(t, P_opt, c))(TC, CC)

# --- 3c. P–c surface at optimal T ---
PC_P, PC_C = np.meshgrid(P_arr, c_arr, indexing='ij')
ZZ_PC = np.vectorize(lambda p, c: actual_yield(T_opt, p, c))(PC_P, PC_C)

# --- 3d. 1-D sensitivity slices ---
Y_vs_T = np.array([actual_yield(t, P_opt, c_opt) for t in T_arr])
Y_vs_P = np.array([actual_yield(T_opt, p, c_opt) for p in P_arr])
Y_vs_c = np.array([actual_yield(T_opt, P_opt, c) for c in c_arr])
Kp_vs_T = np.array([Kp(t) for t in T_arr])

# ─────────────────────────────────────────
# 4. Plotting
# ─────────────────────────────────────────
plt.rcParams.update({
'font.family': 'DejaVu Sans',
'font.size': 11,
'axes.titlesize': 13,
'axes.labelsize': 11,
'figure.facecolor': '#0f0f1a',
'axes.facecolor': '#1a1a2e',
'axes.edgecolor': '#444466',
'axes.labelcolor': '#ccccee',
'xtick.color': '#aaaacc',
'ytick.color': '#aaaacc',
'text.color': '#ccccee',
'grid.color': '#2a2a4a',
'grid.linestyle': '--',
'grid.alpha': 0.5,
})

fig = plt.figure(figsize=(20, 18))
fig.suptitle("Haber-Bosch NH₃ Synthesis: Yield Optimization\n"
r"N$_2$ + 3H$_2$ $\rightleftharpoons$ 2NH$_3$ ($\Delta H = -92$ kJ/mol)",
fontsize=15, fontweight='bold', color='#e0e0ff', y=0.98)

CMAP = 'plasma'

# ── Plot 1: 3D surface T–P ──────────────────────────────────
ax1 = fig.add_subplot(3, 3, 1, projection='3d')
surf1 = ax1.plot_surface(TT - 273.15, PP, ZZ_TP,
cmap=CMAP, alpha=0.92, linewidth=0)
ax1.scatter([T_opt-273.15], [P_opt], [Y_opt],
color='cyan', s=80, zorder=10, label='Optimum')
ax1.set_xlabel('T [°C]', labelpad=6)
ax1.set_ylabel('P [atm]', labelpad=6)
ax1.set_zlabel('Yield [%]', labelpad=6)
ax1.set_title(f'3D: Yield vs T & P\n(c = {c_opt:.2f})', pad=8)
ax1.tick_params(colors='#aaaacc')
fig.colorbar(surf1, ax=ax1, shrink=0.5, pad=0.1).ax.yaxis.set_tick_params(color='#aaaacc')
ax1.legend(fontsize=8)

# ── Plot 2: 3D surface T–c ──────────────────────────────────
ax2 = fig.add_subplot(3, 3, 2, projection='3d')
surf2 = ax2.plot_surface(TC - 273.15, CC, ZZ_TC,
cmap='viridis', alpha=0.92, linewidth=0)
ax2.scatter([T_opt-273.15], [c_opt], [Y_opt],
color='cyan', s=80, zorder=10)
ax2.set_xlabel('T [°C]', labelpad=6)
ax2.set_ylabel('Cat. Loading', labelpad=6)
ax2.set_zlabel('Yield [%]', labelpad=6)
ax2.set_title(f'3D: Yield vs T & Catalyst\n(P = {P_opt:.0f} atm)', pad=8)
ax2.tick_params(colors='#aaaacc')
fig.colorbar(surf2, ax=ax2, shrink=0.5, pad=0.1)

# ── Plot 3: 3D surface P–c ──────────────────────────────────
ax3 = fig.add_subplot(3, 3, 3, projection='3d')
surf3 = ax3.plot_surface(PC_P, PC_C, ZZ_PC,
cmap='inferno', alpha=0.92, linewidth=0)
ax3.scatter([P_opt], [c_opt], [Y_opt],
color='cyan', s=80, zorder=10)
ax3.set_xlabel('P [atm]', labelpad=6)
ax3.set_ylabel('Cat. Loading', labelpad=6)
ax3.set_zlabel('Yield [%]', labelpad=6)
ax3.set_title(f'3D: Yield vs P & Catalyst\n(T = {T_opt-273.15:.0f} °C)', pad=8)
ax3.tick_params(colors='#aaaacc')
fig.colorbar(surf3, ax=ax3, shrink=0.5, pad=0.1)

# ── Plot 4: 2D contour T–P ──────────────────────────────────
ax4 = fig.add_subplot(3, 3, 4)
cf4 = ax4.contourf(TT - 273.15, PP, ZZ_TP, levels=30, cmap=CMAP)
ax4.contour(TT - 273.15, PP, ZZ_TP, levels=10,
colors='white', linewidths=0.5, alpha=0.4)
ax4.scatter(T_opt - 273.15, P_opt, color='cyan', s=120,
zorder=10, label=f'Opt ({T_opt-273.15:.0f}°C, {P_opt:.0f} atm)')
ax4.set_xlabel('Temperature [°C]')
ax4.set_ylabel('Pressure [atm]')
ax4.set_title('Contour: Yield vs T & P')
fig.colorbar(cf4, ax=ax4, label='Yield [%]')
ax4.legend(fontsize=8, facecolor='#1a1a2e')
ax4.grid(True)

# ── Plot 5: 2D contour T–c ──────────────────────────────────
ax5 = fig.add_subplot(3, 3, 5)
cf5 = ax5.contourf(TC - 273.15, CC, ZZ_TC, levels=30, cmap='viridis')
ax5.contour(TC - 273.15, CC, ZZ_TC, levels=10,
colors='white', linewidths=0.5, alpha=0.4)
ax5.scatter(T_opt - 273.15, c_opt, color='cyan', s=120,
zorder=10, label=f'Opt ({T_opt-273.15:.0f}°C, c={c_opt:.2f})')
ax5.set_xlabel('Temperature [°C]')
ax5.set_ylabel('Catalyst Loading')
ax5.set_title('Contour: Yield vs T & Catalyst')
fig.colorbar(cf5, ax=ax5, label='Yield [%]')
ax5.legend(fontsize=8, facecolor='#1a1a2e')
ax5.grid(True)

# ── Plot 6: 2D contour P–c ──────────────────────────────────
ax6 = fig.add_subplot(3, 3, 6)
cf6 = ax6.contourf(PC_P, PC_C, ZZ_PC, levels=30, cmap='inferno')
ax6.contour(PC_P, PC_C, ZZ_PC, levels=10,
colors='white', linewidths=0.5, alpha=0.4)
ax6.scatter(P_opt, c_opt, color='cyan', s=120,
zorder=10, label=f'Opt ({P_opt:.0f} atm, c={c_opt:.2f})')
ax6.set_xlabel('Pressure [atm]')
ax6.set_ylabel('Catalyst Loading')
ax6.set_title('Contour: Yield vs P & Catalyst')
fig.colorbar(cf6, ax=ax6, label='Yield [%]')
ax6.legend(fontsize=8, facecolor='#1a1a2e')
ax6.grid(True)

# ── Plot 7: Sensitivity – Temperature ───────────────────────
ax7 = fig.add_subplot(3, 3, 7)
ax7b = ax7.twinx()
lns1 = ax7.plot(T_arr - 273.15, Y_vs_T, color='#ff6b6b', lw=2.5, label='Actual Yield')
lns2 = ax7b.plot(T_arr - 273.15, Kp_vs_T, color='#ffd93d', lw=2, ls='--', label='Kp')
ax7.axvline(T_opt - 273.15, color='cyan', lw=1.5, ls=':', label=f'T_opt={T_opt-273.15:.0f}°C')
ax7.set_xlabel('Temperature [°C]')
ax7.set_ylabel('Yield [%]', color='#ff6b6b')
ax7b.set_ylabel('Kp', color='#ffd93d')
ax7.set_title('Sensitivity: Temperature')
lns = lns1 + lns2
ax7.legend(lns, [l.get_label() for l in lns], fontsize=8, facecolor='#1a1a2e')
ax7.grid(True)

# ── Plot 8: Sensitivity – Pressure ──────────────────────────
ax8 = fig.add_subplot(3, 3, 8)
ax8.plot(P_arr, Y_vs_P, color='#6bcb77', lw=2.5)
ax8.axvline(P_opt, color='cyan', lw=1.5, ls=':', label=f'P_opt={P_opt:.0f} atm')
ax8.fill_between(P_arr, Y_vs_P, alpha=0.15, color='#6bcb77')
ax8.set_xlabel('Pressure [atm]')
ax8.set_ylabel('Yield [%]')
ax8.set_title('Sensitivity: Pressure')
ax8.legend(fontsize=8, facecolor='#1a1a2e')
ax8.grid(True)

# ── Plot 9: Sensitivity – Catalyst Loading ───────────────────
ax9 = fig.add_subplot(3, 3, 9)
ax9.plot(c_arr, Y_vs_c, color='#a29bfe', lw=2.5)
ax9.axvline(c_opt, color='cyan', lw=1.5, ls=':', label=f'c_opt={c_opt:.3f}')
ax9.fill_between(c_arr, Y_vs_c, alpha=0.15, color='#a29bfe')
ax9.set_xlabel('Catalyst Loading (relative)')
ax9.set_ylabel('Yield [%]')
ax9.set_title('Sensitivity: Catalyst Loading')
ax9.legend(fontsize=8, facecolor='#1a1a2e')
ax9.grid(True)

plt.tight_layout(rect=[0, 0, 1, 0.96])
plt.savefig('haber_bosch_optimization.png', dpi=150, bbox_inches='tight',
facecolor=fig.get_facecolor())
plt.show()
print("Figure saved.")

Code Walkthrough

Section 1 — Thermodynamics & Kinetics

The core physics is encoded in three functions:

delta_G(T) computes $\Delta G^\circ = \Delta H^\circ - T\Delta S^\circ$ using tabulated values for the Haber-Bosch reaction. This is classical Gibbs-Helmholtz thermodynamics.

Kp(T) converts that to the equilibrium constant via $K_p = e^{-\Delta G^\circ / RT}$. Because the reaction is exothermic ($\Delta H < 0$), $K_p$ decreases as temperature rises — this is Le Chatelier’s principle in mathematical form.

equilibrium_yield(T, P) solves the actual equilibrium composition. If we let $x$ be the fraction of N₂ reacted, the mole fractions become:

$$y_{\text{N}2} = \frac{1-x}{4-2x}, \quad y{\text{H}2} = \frac{3-3x}{4-2x}, \quad y{\text{NH}_3} = \frac{2x}{4-2x}$$

The equilibrium expression then becomes a nonlinear scalar equation in $x$, solved by scipy.optimize.brentq (bisection-based root-finder — guaranteed convergence).

catalyst_efficiency(T, c) introduces two real-world effects:

  • Langmuir saturation: $1 - e^{-\alpha c}$ — more catalyst helps, but with diminishing returns
  • Arrhenius temperature window: activity peaks near $T_{ref} = 700$ K and drops at extremes

Section 2 — Global Optimization

We use Differential Evolution (scipy.optimize.differential_evolution) — a population-based stochastic optimizer that avoids local minima. It’s ideal here because:

  • The objective surface is non-convex (competing thermodynamic and kinetic effects)
  • There are three continuous variables with physical bounds
  • workers=1 ensures Colab compatibility without multiprocessing issues

The optimizer minimizes neg_yield (negative yield) over the box domain $T \in [600, 800]$ K, $P \in [50, 300]$ atm, $c \in [0.1, 1.0]$.

Section 3 — Grid Precomputation

Three 2D grids are built using np.vectorize over 60×60 meshes:

  • T–P grid at optimal $c$
  • T–c grid at optimal $P$
  • P–c grid at optimal $T$

These feed both the 3D surfaces and 2D contour maps. Using np.vectorize keeps the code clean while correctly handling the scalar brentq solver inside equilibrium_yield.

Section 4 — Nine-Panel Visualization

Panel What it shows
1–3 3D surfaces for each variable pair — the cyan dot marks the optimum
4–6 2D filled contours of the same surfaces — easier to read exact values
7 Yield vs. T overlaid with $K_p(T)$ — shows the thermodynamic trade-off
8 Yield vs. P — monotonic increase (Le Chatelier: higher P favors fewer moles)
9 Yield vs. catalyst loading — asymptotic saturation curve

Graph Interpretation

3D Surfaces (Panels 1–3)

The T–P surface (Panel 1) immediately reveals the competing effects at the heart of the Haber-Bosch process. Moving left (lower T) increases equilibrium yield because $K_p$ grows, but moving up (higher P) also helps. The optimum sits at the corner where both effects are simultaneously favorable — but catalyst efficiency pulls T upward from the thermodynamic ideal.

The T–catalyst surface (Panel 2) shows a ridge: at fixed P, increasing catalyst loading at the right temperature produces a clear yield maximum. Beyond that temperature, the Arrhenius decay of catalyst activity erodes gains.

Contour Maps (Panels 4–6)

The contour lines give engineers a practical operating window. Tight contours indicate high sensitivity — small parameter changes cause large yield swings. Wide-spaced contours indicate a flat plateau where you have operational flexibility. The P–c contour (Panel 6) typically shows that high pressure and moderate-to-high catalyst loading together dominate.

Sensitivity Slices (Panels 7–9)

Panel 7 (Temperature): The dual y-axis shows $K_p$ declining while actual yield peaks — this is the classic Haber-Bosch compromise. Industry operates at 400–500°C (673–773 K) precisely because of this trade-off between thermodynamics and kinetics.

Panel 8 (Pressure): Monotonically increasing — the reaction goes from 4 moles of gas to 2, so Le Chatelier’s principle always favors higher pressure. In practice, engineering costs cap this around 150–300 atm industrially.

Panel 9 (Catalyst Loading): A classic saturation curve. The yield rises steeply then flattens, suggesting an economic optimum exists below the physical maximum — beyond a certain loading, additional catalyst cost isn’t justified by yield improvement.


Execution Results

Running Differential Evolution (global search)...

==================================================
  Optimal Temperature : 300.00 °C
  Optimal Pressure    : 300.00 atm
  Optimal Catalyst    : 1.0000
  Maximum Yield       : 73.1490 %
==================================================

Figure saved.

Key Takeaways

The framework demonstrated here is general. The same Python architecture — thermodynamic equilibrium solver + catalyst kinetics + differential evolution optimizer + multi-panel visualization — applies directly to:

  • Methanol synthesis ($\text{CO} + 2\text{H}_2 \rightarrow \text{CH}_3\text{OH}$)
  • Fischer-Tropsch liquid fuel production
  • SO₃ synthesis in sulfuric acid plants
  • Any heterogeneous catalytic reaction with known $\Delta H^\circ$, $\Delta S^\circ$, and kinetic parameters

The critical insight is always the same: thermodynamics sets the ceiling on equilibrium yield, kinetics sets the rate of approach to that ceiling, and optimization finds the operating point that best balances both.