mAb Downstream Purification Process#
This notebook demonstrates the bio manufacturing unit operations in difflow for a monoclonal antibody (mAb) purification process.
Process Overview:
Harvest clarification (centrifuge)
Protein A affinity capture
Ion exchange polishing
Ultrafiltration/diafiltration (UF/DF)
All operations are differentiable, enabling gradient-based optimization of process parameters.
import jax
import jax.numpy as jnp
jax.config.update("jax_enable_x64", True)
from difflow import Flowsheet, Unit, make_stream, get_flows
from difflow_bio import (
# Centrifuge
DiscStackCentrifuge, DiscStackParams,
# Chromatography
ProteinAChromatography, ProteinAParams,
IonExchangeChromatography, IEXParams,
# Filtration
Ultrafiltration, UltrafiltrationParams,
Diafiltration, DiafiltrationParams,
)
1. Define the Process Species#
A typical mAb harvest contains:
mAb: The target product
cells: CHO cells to be removed
HCP: Host cell proteins (impurity)
DNA: Host cell DNA (impurity)
buffer: Buffer/water
species = ["mAb", "cells", "HCP", "DNA", "buffer"]
# Harvest from bioreactor (typical fed-batch harvest)
# Concentrations in g/L, flows represent total mass (g) for a 2000L batch
harvest_volume = 2000.0 # L
harvest = make_stream(
{
"mAb": 5.0 * harvest_volume, # 5 g/L titer = 10 kg total
"cells": 20.0 * harvest_volume, # 20 g/L wet cell weight
"HCP": 2.0 * harvest_volume, # Host cell proteins
"DNA": 0.1 * harvest_volume, # DNA
"buffer": 973.0 * harvest_volume, # Water/buffer (balance)
},
T=310.0, # 37C
P=101325.0,
)
print("Harvest composition:")
for name, flow in get_flows(harvest).items():
conc = float(flow) / harvest_volume
print(f" {name}: {conc:.2f} g/L ({float(flow)/1000:.1f} kg total)")
Harvest composition:
mAb: 5.00 g/L (10.0 kg total)
cells: 20.00 g/L (40.0 kg total)
HCP: 2.00 g/L (4.0 kg total)
DNA: 0.10 g/L (0.2 kg total)
buffer: 973.00 g/L (1946.0 kg total)
2. Harvest Clarification with Disc-Stack Centrifuge#
Remove cells from the harvest using a disc-stack centrifuge. Key parameters:
Number of discs and geometry determine the Sigma factor (equivalent settling area)
Higher Sigma = better separation but larger equipment
Cell diameter and density affect settling velocity
# Configure disc-stack centrifuge
centrifuge_params = DiscStackParams(
n_discs=100,
r_outer=0.15, # 15 cm outer radius
r_inner=0.05, # 5 cm inner radius
half_angle=0.698, # 40 degrees
rpm=6000.0,
efficiency=0.8,
species_order=species,
cell_species="cells",
)
centrifuge = DiscStackCentrifuge(centrifuge_params)
# Run centrifuge
# Q = volumetric flow rate in m³/s (or L/h depending on consistent units)
# Processing 2000 L over ~2 hours = ~1000 L/h = 2.78e-4 m³/s
Q = 2.78e-4 # m³/s
concentrate, clarified, cent_info = centrifuge(
harvest,
Q=Q,
d_particle=15e-6, # CHO cells ~15 μm diameter
rho_particle=1050.0, # Cell density kg/m³
rho_fluid=1000.0, # Broth density ~water
viscosity=0.001, # ~water viscosity Pa·s
concentrate_fraction=0.05, # 5% goes to concentrate
)
print("Centrifuge Results:")
print(f" Sigma factor: {float(centrifuge.sigma):.0f} m²")
print(f" Cell recovery to concentrate: {float(cent_info['cell_recovery'])*100:.1f}%")
print(f" Critical particle diameter: {float(cent_info['critical_diameter'])*1e6:.1f} μm")
# Calculate mAb recovery in clarified stream
mab_in = float(get_flows(harvest)["mAb"])
mab_clarified = float(get_flows(clarified)["mAb"])
print(f" mAb in clarified: {mab_clarified/mab_in*100:.1f}%")
print(f"\nClarified stream (per species):")
for name, flow in get_flows(clarified).items():
print(f" {name}: {float(flow):.1f} g")
Centrifuge Results:
Sigma factor: 32654 m²
Cell recovery to concentrate: 80.0%
Critical particle diameter: 0.2 μm
mAb in clarified: 95.0%
Clarified stream (per species):
mAb: 9500.0 g
cells: 8000.0 g
HCP: 3800.0 g
DNA: 190.0 g
buffer: 1848700.0 g
3. Protein A Affinity Capture#
Protein A chromatography is the workhorse of mAb purification:
Highly selective binding to Fc region of IgG
Typical binding capacity: 30-50 g/L resin
Achieves 2-3 log HCP clearance
# Configure Protein A column
# For 10 kg mAb (9.5 kg in clarified after 5% loss to centrifuge concentrate):
# Column sizing: 9500 g / (40 g/L * 0.8 utilization) = 297 L → use 300 L
proa_params = ProteinAParams(
column_volume=300.0, # 300 L column for 10 kg mAb batch
q_max=40.0, # 40 g/L binding capacity
K_d=0.05, # High affinity
target_species="mAb",
yield_factor=0.95, # 95% elution yield
impurity_clearance={
"HCP": 2.0, # 100x reduction
"DNA": 3.0, # 1000x reduction
"cells": 4.0, # Complete removal
},
species_order=species,
)
proa = ProteinAChromatography(proa_params)
# Run capture step
# Clarified volume is 95% of original harvest (5% went to concentrate)
clarified_volume = harvest_volume * 0.95
load_volume = clarified_volume # Load all of the clarified harvest
(proa_eluate, proa_waste), proa_info = proa(
clarified,
load_volume=load_volume,
feed_volume=clarified_volume,
breakthrough_limit=0.01, # 1% breakthrough
)
print("Protein A Results:")
print(f" mAb yield: {float(proa_info['yield'])*100:.1f}%")
print(f" Purity: {float(proa_info['purity'])*100:.1f}%")
print(f" DBC used: {float(proa_info['DBC']):.1f} g/L")
print(f" Mass loaded: {float(proa_info['mass_loaded']):.1f} g")
print(f" Mass eluted: {float(proa_info['mass_eluted']):.1f} g")
print(f"\nEluate composition:")
for name, flow in get_flows(proa_eluate).items():
if float(flow) > 0.01:
print(f" {name}: {float(flow):.2f} g")
Protein A Results:
mAb yield: 95.0%
Purity: 0.5%
DBC used: 39.2 g/L
Mass loaded: 9500.0 g
Mass eluted: 9025.0 g
Eluate composition:
mAb: 9025.00 g
cells: 0.80 g
HCP: 38.00 g
DNA: 0.19 g
buffer: 1848700.00 g
4. Ion Exchange Polishing (CEX)#
Cation exchange chromatography provides additional purification:
Removes aggregates and charge variants
Can operate in bind-elute or flow-through mode
# Configure CEX column (bind-elute mode)
# The column now has a capacity (q_max x column_volume): size it for the
# ~9 kg ProA eluate, 9000 g / (60 g/L x 0.8 utilization) = 188 L -> 200 L
cex_params = IEXParams(
column_volume=200.0,
mode="bind_elute",
q_max=60.0,
K_d=0.3,
target_species="mAb",
selectivity={
"mAb": 1.0, # Binds strongly
"HCP": 0.3, # Some binding
"DNA": 0.0, # Does not bind (flows through)
"cells": 0.0,
"buffer": 0.0,
},
yield_factor=0.92,
species_order=species,
)
cex = IonExchangeChromatography(cex_params)
# Run polishing step: load the whole ProA eluate (load_volume=None).
# The column's capacity, q_max x column_volume, limits what binds.
(cex_product, cex_waste), cex_info = cex(proa_eluate)
print("CEX Results:")
print(f" mAb yield: {float(cex_info['yield'])*100:.1f}%")
print(f" Purity: {float(cex_info['purity'])*100:.1f}%")
print(f"\nProduct composition:")
for name, flow in get_flows(cex_product).items():
if float(flow) > 0.001:
print(f" {name}: {float(flow):.3f} g")
CEX Results:
mAb yield: 92.0%
Purity: 100.0%
Product composition:
mAb: 8303.000 g
HCP: 0.274 g
5. UF/DF for Final Formulation#
Ultrafiltration concentrates the product, then diafiltration exchanges into final formulation buffer.
# Ultrafiltration - concentrate 10x
uf_params = UltrafiltrationParams(
membrane_area=2.0, # m²
MWCO=30.0, # 30 kDa cutoff
rejection={"mAb": 0.999, "HCP": 0.95, "DNA": 0.0}, # mAb fully retained
species_order=species,
)
uf = Ultrafiltration(uf_params)
(uf_retentate, uf_permeate), uf_info = uf(cex_product, concentration_factor=10.0)
# Get mAb recovery
mab_cex = float(get_flows(cex_product).get("mAb", 0))
mab_uf = float(get_flows(uf_retentate).get("mAb", 0))
uf_recovery = mab_uf / mab_cex if mab_cex > 0 else 0
print("UF Results:")
print(f" mAb recovery: {uf_recovery*100:.1f}%")
print(f" Concentration factor: {float(uf_info['concentration_factor']):.1f}x")
print(f" Volume reduction: {float(uf_info['volume_reduction'])*100:.1f}%")
UF Results:
mAb recovery: 99.8%
Concentration factor: 10.0x
Volume reduction: 90.0%
# Diafiltration - 5 diavolumes for buffer exchange
df_params = DiafiltrationParams(
membrane_area=2.0,
MWCO=30.0,
rejection={"mAb": 0.999, "HCP": 0.90, "DNA": 0.0},
species_order=species,
)
df = Diafiltration(df_params)
# Add formulation buffer
formulation_buffer = make_stream(
{"mAb": 0.0, "cells": 0.0, "HCP": 0.0, "DNA": 0.0, "buffer": 1000.0},
T=298.0,
P=101325.0,
)
(df_product, df_waste), df_info = df(uf_retentate, formulation_buffer, n_diavolumes=5.0)
# Calculate DF recovery
mab_df = float(get_flows(df_product).get("mAb", 0))
df_recovery = mab_df / mab_uf if mab_uf > 0 else 0
print("DF Results:")
print(f" mAb recovery: {df_recovery*100:.1f}%")
print(f" Diavolumes: {float(df_info.get('n_diavolumes', 5.0)):.1f}")
DF Results:
mAb recovery: 99.5%
Diavolumes: 5.0
6. Process Summary#
# Calculate overall yields
harvest_mab = float(get_flows(harvest)["mAb"])
final_mab = float(get_flows(df_product).get("mAb", 0))
overall_yield = final_mab / harvest_mab if harvest_mab > 0 else 0
# Calculate purity
final_flows = get_flows(df_product)
final_mab_mass = float(final_flows.get("mAb", 0))
final_hcp_mass = float(final_flows.get("HCP", 0))
total_protein = final_mab_mass + final_hcp_mass
final_purity = final_mab_mass / total_protein if total_protein > 0 else 1.0
print("="*50)
print("PROCESS SUMMARY")
print("="*50)
print(f"\nStarting material: {harvest_mab/1000:.1f} kg mAb")
print(f"Final product: {final_mab/1000:.2f} kg mAb")
print(f"\nOverall yield: {overall_yield*100:.1f}%")
print(f"Final purity: {final_purity*100:.2f}%")
# Step-by-step yields
mab_clarified = float(get_flows(clarified).get("mAb", 0))
mab_proa = float(get_flows(proa_eluate).get("mAb", 0))
mab_cex = float(get_flows(cex_product).get("mAb", 0))
print(f"\nStep yields:")
print(f" Centrifuge: {mab_clarified/harvest_mab*100:.1f}%")
print(f" Protein A: {mab_proa/mab_clarified*100:.1f}%" if mab_clarified > 0 else " Protein A: N/A")
print(f" CEX: {mab_cex/mab_proa*100:.1f}%" if mab_proa > 0 else " CEX: N/A")
print(f" UF: {uf_recovery*100:.1f}%")
print(f" DF: {df_recovery*100:.1f}%")
==================================================
PROCESS SUMMARY
==================================================
Starting material: 10.0 kg mAb
Final product: 8.24 kg mAb
Overall yield: 82.4%
Final purity: 100.00%
Step yields:
Centrifuge: 95.0%
Protein A: 95.0%
CEX: 92.0%
UF: 99.8%
DF: 99.5%
7. Differentiability: Sensitivity Analysis#
Because all operations are JAX-differentiable, we can compute gradients of any output with respect to any parameter.
def compute_proa_yield(proa_yield_factor):
"""Compute ProA mAb recovery as a function of yield factor."""
# Recreate ProA with different yield factor (using proper column size)
params = ProteinAParams(
column_volume=300.0, # 300 L column for 10 kg batch
q_max=40.0,
K_d=0.05,
target_species="mAb",
yield_factor=proa_yield_factor,
impurity_clearance={"HCP": 2.0, "DNA": 3.0, "cells": 4.0},
species_order=species,
)
proa_unit = ProteinAChromatography(params)
# Run ProA
(eluate, _), _ = proa_unit(
clarified,
load_volume=clarified_volume,
feed_volume=clarified_volume,
)
# Return mAb eluted / mAb in clarified feed
mab_clarified = get_flows(clarified).get("mAb", jnp.array(1.0))
return get_flows(eluate).get("mAb", jnp.array(0.0)) / mab_clarified
# Compute gradient
yield_sensitivity = jax.grad(compute_proa_yield)(jnp.array(0.95))
print(f"Sensitivity: d(ProA_yield)/d(yield_factor) = {float(yield_sensitivity):.3f}")
print(f"\nInterpretation: Increasing yield_factor by 1% (0.01)")
print(f"increases ProA mAb recovery by {float(yield_sensitivity)*0.01*100:.2f} percentage points")
Sensitivity: d(ProA_yield)/d(yield_factor) = 1.000
Interpretation: Increasing yield_factor by 1% (0.01)
increases ProA mAb recovery by 1.00 percentage points
# Sensitivity to concentrate_fraction (mAb loss to concentrate)
def mab_recovery_vs_concentrate_fraction(conc_frac):
"""Compute mAb recovery as function of concentrate fraction.
mAb splits with liquid phase, so higher concentrate_fraction = lower mAb recovery.
"""
params = DiscStackParams(
n_discs=100,
r_outer=0.15,
r_inner=0.05,
half_angle=0.698,
rpm=6000.0,
efficiency=0.8,
species_order=species,
cell_species="cells",
)
cent = DiscStackCentrifuge(params)
_, clar, _ = cent(
harvest, Q=Q, d_particle=15e-6,
concentrate_fraction=conc_frac
)
return get_flows(clar).get("mAb", jnp.array(0.0)) / harvest_mab
cent_sensitivity = jax.grad(mab_recovery_vs_concentrate_fraction)(jnp.array(0.05))
print(f"Sensitivity: d(mAb_recovery)/d(concentrate_fraction) = {float(cent_sensitivity):.3f}")
print(f"\nInterpretation: Increasing concentrate_fraction by 1% (0.01)")
print(f"decreases mAb recovery by {abs(float(cent_sensitivity))*0.01*100:.2f} percentage points")
print(f"\n(mAb splits with liquid - more to concentrate means less to clarified)")
Sensitivity: d(mAb_recovery)/d(concentrate_fraction) = -1.000
Interpretation: Increasing concentrate_fraction by 1% (0.01)
decreases mAb recovery by 1.00 percentage points
(mAb splits with liquid - more to concentrate means less to clarified)
Summary#
This notebook demonstrated:
Bio manufacturing unit operations: Centrifuge, chromatography, and filtration
Realistic mAb purification: Industry-standard process train
Differentiability: Gradient computation for sensitivity analysis
All operations support automatic differentiation, enabling:
Process optimization
Uncertainty quantification
Model-based control design