Examples to use gACT isotherm for (multimodal) cation exchange chromatography with split peaks

Background

As I discussed in the Recovery conference, the design philosophy of ACT isotherm goes far beyond affinity chromatography. In theory, it can be applied to any modes of chromatography as detailed in the paper Appendix section (generalized ACT or gACT). Langmuir isotherms can be generally applied to any modes of chromatography including CEX, gACT modifies Langmuir isotherm and provides general handles like pH, H+, Na+, Ca2+, etc. to impact the binding capacity and equilibrium constants.

Recently the implementation of ACT isotherm has been improved to accommodate gACT. Below are some examples to use gACT to simulate a linear salt gradient in a cation exchange chromatography step.

Split peaks

Split peaks may arise from many different sources. The ACT isotherm can mimic some of these effects because 1. if the apparent binding capacity and apparent equilibrium constant change too differently, it tends to give shoulders or split peaks; 2. ACT assumes competitive binding to the same binding sites for different components, which could react to pH/salt concentration dramatically differently during elution. The more strongly binding species can delay, sharpen, or partially displace the weaker species, producing apparent split peak or asymmetric elution profiles.

With the above said, those are perspectives from the isotherm. Whether or not it makes physical sense or what is causing these behaviors physically depends on the users’ interpretation.

Single component system w/ and w/o split peaks

A simple single component system:


Change the isotherm parameter we could get split peaks even for a single component system:

Multi component system w/ split peaks

For a multicomponent system, one can also get split peaks. For instance:


Monomer and LMW12 are configured to have only one peak. Zoomed-in versions for HMW13 and HMW2 which have split peaks:

Depending on the parameters, there could be more than 2 split peaks. I will share a good example when I come across one.

Code

The CADET-Core code will be released along with version 6 in the future. For urgent use, please compile the latest version of CADET-Core on github or DM me. Below is the CADET-Python code to reproduce the above figures.

Single component system:

Cadet.cadet_path = ## use your cadet_cli.exe path

