Introduction
The ACT isotherm is a modified Langmuir isotherm tailored for mechanistic modeling in protein A chromatography. It is known that both binding capacity q_{max} and equilibrium constant (affinity) K_{eq} changes as a function of pH. ACT isotherm uses modification factors derived based on proA and mAb interaction stoichiometries. Details can be found in the paper.
Example code
from cadet import Cadet
import numpy as np
import matplotlib.pyplot as plt
Cadet.cadet_path = r"C:\Users\zwend\CADET\cadet_act2025\bin\cadet-cli.exe" ## IMPORTANT: use a version higher than 5.0.4
## system settings
column_length = 0.025 ## m
column_volume = 5.025e-6 ## m^3
protein_MW = 150 ## kDa
column_porosity = 0.31 ## 1
particle_porosity = 0.95 ## 1
Q = 1.25 /(6e7) ## volumetric flow rate, m^3/s
elution_pH_start = 5.5
elution_pH_end = 3.3
loading = 50.0 ## mg/mL resin
c_feed = 4.0 / protein_MW ## mol/m^3
RT = column_volume / Q ## s
event_CV = [0.0, loading / (c_feed * protein_MW), 4.0, 10.0, ] ## load, wash, elution in CV
Keq = 268 ## ml/mg
Q_max = 85.6 ## mg/ml column volume
total_porosity = column_porosity + (1.0-column_porosity)*particle_porosity
model = Cadet()
model.root.input.model.nunits = 3
model.root.input.model.unit_000.unit_type = 'INLET'
model.root.input.model.unit_000.ncomp = 2 ## The first component is pH
model.root.input.model.unit_000.inlet_type = 'PIECEWISE_CUBIC_POLY'
model.root.input.model.unit_001.unit_type = 'GENERAL_RATE_MODEL'
model.root.input.model.unit_001.ncomp = 2
## Geometry
model.root.input.model.unit_001.col_length = column_length # m
model.root.input.model.unit_001.cross_section_area = column_volume/column_length # m^2
model.root.input.model.unit_001.col_porosity = column_porosity # 1
model.root.input.model.unit_001.par_porosity = particle_porosity # 1
model.root.input.model.unit_001.par_radius = 0.0425e-3 # m
## Transport
model.root.input.model.unit_001.col_dispersion = 1.36e-8 # m^2/s
model.root.input.model.unit_001.film_diffusion = [1, 1.41e-5,] # m/s
model.root.input.model.unit_001.par_diffusion = [1, 1.99e-11] # m^2/s
model.root.input.model.unit_001.par_surfdiffusion = [0.0, 0.0]
model.root.input.model.unit_001.adsorption_model = 'AFFINITY_COMPLEX_TITRATION'
model.root.input.model.unit_001.adsorption.is_kinetic = 1
model.root.input.model.unit_001.adsorption.act_ka = [1.0, Keq*protein_MW*(1.0-total_porosity), ] ## m^3 solid phase / mol protein / s ml/mg=m^3/kg 1 kda = 1 kg/mol
model.root.input.model.unit_001.adsorption.act_kd = [1.0, 1.0] ## s^-1
model.root.input.model.unit_001.adsorption.act_qmax = [1e-10, Q_max/protein_MW/(1.0-total_porosity),] ## mol/m^3 solid phase mg/ml=kg/m^3 / 150 kg/mol = 1/150 mol/m^3
model.root.input.model.unit_001.adsorption.act_etaA = [0, 2.12, ]
model.root.input.model.unit_001.adsorption.act_pkaA = [0, 3.82, ]
model.root.input.model.unit_001.adsorption.act_etaG = [0, 3.48, ]
model.root.input.model.unit_001.adsorption.act_pkaG = [0, 4.66, ]
model.root.input.model.unit_001.init_c = [7.2, 0.0, ]
model.root.input.model.unit_001.init_q = [0.0, 0.0]
### Grid cells
model.root.input.model.unit_001.discretization.ncol = 50
model.root.input.model.unit_001.discretization.npar = 12
### Bound states
model.root.input.model.unit_001.discretization.nbound = [0, 1,]
### Other options
model.root.input.model.unit_001.discretization.par_disc_type = 'EQUIDISTANT_PAR'
model.root.input.model.unit_001.discretization.use_analytic_jacobian = 1
model.root.input.model.unit_001.discretization.reconstruction = 'WENO'
model.root.input.model.unit_001.discretization.gs_type = 1
model.root.input.model.unit_001.discretization.max_krylov = 0
model.root.input.model.unit_001.discretization.max_restarts = 10
model.root.input.model.unit_001.discretization.schur_safety = 1.0e-8
model.root.input.model.unit_001.discretization.weno.boundary_model = 0
model.root.input.model.unit_001.discretization.weno.weno_eps = 1e-10
model.root.input.model.unit_001.discretization.weno.weno_order = 2
model.root.input.model.unit_002.unit_type = 'OUTLET'
model.root.input.model.unit_002.ncomp = 2
model.root.input.solver.sections.nsec = 4
model.root.input.solver.sections.section_times = [0, event_CV[1] * RT, (event_CV[1] + event_CV[2]) * RT, (event_CV[1] + event_CV[2] + event_CV[3]) * RT, (event_CV[1] + event_CV[2] + event_CV[3] + 5.5) * RT] ## s
model.root.input.solver.sections.section_continuity = [0,0,0,0]
## load
model.root.input.model.unit_000.sec_000.const_coeff = [7.2, c_feed,] # mol / m^3
model.root.input.model.unit_000.sec_000.lin_coeff = [0.0, 0.0,]
model.root.input.model.unit_000.sec_000.quad_coeff = [0.0, 0.0,]
model.root.input.model.unit_000.sec_000.cube_coeff = [0.0, 0.0,]
## wash
model.root.input.model.unit_000.sec_001.const_coeff = [5.5, 0.0,]
model.root.input.model.unit_000.sec_001.lin_coeff = [0.0, 0.0,]
model.root.input.model.unit_000.sec_001.quad_coeff = [0.0, 0.0,]
model.root.input.model.unit_000.sec_001.cube_coeff = [0.0, 0.0,]
## elution
model.root.input.model.unit_000.sec_002.const_coeff = [elution_pH_start, 0.0]
model.root.input.model.unit_000.sec_002.lin_coeff = [-(elution_pH_start-elution_pH_end)/(event_CV[3] * RT), 0.0, ]
model.root.input.model.unit_000.sec_002.quad_coeff = [0.0, 0.0,]
model.root.input.model.unit_000.sec_002.cube_coeff = [0.0, 0.0,]
## end
model.root.input.model.unit_000.sec_003.const_coeff = [elution_pH_end, 0.0]
model.root.input.model.unit_000.sec_003.lin_coeff = [0.0,0.0,]
model.root.input.model.unit_000.sec_003.quad_coeff = [0.0,0.0,]
model.root.input.model.unit_000.sec_003.cube_coeff = [0.0,0.0,]
model.root.input.model.connections.nswitches = 1
model.root.input.model.connections.switch_000.section = 0
model.root.input.model.connections.switch_000.connections = [
0, 1, -1, -1, Q,
1, 2, -1, -1, Q]
model.root.input.model.solver.gs_type = 1
model.root.input.model.solver.max_krylov = 0
model.root.input.model.solver.max_restarts = 10
model.root.input.model.solver.schur_safety = 1e-8
model.root.input.solver.nthreads = 1
# Tolerances for the time integrator
model.root.input.solver.time_integrator.abstol = 1e-6
model.root.input.solver.time_integrator.algtol = 1e-10
model.root.input.solver.time_integrator.reltol = 1e-6
model.root.input.solver.time_integrator.init_step_size = 1e-6
model.root.input.solver.time_integrator.max_steps = 1000000
# Return data
model.root.input['return'].split_components_data = 1
model.root.input['return'].split_ports_data = 0
model.root.input['return'].unit_000.write_solution_bulk = 0
model.root.input['return'].unit_000.write_solution_inlet = 0
model.root.input['return'].unit_000.write_solution_outlet = 1
# Copy settings to the other unit operations
model.root.input['return'].unit_001 = model.root.input['return'].unit_000
model.root.input['return'].unit_002 = model.root.input['return'].unit_000
# Solution times
model.root.input.solver.user_solution_times = np.linspace(0, (event_CV[1] + event_CV[2] + event_CV[3] + 5.5) * RT, 201)
model.filename = 'model.h5'
model.save()
data = model.run()
model.load()
time = model.root.output.solution.solution_times
c = model.root.output.solution.unit_001.solution_outlet_comp_001
pH_outlet = model.root.output.solution.unit_001.solution_outlet_comp_000
fig = plt.figure()
ax = fig.add_subplot()
ax.plot(time[1:]/60*Q*6e7, c[1:]*protein_MW, c="orange", label="mAb")
ax.set_xlabel(r'Volume/mL')
ax.set_ylabel(r'Concentration/(mg/mL)')
ax_ph = ax.twinx()
ax_ph.plot(time/60*Q*6e7, pH_outlet, label='pH')
ax_ph.set_ylabel('pH')
plt.show()
The above code should produce the following plot:
Feel free to change the code to step elution, different elution pHs etc.
Although the intended use of the ACT isotherm is to model pH effects, pH can be substituted for any type of salt as well. See the same paper Appendix for examples.
Please reach out to me with any questions regarding the isotherm usage.