mAb Downstream Purification Process#

This notebook demonstrates the bio manufacturing unit operations in difflow for a monoclonal antibody (mAb) purification process.

Process Overview:

  1. Harvest clarification (centrifuge)

  2. Protein A affinity capture

  3. Ion exchange polishing

  4. 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:

  1. Bio manufacturing unit operations: Centrifuge, chromatography, and filtration

  2. Realistic mAb purification: Industry-standard process train

  3. Differentiability: Gradient computation for sensitivity analysis

All operations support automatic differentiation, enabling:

  • Process optimization

  • Uncertainty quantification

  • Model-based control design