def run_cadet(x, start_salt_conc, end_salt_conc, grad_start_time, grad_end_time, loaded_mass, filename):
    '''
    @input: x contains all input params for monomer only
    x = [etaA, cmidA, etaG, cmidG, log(Dp)]
    loaded_mass in mg (scalar, monomer only)
    '''
    etaA   = x[0]
    cmidA  = x[1]
    etaG   = x[2]
    cmidG  = x[3]
    Dp     = np.exp(x[4])

    Q_max  = 104.91
    Keq    = 1189.96

    n_comp = 1  # monomer only

    protein_MW = 150e3   # Da = g/mol = mg/mmol
    load_conc  = 5.99    # mg/ml

    column_porosity  = 0.40
    particle_porosity = 0.65
    RT = 3 * 60          # s

    total_porosity = column_porosity + (1.0 - column_porosity) * particle_porosity

    model = Cadet()

    ## Number of units
    model.root.input.model.nunits = 3

    ## Inlet
    model.root.input.model.unit_000.unit_type = 'INLET'
    model.root.input.model.unit_000.ncomp = 1 + n_comp   # salt + monomer
    model.root.input.model.unit_000.inlet_type = 'PIECEWISE_CUBIC_POLY'

    ## Column model (GRM)
    model.root.input.model.unit_001.unit_type = 'COLUMN_MODEL_1D'
    model.root.input.model.unit_001.ncomp = 1 + n_comp
    model.root.input.model.unit_001.npartype = 1

    ## Geometry
    model.root.input.model.unit_001.col_length = 0.1
    model.root.input.model.unit_001.cross_section_area = 0.25 * np.pi * 0.005**2

    CV = model.root.input.model.unit_001.col_length * model.root.input.model.unit_001.cross_section_area
    Q  = CV / RT
    loading = loaded_mass / (CV * 1e6)   # mg/ml resin

    model.root.input.model.unit_001.col_porosity = column_porosity
    model.root.input.model.unit_001.particle_type_000.par_porosity  = particle_porosity
    model.root.input.model.unit_001.particle_type_000.par_radius    = 50.0 / 2.0 * 1e-6

    ## Transport
    model.root.input.model.unit_001.col_dispersion = 6e-8
    model.root.input.model.unit_001.particle_type_000.has_film_diffusion  = True
    model.root.input.model.unit_001.particle_type_000.film_diffusion      = np.array([1e-05, 1e-05], dtype=np.float64)
    model.root.input.model.unit_001.particle_type_000.has_pore_diffusion  = True
    model.root.input.model.unit_001.particle_type_000.pore_diffusion      = np.array([7e-10, Dp],   dtype=np.float64)
    model.root.input.model.unit_001.particle_type_000.has_surface_diffusion = False

    ## Isotherm
    model.root.input.model.unit_001.particle_type_000.adsorption_model = 'AFFINITY_COMPLEX_TITRATION'
    model.root.input.model.unit_001.particle_type_000.nbound           = np.array([0, 1])
    model.root.input.model.unit_001.particle_type_000.adsorption.act_use_ion_conc = True
    model.root.input.model.unit_001.particle_type_000.adsorption.is_kinetic       = 1

    model.root.input.model.unit_001.particle_type_000.adsorption.act_ka = [
        1.0,
        Keq * protein_MW * (1.0 - total_porosity),
    ]
    model.root.input.model.unit_001.particle_type_000.adsorption.act_kd = [1.0, 1.0]

    model.root.input.model.unit_001.particle_type_000.adsorption.act_qmax = [
        1e-10,
        Q_max / protein_MW / (1.0 - total_porosity),
    ]

    model.root.input.model.unit_001.particle_type_000.adsorption.act_etaa   = np.array([0.0, etaA],  dtype=np.float64)
    model.root.input.model.unit_001.particle_type_000.adsorption.act_etag   = np.array([0.0, etaG],  dtype=np.float64)
    model.root.input.model.unit_001.particle_type_000.adsorption.act_cmid_a = np.array([0.0, cmidA], dtype=np.float64)
    model.root.input.model.unit_001.particle_type_000.adsorption.act_cmid_g = np.array([0.0, cmidG], dtype=np.float64)

    ## Initial conditions
    model.root.input.model.unit_001.init_c  = [start_salt_conc, 0.0]
    model.root.input.model.unit_001.init_cp = [start_salt_conc, 0.0]

    ## FVM discretization
    model.root.input.model.unit_001.discretization.spatial_method    = "FV"
    model.root.input.model.unit_001.discretization.reconstruction    = np.bytes_(b'WENO')
    model.root.input.model.unit_001.discretization.ncol              = np.int64(40)
    model.root.input.model.unit_001.discretization.weno.weno_order   = np.int64(3)

    model.root.input.model.unit_001.particle_type_000.discretization.spatial_method  = "FV"
    model.root.input.model.unit_001.particle_type_000.discretization.ncells          = np.int64(20)
    model.root.input.model.unit_001.particle_type_000.discretization.par_disc_type   = np.bytes_(b'EQUIDISTANT_PAR')

    model.root.input.model.unit_001.discretization.use_analytic_jacobian = 1
    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.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

    ## Time integrator tolerances
    model.root.input.solver.time_integrator.abstol             = 1e-08
    model.root.input.solver.time_integrator.algtol             = 1e-12
    model.root.input.solver.time_integrator.init_step_size     = 1e-12
    model.root.input.solver.time_integrator.max_convtest_fail  = 50
    model.root.input.solver.time_integrator.max_errtest_fail   = 7
    model.root.input.solver.time_integrator.max_steps          = 1000000
    model.root.input.solver.time_integrator.reltol             = 1e-08

    ## Outlet
    model.root.input.model.unit_002.unit_type = 'OUTLET'
    model.root.input.model.unit_002.ncomp = 1 + n_comp

    ## Section times: load, wash, elution, hold
    lag_time = 1300
    model.root.input.solver.sections.nsec = 4
    model.root.input.solver.sections.section_times = [
        0,
        RT * loading / load_conc,
        grad_start_time,
        grad_end_time,
        grad_end_time + lag_time,
    ]
    model.root.input.solver.sections.section_continuity = [0, 0, 0, 0]

    ## Load phase
    model.root.input.model.unit_000.sec_000.const_coeff = [start_salt_conc, load_conc / protein_MW]
    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 phase
    model.root.input.model.unit_000.sec_001.const_coeff = [start_salt_conc, 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 phase
    model.root.input.model.unit_000.sec_002.const_coeff = [start_salt_conc, 0.0]
    model.root.input.model.unit_000.sec_002.lin_coeff   = [
        (end_salt_conc - start_salt_conc) / (grad_end_time - grad_start_time), 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]

    ## Hold phase
    model.root.input.model.unit_000.sec_003.const_coeff = [end_salt_conc, 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]

    ## Return / output settings
    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
    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, grad_end_time + lag_time, 500)

    ## Connections
    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.filename = filename
    model.save()
    data = model.run()
    model.load()

    time   = model.root.output.solution.solution_times
    volume = time * Q * 1e6                                        # mL
    salt   = model.root.output.solution.unit_001.solution_outlet_comp_000
    monomer = model.root.output.solution.unit_001.solution_outlet_comp_001 * protein_MW

    return volume, salt, monomer


## sinlge component, simulation 1, single peak
x = np.asarray([
    2.28,          # etaA
    270.62,        # cmidA
    10.89,         # etaG, 
    310.76,        # cmidG,   
    -26.3,         # log(Dp)
])

loaded_mass = 53.12209550021964   # mg, monomer only

volume, salt, monomer = run_cadet(x, 54, 1038, 2373, 9647, loaded_mass, 'model.h5')

## plot
fig, axes = plt.subplots(1, 1, figsize=(12, 6), )

axes.plot(volume, monomer, color='red',  label='Monomer', alpha=0.6)
axes.set_ylabel('Concentration (mg/mL)')
axes.set_title('Single component, linear salt gradient using gACT isotherm')
axes.legend(loc = 'upper left')
axes.grid(True, alpha=0.3)
axes.set_xlabel('Volume (mL)')

axes2 = axes.twinx()
axes2.plot(volume, salt, color='blue',  label='Na⁺ (salt)', alpha=0.6)
axes2.set_ylabel('Concentration (mM)')
axes2.legend(loc = 'center left')

plt.show()

## sinlge component, simulation 2, split peaks

x = np.asarray([
    2.28,          # etaA
    220.62,        # cmidA
    13.89,         # etaG, 
    400.76,        # cmidG,   310.76    
    -26.3,         # log(Dp)
])

loaded_mass = 53.12209550021964   # mg, monomer only

volume, salt, monomer = run_cadet(x, 54, 1038, 2373, 9647, loaded_mass, 'model.h5')

# plot
fig, axes = plt.subplots(1, 1, figsize=(12, 6), )

axes.plot(volume, monomer, color='red',  label='Monomer', alpha=0.6)
axes.set_ylabel('Concentration (mg/mL)')
axes.set_title('Single component with split peaks, linear salt gradient using gACT isotherm')
axes.legend(loc = 'upper left')
axes.grid(True, alpha=0.3)
axes.set_xlabel('Volume (mL)')

axes2 = axes.twinx()
axes2.plot(volume, salt, color='blue',  label='Na⁺ (salt)', alpha=0.6)
axes2.set_ylabel('Concentration (mM)')
axes2.legend(loc = 'center left')
axes2.set_xlabel('Volume (mL)')

plt.show()

Multicomponent system:

def run_cadet(x, start_salt_conc, end_salt_conc, grad_start_time, grad_end_time, loaded_mass, filename):
    '''
    @input: x contains all input params: the order should be qmax, keq, etaA, pkaA, etaG, pkaG
    loaded_mass in mg, mass of all components
    '''
    ## comfigure for a multicomponent system
    ## the sequence is HMW31, HMW2, mono, LMW12
    etaA = [x[0], x[1], x[2], x[3], ]
    cmidA = [x[4], x[5], x[6], x[7], ]

    etaG = [x[8], x[9], x[10], x[11], ]
    cmidG = [x[12], x[13], x[14], x[15], ]

    Dps = np.exp(np.asarray([x[16], x[17], x[18], x[19], ]))

    Q_max = [188.96, 197.45, 104.91, 112.26, ]
    Keq = [1253.32, 1057.12, 1189.96, 2177.84, ]

    n_comp = len(etaA)
   
    protein_MW = np.asarray([450e3, 300e3, 150e3, 75e3])                     ## Da = g/mol = mg/mmol
    load_conc = 5.99                                                         ## mg/ml

    column_porosity = 0.40                                                   ## 1
    particle_porosity = 0.65                                                 ## 1
    RT = 3*60                                                                ## s
   
    total_porosity = column_porosity + (1.0-column_porosity)*particle_porosity

    load_ratio = [loaded_mass[i] / np.sum(loaded_mass) for i in range (0, n_comp)]
   
    model = Cadet()
   
    ## number of units
    model.root.input.model.nunits = 3
   
    ## Inlet
    model.root.input.model.unit_000.unit_type = 'INLET'
    model.root.input.model.unit_000.ncomp = 1+n_comp                                             # salt + proteins
    model.root.input.model.unit_000.inlet_type = 'PIECEWISE_CUBIC_POLY'
   
    ## column model
    ## GRM
    model.root.input.model.unit_001.unit_type = 'COLUMN_MODEL_1D'
    model.root.input.model.unit_001.ncomp = 1+n_comp
   
    ## Geometry
    model.root.input.model.unit_001.col_length = 0.1                                        # m
    model.root.input.model.unit_001.cross_section_area = 0.25*np.pi*0.005**2                # m
   
    CV = model.root.input.model.unit_001.col_length * model.root.input.model.unit_001.cross_section_area   ## m^3
    Q = CV / RT                                                                             # m^3 / s
    loading = np.sum(loaded_mass) / (CV*1e6)                                                # mg/ml resin
   
    model.root.input.model.unit_001.col_porosity = column_porosity                          # -
    model.root.input.model.unit_001.particle_type_000.par_porosity = particle_porosity                        # -
    model.root.input.model.unit_001.particle_type_000.par_radius = 50.0/2.0*1e-6                              # m
    model.root.input.model.unit_001.npartype=1  
                                                                             
    ## Transport          
    model.root.input.model.unit_001.col_dispersion = 6e-8                                   # m^2 / s (interstitial volume)
    model.root.input.model.unit_001.particle_type_000.has_film_diffusion = True
    model.root.input.model.unit_001.particle_type_000.film_diffusion = np.array([1e-05, 1e-05, 1e-05, 1e-05, 1e-05, ], dtype=np.float64)            # m / s
    model.root.input.model.unit_001.particle_type_000.has_pore_diffusion = True
    model.root.input.model.unit_001.particle_type_000.pore_diffusion = np.array([7e-10, *Dps], dtype=np.float64)
    model.root.input.model.unit_001.particle_type_000.has_surface_diffusion = False

    ## Isotherm model
    model.root.input.model.unit_001.particle_type_000.adsorption_model = 'AFFINITY_COMPLEX_TITRATION'
    model.root.input.model.unit_001.particle_type_000.nbound = np.array([0, 1, 1, 1, 1])
    model.root.input.model.unit_001.particle_type_000.adsorption.act_use_ion_conc = True
    model.root.input.model.unit_001.particle_type_000.adsorption.is_kinetic = 1

    model.root.input.model.unit_001.particle_type_000.adsorption.act_ka = [1.0,
                                                                           Keq[0]*protein_MW[0]*(1.0-total_porosity),
                                                                           Keq[1]*protein_MW[1]*(1.0-total_porosity),
                                                                           Keq[2]*protein_MW[2]*(1.0-total_porosity),
                                                                           Keq[3]*protein_MW[3]*(1.0-total_porosity), ]                ## ml liq/mg * mg/mmol * mL resin/mL liq = ml resin/mmol (mM-1)  
   
    model.root.input.model.unit_001.particle_type_000.adsorption.act_kd = [1.0, 1.0, 1.0, 1.0, 1.0, ]                                            
   
    model.root.input.model.unit_001.particle_type_000.adsorption.act_qmax = [1e-10,
                                                                             Q_max[0]/protein_MW[0]/(1.0-total_porosity),
                                                                             Q_max[1]/protein_MW[1]/(1.0-total_porosity),
                                                                             Q_max[2]/protein_MW[2]/(1.0-total_porosity),
                                                                             Q_max[3]/protein_MW[3]/(1.0-total_porosity),]            ## mmol/ml resin (mM)
   
    model.root.input.model.unit_001.particle_type_000.adsorption.act_etaa = np.array([0.0, *etaA], dtype=np.float64)
    model.root.input.model.unit_001.particle_type_000.adsorption.act_etag = np.array([0.0, *etaG], dtype=np.float64)

    model.root.input.model.unit_001.particle_type_000.adsorption.act_cmid_a = np.array([0.0, *cmidA], dtype=np.float64)              ## in mM
    model.root.input.model.unit_001.particle_type_000.adsorption.act_cmid_g = np.array([0.0, *cmidG], dtype=np.float64)              ## in mM
   
    model.root.input.model.unit_001.init_c =  [start_salt_conc, 0, 0, 0, 0, ]
    model.root.input.model.unit_001.init_cp = [start_salt_conc, 0, 0, 0, 0, ]
   
    ## FVM discretization
    ### Grid cells
    model.root.input.model.unit_001.discretization.spatial_method = "FV"
    model.root.input.model.unit_001.discretization.reconstruction = np.bytes_(b'WENO')
    model.root.input.model.unit_001.discretization.ncol = np.int64(40)
    model.root.input.model.unit_001.discretization.weno.weno_order = np.int64(3)

    model.root.input.model.unit_001.particle_type_000.discretization.spatial_method = "FV"
    model.root.input.model.unit_001.particle_type_000.discretization.ncells = np.int64(20)
    model.root.input.model.unit_001.particle_type_000.discretization.par_disc_type = np.bytes_(b'EQUIDISTANT_PAR')
   
    ### Other options  
    model.root.input.model.unit_001.discretization.use_analytic_jacobian = 1
    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.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
   
    # Number of cores for parallel simulation
    model.root.input.solver.nthreads = 1
   
    # Tolerances for the time integrator
    model.root.input.solver.time_integrator.abstol = 1e-08
    model.root.input.solver.time_integrator.algtol = 1e-12
    model.root.input.solver.time_integrator.init_step_size = 1e-12
    model.root.input.solver.time_integrator.max_convtest_fail = 50
    model.root.input.solver.time_integrator.max_errtest_fail = 7
    model.root.input.solver.time_integrator.max_steps = 1000000
    model.root.input.solver.time_integrator.reltol = 1e-08
   
    ## outlet
    model.root.input.model.unit_002.unit_type = 'OUTLET'
    model.root.input.model.unit_002.ncomp = 1+n_comp
   
    ## time
    model.root.input.solver.sections.nsec = 4
   
    # load, wash, elution, hold
    lag_time = 1300
    model.root.input.solver.sections.section_times = [0, RT*loading/load_conc, grad_start_time, grad_end_time, grad_end_time+lag_time, ]   # s
    model.root.input.solver.sections.section_continuity = [0, 0, 0, 0,]
   
    ## load phase
    model.root.input.model.unit_000.sec_000.const_coeff = [start_salt_conc,
                                                           load_conc * load_ratio[0] /protein_MW[0],
                                                           load_conc * load_ratio[1] /protein_MW[1],
                                                           load_conc * load_ratio[2] /protein_MW[2],
                                                           load_conc * load_ratio[3] /protein_MW[3], ]        # pksalt and M:  mg/mL * mmol/mg = mmol/ml
    model.root.input.model.unit_000.sec_000.lin_coeff =  5*[0.0,]
    model.root.input.model.unit_000.sec_000.quad_coeff = 5*[0.0,]
    model.root.input.model.unit_000.sec_000.cube_coeff = 5*[0.0,]
   
    ## wash phase
    model.root.input.model.unit_000.sec_001.const_coeff = [start_salt_conc, 0.0, 0.0, 0.0, 0.0, ]                        # mol / m^3
    model.root.input.model.unit_000.sec_001.lin_coeff =  5*[0.0,]
    model.root.input.model.unit_000.sec_001.quad_coeff = 5*[0.0,]
    model.root.input.model.unit_000.sec_001.cube_coeff = 5*[0.0,]
   
    ## elution phase
    model.root.input.model.unit_000.sec_002.const_coeff = [start_salt_conc, 0.0, 0.0, 0.0, 0.0, ]                        # mol / m^3
    model.root.input.model.unit_000.sec_002.lin_coeff =  [(end_salt_conc - start_salt_conc) / (grad_end_time - grad_start_time), 0.0, 0.0, 0.0, 0.0, ]
    model.root.input.model.unit_000.sec_002.quad_coeff = 5*[0.0,]
    model.root.input.model.unit_000.sec_002.cube_coeff = 5*[0.0,]
   
    ## hold phase
    model.root.input.model.unit_000.sec_003.const_coeff = [end_salt_conc, 0, 0, 0, 0, ]                                # mol / m^3
    model.root.input.model.unit_000.sec_003.lin_coeff =  5*[0.0,]
    model.root.input.model.unit_000.sec_003.quad_coeff = 5*[0.0,]
    model.root.input.model.unit_000.sec_003.cube_coeff = 5*[0.0,]
   
    # 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                                                # off, spatial concentration distributions
    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, grad_end_time+lag_time, 500)
   
    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,                                             # [unit_000, unit_001, all components, all components, Q/ m^3*s^-1
        1, 2, -1, -1, Q, ]                                           # [unit_001, unit_002, all components, all components, Q/ m^3*s^-1
   
   
    model.filename = filename
    model.save()
   
    data = model.run()
    #print(data)
    model.load()
   
    time = model.root.output.solution.solution_times
    volume = time * Q * 1e6                                          # mL
    salt = model.root.output.solution.unit_001.solution_outlet_comp_000
   
    HMW13 = model.root.output.solution.unit_001.solution_outlet_comp_001 * protein_MW[0]
    HMW2 = model.root.output.solution.unit_001.solution_outlet_comp_002 * protein_MW[1]
    monomer = model.root.output.solution.unit_001.solution_outlet_comp_003 * protein_MW[2]
    LMW12 = model.root.output.solution.unit_001.solution_outlet_comp_004 * protein_MW[3]

    return volume, salt, HMW13, HMW2, monomer, LMW12

## HMW13, HMW2, monomer, LMW12
x = np.asarray([ 7.07,   8.97,   12.28,   5.40,             ## etaA
               460.53, 400.50,  260.62, 230.19,             ## cmidA
                15.27,   20.64,  31.82,  16.64,             ## etaG
               186.84,  240.46, 255.74, 170.96,             ## cmidG
                -27.7,   -27.3,  -26.9,  -26.6, ])          ## log(Dp)

loaded_mass = [np.float64(2.9554704554706563),
               np.float64(2.757192885678953),
               np.float64(53.12209550021964),
               np.float64(10.436484908630728)]

volume, salt, HMW13, HMW2, monomer, LMW12 = run_cadet(x, 54, 1038, 2373, 9647, loaded_mass, 'model.h5')

fig, axes = plt.subplots(1, 1, figsize=(12, 6))

# Primary y-axis: protein species
axes.plot(volume, monomer, color='red', label='Monomer', alpha=0.6)
axes.plot(volume, HMW13,   color='green', label='HMW13',   alpha=0.6)
axes.plot(volume, HMW2,    color='orange', label='HMW2',    alpha=0.6)
axes.plot(volume, LMW12,   color='purple', label='LMW12',   alpha=0.6)

axes.set_ylabel('Concentration (mg/mL)')
axes.set_title('Multi-component, linear salt gradient using gACT isotherm')
axes.legend(loc='upper left')
axes.grid(True, alpha=0.3)
axes.set_xlabel('Volume (mL)')

# Secondary y-axis: salt
axes2 = axes.twinx()
axes2.plot(volume, salt, color='blue', label='Na+ (salt)', alpha=0.6)
axes2.set_ylabel('Concentration (mM)')
axes2.legend(loc='center left')

plt.show()

The code will be part of the v6.0.0a4 pre-release, making a source build obsolete in the near future.