Implementing Section Dependent Isotherms

Expected behavior.

I’m pretty new to using CADET but have been pouring over the forum and CADET-workshop content to figure it out.

I’m trying to model a LN Series Resin Cartridge column used for a published adsorption chromatography method to separate rare earth elements for analytical quantitation. The method utilizes nitric acid eluent concentration steps to affect the binding isotherms to achieve feasible and rapid separations. I understand that to implement section dependent isotherms I need to employ external functions (EXTFUN) to facilitate the changing binding model parameters.

Actual behavior

The results I’m obtaining don’t behave as one would expect. I’m hoping I can get corrections or some pointers from others here. Is my python properly specifying time sections, EXTFUN’s, and binding parameters, etc.? My specific questions are below.

How to produce bug (including a minimal reproducible example)

Below I show my implementation of using External Functions to get section-dependent isotherms.

File produced by conda env export > environment.yml

environment.yml (8.35 KB)

The general procedure I’ve employed is:

  1. Create a no-EXTFUN CADET-process flowsheet object using CADET-process
  2. Create the CADET-process process object using the resulting flowsheet and chromatography method information
    1. The resulting process object now has “events” and “sections”
    2. I’ve turned on the solution recorder for the column particles and solid.
  3. Save the process to h5 and then reload it to obtain the CADET object with a mostly formed model.root
  4. Modify the appropriate model.root features to implement EXTFUNs.
  5. Solve the simulation: model.run()
  6. Reload from the h5 file to populate model.root.output.solution
  7. Plot the result
  8. Calculated mobile phase recoveries.

This is my python implementation:

#!~/.conda/envs/cadet/bin/python

import numpy as np
import os
import matplotlib.pyplot as plt
import scipy.integrate as integrate

from CADETProcess.processModel import ComponentSystem
from CADETProcess.processModel import Langmuir
from CADETProcess.processModel import Inlet, LumpedRateModelWithPores, Outlet
from CADETProcess.processModel import FlowSheet
from CADETProcess.processModel import Process
from CADETProcess.simulator import Cadet

# Derived Constants
CADET_CLI_PATH = os.path.join(os.path.abspath('.'), 'CADET/install')
assert os.path.exists(CADET_CLI_PATH), f"CADET_CLI_PATH doesn't exist: {CADET_CLI_PATH}"
WORKING_FOLDER = os.path.join(os.path.abspath('.'), 'ln-1_model/forum_work/post')

# A few variables to simplify variations on the theme I wanted to be able to generate.
load = 150.                     # Used 150 and 10
column_length_multiplier = 1    # Used 1 and 3
run_length_multiplier = 1       # Used 1 and 5

# Simplified Binding Isotherms:
k_prime = {
    'La': {'constant': 0.05641268710558851, 'slope': -2.620012417528447},
    'Nd': {'constant': 0.16512886791167147, 'slope': -2.6425049824718645},
    'Eu': {'constant': 0.6698847949281866, 'slope': -3.2578698806430073},
}
def k_prime_func(element: str, M_HNO3: float) -> float:
    """Returns retention factor (k') for LN Resin as a function of [HNO3] in M.

    Args:
        element (str): 2 letter symbol for element
        M_HNO3 (float): Concentration of Nitric Acid [HNO3] in molarity (M)

    Returns:
        float: k' or Retention factor (formerly known as capacity factor) of the material
               on LN Resin (HDEHP stationary phase) as a function of [HNO3] in M.
    """
    return max(1., round(k_prime[element]['constant'] * (M_HNO3 ** k_prime[element]['slope']),5))

# Process Information - Used to create process events later
load_comp = [load, load, load]          # mMol/L = mol/m^3
initial_flowrate = 7/5/60/1e6           # 7mL / 5min / 60sec/min / 1e6mL/m^3 = m^3/s
load_flowrate = initial_flowrate
# load_profile: ([HNO3] in M, vol in ml, step name)
load_profile = [
    (0.01, 0.99, "load"),
    (0.01, 2, "rinse1"),
    (0.01, 2, "rinse2"),
    (0.01, 2, "rinse3"),
]                                       # (diluent acid conc [M], vol [mL], name)
elute_flowrate = 5/40/60/1e6            # 5mL / 40min / 60sec/min / 1e6mL/m^3 = m^3/s
# Gradient steps: ([HNO3] in M, vol in ml, flowrate in m^3/s)
acid_profile = [
    (0.01, 5, elute_flowrate),
    (0.5, 5, elute_flowrate)
]                                       # (acid conc (M), vol (mL), flow rate (m^3/s))
# Create a list of each section's HNO3 molarity:
section_acid_concentrations = [step[0] for step in load_profile] + [step[0] for step in acid_profile]

# Initial CADET-process Simulation Specification
# Component System
component_system = ComponentSystem(['La', 'Nd', 'Eu'])

# Binding Model
binding_model = Langmuir(component_system, name='langmuir')
binding_model.is_kinetic = False

# The column is conditioned with 0.01 M HNO3.
binding_model.adsorption_rate = [k_prime_func(component.name, 0.01)*1.75 for component in component_system]
binding_model.desorption_rate = [1] * component_system.n_comp
binding_model.capacity = [160] * component_system.n_comp        # mol/m^3
# Verify that the binding model has all required parameters:
assert binding_model.check_required_parameters()

# Unit Operations
inlet = Inlet(component_system, name='inlet')
inlet.flow_rate = initial_flowrate

# Column based on LumpedRateModelWithPores
column = LumpedRateModelWithPores(component_system, 'column')
column.binding_model = binding_model
column.length = 0.0408 * column_length_multiplier      # m = 4.08 cm times multiplier
column.diameter = 0.008             # m = 0.8 cm Dia
column.bed_porosity = 0.67          # 0 to 1 - Porosity of the bed
column.particle_radius = 7.5e-5     # m = 75 microns bead size
column.particle_porosity = 0.16     # 0 to 1 - Porosity of particles
column.axial_dispersion = 2.0e-7    # Dispersion rate of components in axial direction
column.film_diffusion = 1.0e-5      # Diffusion rate for components in pore volume. m/s

outlet = Outlet(component_system, name='outlet')

# Flow Sheet
flow_sheet = FlowSheet(component_system)
flow_sheet.add_unit(inlet)
flow_sheet.add_unit(column)
flow_sheet.add_unit(outlet)
flow_sheet.add_connection(inlet, column)
flow_sheet.add_connection(column, outlet)

# Record the column particle solution.
column.solution_recorder.write_solution_particle=True
column.solution_recorder.write_solution_bulk=True
column.solution_recorder.write_solution_solid=True

# Define CADET Process Events
process = Process(flow_sheet, 'LN-1')
# Load and Triple Rinse:
t = 0
conc = load_comp
process.add_event(f"Start", 'flow_sheet.inlet.flow_rate', initial_flowrate, t)
for event in load_profile:
    _, vol, name = event
    conc = conc if name == 'load' else [conc[i] * 0.01 / vol for i in range(component_system.n_comp)]  # 1% left in container diluted in vol ml
    process.add_event(name, 'flow_sheet.inlet.c', conc, t)
    t += vol*1e-6/initial_flowrate
# Gradient
flowrate = elute_flowrate
process.add_event("elute_start", 'flow_sheet.inlet.flow_rate', flowrate, t)
n = 0
# Loop over acid concentration steps:
for event in acid_profile:
    if event[2] != flowrate:
        n += 1
        flowrate = event[2]
        process.add_event(f"flow_adj_{n}", 'flow_sheet.inlet.flow_rate', flowrate, t)
    process.add_event(f"acid_conc_{str(event[0]).replace('.', '_')}", 'flow_sheet.inlet.c', [0] * component_system.n_components, t)
    t += event[1]*1e-6/flowrate
process.cycle_time = t * run_length_multiplier

# Transfer Process to a CADET Core Object
process_simulator = Cadet(CADET_CLI_PATH)
# Delete and recreate the saved, unsolved model in this h5 file:
h5_filepath = os.path.join(WORKING_FOLDER,'ln_load_elute_in_2_steps.h5')
if os.path.exists(h5_filepath):
    os.remove(h5_filepath)
process_simulator.save_to_h5(process=process, file_path=h5_filepath)
model = Cadet(CADET_CLI_PATH)
model = model.load_from_h5(h5_filepath)

# Replace the Binind Model with EXTFUNs
## external adsorption isotherm
model.root.input.model.unit_001.adsorption_model = b'EXT_MULTI_COMPONENT_LANGMUIR'

# Remove the existing Multi_Component_Langmuir info from the model.
del model.root.input.model.unit_001.adsorption

# Replace it with the EXTFUN Multi_Component_Langmuir model
model.root.input.model.unit_001.adsorption.adsorption_model = b'EXT_MULTI_COMPONENT_LANGMUIR'
# Specify which EXTernal Source each EXTFUN Binding Model parameter points to:
model.root.input.model.unit_001.adsorption.extfun = np.array([0, 1, 1])

model.root.input.model.unit_001.adsorption.is_kinetic = False

model.root.input.model.unit_001.adsorption.ext_mcl_ka =     1
model.root.input.model.unit_001.adsorption.ext_mcl_ka_t =   0
model.root.input.model.unit_001.adsorption.ext_mcl_ka_tt =  0
model.root.input.model.unit_001.adsorption.ext_mcl_ka_ttt = 0

model.root.input.model.unit_001.adsorption.ext_mcl_kd =     1
model.root.input.model.unit_001.adsorption.ext_mcl_kd_t =   0
model.root.input.model.unit_001.adsorption.ext_mcl_kd_tt =  0
model.root.input.model.unit_001.adsorption.ext_mcl_kd_ttt = 0
    
model.root.input.model.unit_001.adsorption.ext_mcl_qmax =     160
model.root.input.model.unit_001.adsorption.ext_mcl_qmax_t =   0
model.root.input.model.unit_001.adsorption.ext_mcl_qmax_tt =  0
model.root.input.model.unit_001.adsorption.ext_mcl_qmax_ttt = 0

n_sections = len(model.root.input.solver.sections.section_times) - 1
## external source 0: Returns np.ndarray of K_a's in each section.
model.root.input.model.external.source_000.extfun_type = 'PIECEWISE_CUBIC_POLY'
model.root.input.model.external.source_000.section_times = model.root.input.solver.sections.section_times
# model.root.input.model.external.source_000.velocity =    1
model.root.input.model.external.source_000.const_coeff = [[k_prime_func(component, m_acid)*1.75 for component in component_system.names] for m_acid in section_acid_concentrations]
model.root.input.model.external.source_000.lin_coeff =   [0.0] * n_sections
model.root.input.model.external.source_000.quad_coeff =  [0.0] * n_sections
model.root.input.model.external.source_000.cube_coeff =  [0.0] * n_sections

## external source 1: EXTFUN(param, t) = 1.0 * param
model.root.input.model.external.source_001.extfun_type = 'PIECEWISE_CUBIC_POLY'
model.root.input.model.external.source_001.section_times = model.root.input.solver.sections.section_times
# model.root.input.model.external.source_001.velocity =    1
model.root.input.model.external.source_001.const_coeff = [1.0] * n_sections
model.root.input.model.external.source_001.lin_coeff =   [0.0] * n_sections
model.root.input.model.external.source_001.quad_coeff =  [0.0] * n_sections
model.root.input.model.external.source_001.cube_coeff =  [0.0] * n_sections

simulation_end_time = int(model.root.input.solver.sections.section_times[-1].item())

# Adjust number of threads the solver can use if desired:
model.root.input.solver.nthreads = 5

# Solve the model
model.run()

# Plot the Results
model.load()
time = model.root.output.solution.solution_times/60
c = model.root.output.solution.unit_001.solution_outlet_port_000

fig = plt.figure()
ax = fig.add_subplot()
ax.plot(time, c)
ax.set_xlabel(r'time / min')
ax.set_ylabel(r'c / mM')
ax.legend(component_system.names)
plt.title(f"Load [component]={load_comp[0]} mM, Col_length={column.length*100:.2f} cm")
plt.axvline(column.calculate_interstitial_rt(initial_flowrate)/60, color='k', lw=1)
plt.text(column.calculate_interstitial_rt(initial_flowrate)/60, 0, "Residence time", rotation="vertical", horizontalalignment="right", fontsize=12)
plt.axvline(process.section_times[5]/60, color='k', lw=1)
plt.text(process.section_times[5]/60, 0, "0.5M Start", rotation="vertical", horizontalalignment="right", fontsize=12)
plt.axvline(t/60, color='k', lw=1)
plt.text(t/60, 0, "Normal end", rotation="vertical", horizontalalignment="right",fontsize=12)
# Plot zoom settings:
# plt.xlim(0, 8)
# plt.ylim(0, 0.5e-10)
fig.tight_layout()
plt.savefig(os.path.join(WORKING_FOLDER,"ln-1_simple.png"))

# Calculate component yields
# NOTE: currently only accounts for the mobile phase.
# Construct flowrate array (fr): the flowrate at each time element.
idx_ifr = np.argwhere(time > process.events[5].time/60)[0].item()
fr = np.ones([idx_ifr])*initial_flowrate
fr = np.append(fr, np.ones([time.shape[0]-idx_ifr])*elute_flowrate)
# How much goes in:
amt_in = {}
for component in range(3):
    amt_in[component] = integrate.simpson(y=np.multiply(model.root.output.solution.unit_001.solution_inlet_port_000[:, component], fr), x=time*60)*1E6
amt_in
# How much goes out:
amt_out = {}
for component in range(3):
    amt_out[component] = integrate.simpson(y=np.multiply(model.root.output.solution.unit_001.solution_outlet_port_000[:, component], fr), x=time*60)*1e6
    print(f"Recovery[{component_system[component].name}] = {100*amt_out[component]/amt_in[component]:.2f}%")

print("Column-bound component concentrations:")
print(model.root.output.solution.unit_001.solution_solid[-1,:,:])

My Questions/Problems:

  1. Using a load that saturates the column (each component at 150 mM) I get breakthrough in ~60 seconds (column residence time) as expected but no later peaks even if I extend the run a “long time”. I expect to get at least an Eu peak later in the chromatogram.


Furthermore, integrating inputs and outputs, I’ve calculated recovery for each component:

Recovery_i ={{\int_{0}^{end} c_{i,out}^l \cdot {flowrate}\ dt} \over {\int_{0}^{end} c_{i,in}^l \cdot {flowrate}\ dt}}

Recovery[La] = 98.90
Recovery[Nd] = 98.32
Recovery[Eu] = 40.16

Also, model.root.output.solution.unit_001.solution_solid[-1,:,2] shows high Eu compositions throughout the column at the last time step.

So, roughly half the Eu is still bound to the column though it should have eluted at the 0.5M acid step. These details seem to suggest the new binding constants never manifests in the calculations. How do I modify my python script to properly implement my section dependent isotherms?

  1. Using a realistic load (each component at 10 mM), I still get breakthrough in ~60 seconds, no separation happens, no later peaks, recovery is 0% for all components and seem to be bound to the column. Real fractions with components are expected like 45 minutes into the run around the 0.5M acid step starts.

  2. Is my implementation of EXTFUNs appropriate to be using time section-dependent isotherms (see background below)? Are there ways to debug/verify this given that, I think, EXTFUNs are all happening within the CADET-Core C++ compiled artifacts. (I use VSCode extensively but only really know python.)

In case this background/context is helpful:

Resin retention factors (k’) for LN resins are a function of acid concentration in the eluent as described by:

Philip Horwitz, E., Daniel R. McAlister, and Mark L. Dietz. “Extraction Chromatography Versus Solvent Extraction: How Similar Are They?” Separation Science and Technology 41, no. 10 (June 1, 2006): 2163–82. https://doi.org/10.1080/01496390600742849.

A minimized subset of lanthanides’ k’ curves are being estimated using a simple function derived from that work. (see def k_prime_func in code.)

I’m using the multi-component Langmuir binding model in a LumpedRateModelWithPores column. I’m starting by assuming the binding mode is quasi-stationary (so IS_KINETIC=0 and k_d = 1. and k_a=k_{eq}=D_w for each component). My real chromatography method has many more components and acid molarity steps, but I’ve simplified the model down to 3 components and 2 acid molarity steps to work out the CADET simulation methodology.

I’m using:

  • CADET-Core v5.0.3 with fixes Jan Breuer made to enable threading, built from source
  • CADET-Python v1.0.4
  • CADET-Process 0.10.0
  • on a Macbook Pro with the Apple M3 Max chip running Sonoma 14.7.3

Hi there!

I haven’t yet dug into the code you shared, but it just occurs to me that have you tried the other option: break up your simulation (A) into smaller simulations (A → B+C) according to the time sections you have. By doing so one can choose/configure the isotherms in these smaller simulations one more time. Overall it will make the entire simulation look as if section dependent isotherms are used. One thing to pay attention to in this case is to pass the state vectors in a previous simulation to the next one. Please see this thread for more information (although the code is written in cadet-python rather than cadet-process).

Best,
Flynn

1 Like

Thank you, Flynn, for your reply!
Sorry for the delayed reply. I started working on your suggestion and then got sick. Anyway…

I think this code block follows at least the spirit if what you showed in the other thread. However, consistent with my initial post above it uses cadet-process throughout.

#!~/.conda/envs/cadet/bin/python

import numpy as np
import os
import matplotlib.pyplot as plt
import scipy.integrate as integrate

from CADETProcess.processModel import ComponentSystem
from CADETProcess.processModel import Langmuir
from CADETProcess.processModel import Inlet, LumpedRateModelWithPores, Outlet
from CADETProcess.processModel import FlowSheet
from CADETProcess.processModel import Process
from CADETProcess.simulator import Cadet

# Derived Constants
CADET_CLI_PATH = os.path.join(os.path.abspath('.'), 'CADET/install')
assert os.path.exists(CADET_CLI_PATH), f"CADET_CLI_PATH doesn't exist: {CADET_CLI_PATH}"
WORKING_FOLDER = os.path.join(os.path.abspath('.'), 'ln-1_model/forum_work/post')

# A few variables to simplify variations on the theme I wanted to be able to generate.
load = 150.                     # Used 150 and 10
column_length_multiplier = 1    # Used 1 and 3
run_length_multiplier = 1       # Used 1 and 5

# Simplified Binding Isotherms:
k_prime = {
    'La': {'constant': 0.05641268710558851, 'slope': -2.620012417528447},
    'Nd': {'constant': 0.16512886791167147, 'slope': -2.6425049824718645},
    'Eu': {'constant': 0.6698847949281866, 'slope': -3.2578698806430073},
}
def k_prime_func(element: str, M_HNO3: float) -> float:
    """Returns retention factor (k') for LN Resin as a function of [HNO3] in M.

    Args:
        element (str): 2 letter symbol for element
        M_HNO3 (float): Concentration of Nitric Acid [HNO3] in molarity (M)

    Returns:
        float: k' or Retention factor (formerly known as capacity factor) of the material
               on LN Resin (HDEHP stationary phase) as a function of [HNO3] in M.
    """
    return max(1., round(k_prime[element]['constant'] * (M_HNO3 ** k_prime[element]['slope']),5))

# Process Information - Used to create process events later
load_comp = [load, load, load]          # mMol/L = mol/m^3
initial_flowrate = 7/5/60/1e6           # 7mL / 5min / 60sec/min / 1e6mL/m^3 = m^3/s
load_flowrate = initial_flowrate
# load_profile: ([HNO3] in M, vol in ml, step name)
load_profile = [
    (0.01, 0.99, "load"),
    (0.01, 2, "rinse1"),
    (0.01, 2, "rinse2"),
    (0.01, 2, "rinse3"),
]                                       # (diluent acid conc [M], vol [mL], name)
elute_flowrate = 5/40/60/1e6            # 5mL / 40min / 60sec/min / 1e6mL/m^3 = m^3/s
# Gradient steps: ([HNO3] in M, vol in ml, flowrate in m^3/s)
acid_profile = [
    (0.01, 5, elute_flowrate),
    (0.5, 5, elute_flowrate)
]                                       # (acid conc (M), vol (mL), flow rate (m^3/s))

# All the above is the same as my original post. Below are changes to implement sequential solve
# using CADET Process and https://forum.cadet-web.de/t/section-dependent-isotherms-and-alternative-approaches/772/12

def create_flowsheet(keq):
    # Initial CADET-process Simulation Specification
    # Component System
    component_system = ComponentSystem(['La', 'Nd', 'Eu'])

    # Binding Model
    binding_model = Langmuir(component_system, name='langmuir')
    binding_model.is_kinetic = False

    # Set binding model parameters
    binding_model.adsorption_rate = keq
    binding_model.desorption_rate = [1] * component_system.n_comp
    binding_model.capacity = [160] * component_system.n_comp        # mol/m^3
    # Verify that the binding model has all required parameters:
    assert binding_model.check_required_parameters()

    # Unit Operations
    inlet = Inlet(component_system, name='inlet')
    inlet.flow_rate = initial_flowrate

    # Column based on LumpedRateModelWithPores
    column = LumpedRateModelWithPores(component_system, 'column')
    column.binding_model = binding_model
    column.length = 0.0408 * column_length_multiplier      # m = 4.08 cm times multiplier
    column.diameter = 0.008             # m = 0.8 cm Dia
    column.bed_porosity = 0.67          # 0 to 1 - Porosity of the bed
    column.particle_radius = 7.5e-5     # m = 75 microns bead size
    column.particle_porosity = 0.16     # 0 to 1 - Porosity of particles
    column.axial_dispersion = 2.0e-7    # Dispersion rate of components in axial direction
    column.film_diffusion = 1.0e-5      # Diffusion rate for components in pore volume. m/s

    outlet = Outlet(component_system, name='outlet')

    # Flow Sheet
    flow_sheet = FlowSheet(component_system)
    flow_sheet.add_unit(inlet)
    flow_sheet.add_unit(column)
    flow_sheet.add_unit(outlet)
    flow_sheet.add_connection(inlet, column)
    flow_sheet.add_connection(column, outlet)

    # Record the column particle solution.
    column.solution_recorder.write_solution_particle=True
    column.solution_recorder.write_solution_bulk=True
    column.solution_recorder.write_solution_solid=True

    return flow_sheet

#### Part 1 Simulation
flow_sheet = create_flowsheet([k_prime_func(component, 0.01)*1.75 for component in ['La', 'Nd', 'Eu']])
# Define CADET Process Events
process = Process(flow_sheet, 'LN-1')
# Load and Triple Rinse:
t = 0
conc = load_comp
process.add_event(f"Start", 'flow_sheet.inlet.flow_rate', initial_flowrate, t)
for event in load_profile:
    _, vol, name = event
    conc = conc if name == 'load' else [conc[i] * 0.01 / vol for i in range(flow_sheet.component_system.n_comp)]  # 1% left in container diluted in vol ml
    process.add_event(name, 'flow_sheet.inlet.c', conc, t)
    t += vol*1e-6/initial_flowrate
# Gradient
flowrate = elute_flowrate
process.add_event("elute_start", 'flow_sheet.inlet.flow_rate', flowrate, t)
n = 0
# Section 1 includes only the 1st acid profile step (same acid concentration as column was conditioned with):
event = acid_profile[0]
if event[2] != flowrate:
    n += 1
    flowrate = event[2]
    process.add_event(f"flow_adj_{n}", 'flow_sheet.inlet.flow_rate', flowrate, t)
process.add_event(f"acid_conc_{str(event[0]).replace('.', '_')}", 'flow_sheet.inlet.c', [0] * flow_sheet.component_system.n_components, t)
t += event[1]*1e-6/flowrate
process.cycle_time = t

# Transfer Process to a CADET Core Object
process_simulator = Cadet(CADET_CLI_PATH)
# Delete and recreate the saved, unsolved model in this h5 file:
h5_filepath = os.path.join(WORKING_FOLDER,'ln_load_elute_in_2_steps.h5')
if os.path.exists(h5_filepath):
    os.remove(h5_filepath)
process_simulator.save_to_h5(process=process, file_path=h5_filepath)
model = Cadet(CADET_CLI_PATH)
model = model.load_from_h5(h5_filepath)

# Set up data return:
# Return data
model.root.input['return'].split_components_data = True
# model.root.input['return'].split_ports_data = 0
model.root.input['return'].unit_000.write_solution_outlet = True
model.root.input['return'].write_solution_last = True

# 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

# Adjust number of threads the solver can use if desired:
model.root.input.solver.nthreads = 5

# Solve the model
model.run()

# Capture states and solution
model.load()
agg_soln = model.root.output.solution.copy()
init_s = model.root.output.last_state_y
init_sdot = model.root.output.last_state_ydot

# Plot Section 1 results
time = model.root.output.solution.solution_times/60
c = model.root.output.solution.unit_001.solution_outlet_port_000
fig = plt.figure()
ax = fig.add_subplot()
ax.plot(time, c)
ax.set_xlabel(r'time / min')
ax.set_ylabel(r'c / mM')
ax.legend(flow_sheet.component_system.names)
plt.title(f"Load [component]={load_comp[0]} mM, Col_length={flow_sheet.column.length*100:.2f} cm")
plt.axvline(flow_sheet.column.calculate_interstitial_rt(initial_flowrate)/60, color='k', lw=1)
plt.text(flow_sheet.column.calculate_interstitial_rt(initial_flowrate)/60, 0, "Residence time", rotation="vertical", horizontalalignment="right", fontsize=12)
plt.axvline(t/60, color='k', lw=1)
plt.text(t/60, 0, "Section 1 end", rotation="vertical", horizontalalignment="right",fontsize=12)
# Plot zoom settings:
# plt.xlim(0, 4)
# plt.ylim(0, 0.5e-10)
fig.tight_layout()
plt.savefig(os.path.join(WORKING_FOLDER,"section1.png"))

#### Part 2 Simulation
event = acid_profile[1]
# Instantiate new flowsheet with different binding parameter (keq)
flow_sheet = create_flowsheet([k_prime_func(component.name, event[0])*1.75 for component in flow_sheet.component_system])
process = Process(flow_sheet, 'LN-1')
t=0
flowrate = event[2]
process.add_event(f"flow_adj_{n}", 'flow_sheet.inlet.flow_rate', flowrate, t)
process.add_event(f"acid_conc_{str(event[0]).replace('.', '_')}", 'flow_sheet.inlet.c', [0] * flow_sheet.component_system.n_components, t)
t += event[1]*1e-6/flowrate
process.cycle_time = t * run_length_multiplier

# Transfer Process to a CADET Core Object
process_simulator = Cadet(CADET_CLI_PATH)
# Delete and recreate the saved, unsolved model in this h5 file:
h5_filepath = os.path.join(WORKING_FOLDER,'ln_load_elute_in_2_steps.h5')
if os.path.exists(h5_filepath):
    os.remove(h5_filepath)
process_simulator.save_to_h5(process=process, file_path=h5_filepath)
model = Cadet(CADET_CLI_PATH)
model = model.load_from_h5(h5_filepath)

# Set up data return:
# Return data
model.root.input['return'].split_components_data = True
# model.root.input['return'].split_ports_data = 0
model.root.input['return'].unit_000.write_solution_outlet = True
model.root.input['return'].write_solution_last = True

# 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

# Reset initial solution state to previous state
model.root.input.model.init_state_y = init_s
model.root.input.model.init_state_ydot = init_sdot

# Solve the model
model.run()
model.load()

# Plot Section 2 Results
time = model.root.output.solution.solution_times/60
c = model.root.output.solution.unit_001.solution_outlet_port_000
fig = plt.figure()
ax = fig.add_subplot()
ax.plot(time, c)
ax.set_xlabel(r'time / min')
ax.set_ylabel(r'c / mM')
ax.legend(flow_sheet.component_system.names)
plt.title(f"Second Sequential Section")
plt.axvline(0, color='k', lw=1)
plt.text(0, 0, "0.5M Start", rotation="vertical", horizontalalignment="right", fontsize=12)
plt.axvline(flow_sheet.column.calculate_interstitial_rt(initial_flowrate)/60, color='k', lw=1)
plt.text(flow_sheet.column.calculate_interstitial_rt(initial_flowrate)/60, 0, "Residence time", rotation="vertical", horizontalalignment="right", fontsize=12)
plt.axvline(t/60, color='k', lw=1)
plt.text(t/60, 0, "Normal end", rotation="vertical", horizontalalignment="right",fontsize=12)
# Plot zoom settings:
# plt.xlim(0, 4)
# plt.ylim(0, 0.05)
fig.tight_layout()
plt.savefig(os.path.join(WORKING_FOLDER,"section2.png"))

As you can see, it eliminates the use of EXTFUNs all together since the binding model parameters are specified in the flowsheet creation.

Unfortunately, this gives identical results (though I think for different reasons):


The solution of the first section after the load and triple rinse is the same as in my original post. I just didn’t embed the zoomed in plot or extend the run length.

The solution of the second section gives a totally flat chromatogram.
Although I capture and transfer model.root.output.last_state_y and model.root.output.last_state_ydot from the first section, the shape of these two features [(1215,)] are only a single dimension. I don’t think that captures the composition of all 3 components at each column coordinate, does it? Thus, the chromatogram is flat because nothing is “loaded” on the column in the second section. I wasn’t sure how to resolve that. I obviously can locate the column composition data from the first section as shown in my original post, but I wasn’t sure how to instantiate/transfer the second section with it.

The other question this raises is that this sequential solution method will cause the entire column binding parameters to instantaneously change for the second time section from its beginning. This is in contrast to the EXTFUN approach that would transition the new parameters through the column using the velocity. I’m guessing this is why the velocity approach was originally implemented.

Hello Sean,

thanks again for stopping by the Office Hours yesterday! I’ve had some time to mull things over and have two working solutions. Well, at least I hope I do.


First solution:

using the Mobile Phase Modulator Langmuir Isotherm.

It uses this equation:
\frac{\mathrm{d} q_i}{\mathrm{d} t} = k_{a,i} e^{\gamma_i c_{p,0}} c_{p,i}\: q_{\text{max},i} \left( 1 - \sum_{j=1}^{N_{\text{comp}} - 1} \frac{q_j}{q_{\text{max},j}} \right) - k_{d,i} \: c_{p,0}^{\beta_i} \: q_i
with the assumption, that the 0-th component c_{p,0} is some adsorption-modifying component, in our case, the acid HNO3.

If I understand your calculation for kEq (via k_prime) correctly:

k_prime_value = k_prime[element]['constant'] * (M_HNO3 ** k_prime[element]['slope']

or simplified to

kEq = k_prime*1.75 = ['constant'] * 1.75 * (M_HNO3 ** ['slope'])

we can use the MPML isotherm to model that behavior. First, because we do not consider kinetic effects, we assume that \frac{\mathrm{d} q_i}{\mathrm{d} t} =0. Then, by dividing the isotherm by c_{p,0}^{\beta_i} we get
c_{p,0}^{-\beta_i}\frac{\mathrm{d} q_i}{\mathrm{d} t} = 0 = k_{a,i}\: c_{p,0}^{-\beta_i} \:e^{\gamma_i c_{p,0}} c_{p,i}\: q_{\text{max},i} \left( 1 - \sum_{j=1}^{N_{\text{comp}} - 1} \frac{q_j}{q_{\text{max},j}} \right) - k_{d,i} \: q_i

So k_{a,i} takes the place of the “constant”, -\beta_i takes the place of “slope” and c_{p,0} is the concentration of HNO3. (and we set \gamma_i =0, as we do not need that influence).

I have a working example with only CADET-Process below. (I’ve taken the liberty to change some data storage code during the debugging to make tracing values easier for me)

import os

from CADETProcess.processModel import ComponentSystem, MobilePhaseModulator
from CADETProcess.processModel import Inlet, LumpedRateModelWithPores, Outlet
from CADETProcess.processModel import FlowSheet
from CADETProcess.processModel import Process
from CADETProcess.simulator import Cadet

# Derived Constants
CADET_CLI_PATH = os.path.join(os.path.abspath('.'), 'CADET/install')
assert os.path.exists(CADET_CLI_PATH), f"CADET_CLI_PATH doesn't exist: {CADET_CLI_PATH}"
WORKING_FOLDER = os.path.join(os.path.abspath('.'), 'ln-1_model')

# A few variables to simplify variations on the theme I wanted to be able to generate.
load = 150.  # Used 150 and 10
column_length_multiplier = 1  # Used 1 and 3
run_length_multiplier = 1  # Used 1 and 5

# Simplified Binding Isotherms:
k_prime = {
    'La': {'constant': 0.05641268710558851, 'slope': -2.620012417528447},
    'Nd': {'constant': 0.16512886791167147, 'slope': -2.6425049824718645},
    'Eu': {'constant': 0.6698847949281866, 'slope': -3.2578698806430073},
}

# Process Information - Used to create process events later
load_comp = [load, load, load]  # mMol/L = mol/m^3
initial_flowrate = 7 / 5 / 60 / 1e6  # 7mL / 5min / 60sec/min / 1e6mL/m^3 = m^3/s
load_flowrate = initial_flowrate

acid_load_concentration = 0.01  # mol / L # ToDO: convert to mol / m^3 and adjust slope parameters
acid_elution_concentration = 0.5 # mol / L # ToDO: convert to mol / m^3 and adjust slope parameters

# load_profile: ([HNO3] in M, vol in ml, step name)
load_profile = [
    {"acid": acid_load_concentration, "volume": 0.99, "name": "load"},
    {"acid": acid_load_concentration, "volume": 2, "name": "rinse1"},
    {"acid": acid_load_concentration, "volume": 2, "name": "rinse2"},
    {"acid": acid_load_concentration, "volume": 2, "name": "rinse3"},
]  # (diluent acid conc [M], vol [mL], name)
elute_flowrate = 5 / 40 / 60 / 1e6  # 5mL / 40min / 60sec/min / 1e6mL/m^3 = m^3/s
# Gradient steps: ([HNO3] in M, vol in ml, flowrate in m^3/s)
acid_profile = [
    {"acid": acid_load_concentration, "volume": 5, "flowrate": elute_flowrate},
    {"acid": acid_elution_concentration, "volume": 5, "flowrate": elute_flowrate},
]  # (acid conc (M), vol (mL), flow rate (m^3/s))
# Create a list of each section's HNO3 molarity:
section_acid_concentrations = [step["acid"] for step in load_profile] + [step["acid"] for step in acid_profile]

##########################################################

# Initial CADET-process Simulation Specification
# Component System
component_system = ComponentSystem(["HNO3", 'La', 'Nd', 'Eu'])

# Binding Model
binding_model = MobilePhaseModulator(component_system, name='MPM')
binding_model.is_kinetic = False

binding_model.adsorption_rate = [0] + [k_prime[element]["constant"] * 1.75 for element in ['La', 'Nd', 'Eu']]
binding_model.desorption_rate = [1] * component_system.n_comp
binding_model.capacity = [160] * component_system.n_comp  # mol/m^3
binding_model.ion_exchange_characteristic = [0] + [-1 * k_prime[element]["slope"] for element in ['La', 'Nd', 'Eu']]
binding_model.hydrophobicity = [0] * component_system.n_comp
binding_model.bound_states = [0, 1, 1, 1]
# Verify that the binding model has all required parameters:
assert binding_model.check_required_parameters()

# Unit Operations
inlet = Inlet(component_system, name='inlet')
inlet.flow_rate = initial_flowrate

# Column based on LumpedRateModelWithPores
column = LumpedRateModelWithPores(component_system, 'column')
column.binding_model = binding_model
column.length = 0.0408 * column_length_multiplier  # m = 4.08 cm times multiplier
column.diameter = 0.008  # m = 0.8 cm Dia
column.bed_porosity = 0.67  # 0 to 1 - Porosity of the bed
column.particle_radius = 7.5e-5  # m = 75 microns bead size
column.particle_porosity = 0.16  # 0 to 1 - Porosity of particles
column.axial_dispersion = 2.0e-7  # Dispersion rate of components in axial direction
column.film_diffusion = 1.0e-2  # Diffusion rate for components in pore volume. m/s

column.q = [0., 0., 0.]  # mol/m^3  # q (bound concentration) only has three entries because the acid does not bind
column.c = [0.01, 0., 0., 0.]  # mol/m^3   # The column is conditioned with 0.01 M HNO3.
column.cp = [0.01, 0., 0., 0.]  # mol/m^3  # The column is conditioned with 0.01 M HNO3.

outlet = Outlet(component_system, name='outlet')

# Flow Sheet
flow_sheet = FlowSheet(component_system)
flow_sheet.add_unit(inlet)
flow_sheet.add_unit(column)
flow_sheet.add_unit(outlet)
flow_sheet.add_connection(inlet, column)
flow_sheet.add_connection(column, outlet)

# Record the column particle solution.
column.solution_recorder.write_solution_particle = True
column.solution_recorder.write_solution_bulk = True
column.solution_recorder.write_solution_solid = True

# Define CADET Process Events
process = Process(flow_sheet, 'LN-1')

# Load and Triple Rinse:
t = 0
conc = load_comp
process.add_event(f"Start", 'flow_sheet.inlet.flow_rate', initial_flowrate, t)
for event in load_profile:
    if event["name"] != 'load':
        conc = [conc[i] * 0.01 / event["volume"] for i in
                range(3)]  # 1% left in container diluted in vol ml

    process.add_event(event["name"], 'flow_sheet.inlet.c', [event["acid"], ] + conc, t)
    t += event["volume"] * 1e-6 / initial_flowrate

# Gradient
flowrate = elute_flowrate
process.add_event("elute_start", 'flow_sheet.inlet.flow_rate', flowrate, t)
n = 0
# Loop over acid concentration steps:
for event in acid_profile:
    if event["flowrate"] != flowrate:
        n += 1
        flowrate = event["flowrate"]
        process.add_event(f"flow_adj_{n}", 'flow_sheet.inlet.flow_rate', flowrate, t)

    process.add_event(
        name=f"acid_conc_{str(event['acid']).replace('.', '_')}",
        parameter_path='flow_sheet.inlet.c',
        state=[event["acid"], 0, 0, 0],
        time=t
    )
    t += event["volume"] * 1e-6 / flowrate

process.cycle_time = t * run_length_multiplier

process_simulator = Cadet()
sim_results = process_simulator.simulate(process)
sim_results.solution.outlet.outlet.plot()


Second solution:

External functions.

This covers 90% of the application, however, it does not yet accept different external functions for the different components. So all components are modified by the same external function.

#!~/.conda/envs/cadet/bin/python

import numpy as np
import os
import matplotlib.pyplot as plt
import scipy.integrate as integrate

from CADETProcess.processModel import ComponentSystem
from CADETProcess.processModel import Langmuir
from CADETProcess.processModel import Inlet, LumpedRateModelWithPores, Outlet
from CADETProcess.processModel import FlowSheet
from CADETProcess.processModel import Process
from CADETProcess.simulator import Cadet

# Derived Constants
# CADET_CLI_PATH = os.path.join(os.path.abspath('.'), 'CADET/install')
# assert os.path.exists(CADET_CLI_PATH), f"CADET_CLI_PATH doesn't exist: {CADET_CLI_PATH}"
WORKING_FOLDER = os.path.join(os.path.abspath('.'), 'ln-1_model')

# A few variables to simplify variations on the theme I wanted to be able to generate.
load = 0.  # Used 150 and 10
column_length_multiplier = 1  # Used 1 and 3
run_length_multiplier = 1  # Used 1 and 5

# Simplified Binding Isotherms:
k_prime = {
    'La': {'constant': 0.05641268710558851, 'slope': -2.620012417528447},
    'Nd': {'constant': 0.16512886791167147, 'slope': -2.6425049824718645},
    'Eu': {'constant': 0.6698847949281866, 'slope': -3.2578698806430073},
}


def k_prime_func(element: str, M_HNO3: float) -> float:
    """Returns retention factor (k') for LN Resin as a function of [HNO3] in M.

    Args:
        element (str): 2 letter symbol for element
        M_HNO3 (float): Concentration of Nitric Acid [HNO3] in molarity (M)

    Returns:
        float: k' or Retention factor (formerly known as capacity factor) of the material
               on LN Resin (HDEHP stationary phase) as a function of [HNO3] in M.
    """
    return max(1., round(k_prime[element]['constant'] * (M_HNO3 ** k_prime[element]['slope']), 5))


# Process Information - Used to create process events later
load_comp = [load, load, load]  # mMol/L = mol/m^3
initial_flowrate = 7 / 5 / 60 / 1e6  # 7mL / 5min / 60sec/min / 1e6mL/m^3 = m^3/s
load_flowrate = initial_flowrate
# load_profile: ([HNO3] in M, vol in ml, step name)
load_profile = [
    (0.01, 0.99, "load"),
    (0.01, 2, "rinse1"),
    (0.01, 2, "rinse2"),
    (0.01, 2, "rinse3"),
]  # (diluent acid conc [M], vol [mL], name)
elute_flowrate = 5 / 40 / 60 / 1e6  # 5mL / 40min / 60sec/min / 1e6mL/m^3 = m^3/s
# Gradient steps: ([HNO3] in M, vol in ml, flowrate in m^3/s)
acid_profile = [
    (0.01, 5, elute_flowrate),
    (0.5, 5, elute_flowrate)
]  # (acid conc (M), vol (mL), flow rate (m^3/s))
# Create a list of each section's HNO3 molarity:
section_acid_concentrations = [step[0] for step in load_profile] + [step[0] for step in acid_profile]

# Initial CADET-process Simulation Specification
# Component System
component_system = ComponentSystem(['La', 'Nd', 'Eu'])

# Binding Model
binding_model = Langmuir(component_system, name='langmuir')
binding_model.is_kinetic = False

# The column is conditioned with 0.01 M HNO3.
binding_model.adsorption_rate = [k_prime_func(component.name, 0.01) * 1.75 for component in component_system]
binding_model.desorption_rate = [1] * component_system.n_comp
binding_model.capacity = [160] * component_system.n_comp  # mol/m^3
# Verify that the binding model has all required parameters:
assert binding_model.check_required_parameters()

# Unit Operations
inlet = Inlet(component_system, name='inlet')
inlet.flow_rate = initial_flowrate

# Column based on LumpedRateModelWithPores
column = LumpedRateModelWithPores(component_system, 'column')
column.binding_model = binding_model
column.length = 0.0408 * column_length_multiplier  # m = 4.08 cm times multiplier
column.diameter = 0.008  # m = 0.8 cm Dia
column.bed_porosity = 0.67  # 0 to 1 - Porosity of the bed
column.particle_radius = 7.5e-5  # m = 75 microns bead size
column.particle_porosity = 0.16  # 0 to 1 - Porosity of particles
column.axial_dispersion = 2.0e-7  # Dispersion rate of components in axial direction
column.film_diffusion = 1.0e-2  # Diffusion rate for components in pore volume. m/s

column.q = [10., ] * component_system.n_comp  # mol/m^3

outlet = Outlet(component_system, name='outlet')

# Flow Sheet
flow_sheet = FlowSheet(component_system)
flow_sheet.add_unit(inlet)
flow_sheet.add_unit(column)
flow_sheet.add_unit(outlet)
flow_sheet.add_connection(inlet, column)
flow_sheet.add_connection(column, outlet)

# Record the column particle solution.
column.solution_recorder.write_solution_particle = True
column.solution_recorder.write_solution_bulk = True
column.solution_recorder.write_solution_solid = True

# Define CADET Process Events
process = Process(flow_sheet, 'LN-1')
# Load and Triple Rinse:
t = 0
conc = load_comp
process.add_event(f"Start", 'flow_sheet.inlet.flow_rate', initial_flowrate, t)
for event in load_profile:
    _, vol, name = event
    conc = conc if name == 'load' else [conc[i] * 0.01 / vol for i in
                                        range(component_system.n_comp)]  # 1% left in container diluted in vol ml
    process.add_event(name, 'flow_sheet.inlet.c', conc, t)
    t += vol * 1e-6 / initial_flowrate
# Gradient
flowrate = elute_flowrate
process.add_event("elute_start", 'flow_sheet.inlet.flow_rate', flowrate, t)
n = 0
# Loop over acid concentration steps:
for event in acid_profile:
    if event[2] != flowrate:
        n += 1
        flowrate = event[2]
        process.add_event(f"flow_adj_{n}", 'flow_sheet.inlet.flow_rate', flowrate, t)
    process.add_event(f"acid_conc_{str(event[0]).replace('.', '_')}", 'flow_sheet.inlet.c',
                      [0] * component_system.n_components, t)
    t += event[1] * 1e-6 / flowrate
process.cycle_time = t * run_length_multiplier

# Transfer Process to a CADET Core Object
process_simulator = Cadet()
# Delete and recreate the saved, unsolved model in this h5 file:
h5_filepath = os.path.join(WORKING_FOLDER, 'ln_load_elute_in_2_steps.h5')
if os.path.exists(h5_filepath):
    os.remove(h5_filepath)
process_simulator.save_to_h5(process=process, file_path=h5_filepath)
model = Cadet()
model = model.load_from_h5(h5_filepath)

# Replace the Binind Model with EXTFUNs

# Remove the existing Multi_Component_Langmuir info from the model.
del model.root.input.model.unit_001.adsorption

simulation_end_time = int(model.root.input.solver.sections.section_times[-1].item())

# Replace it with the EXTFUN Multi_Component_Langmuir model
model.root.input.model.unit_001.adsorption_model = b'EXT_MULTI_COMPONENT_LANGMUIR'
# Specify which EXTernal Source each EXTFUN Binding Model parameter points to:
model.root.input.model.unit_001.adsorption.extfun = [0, 1, 2]

model.root.input.model.unit_001.adsorption.is_kinetic = False

model.root.input.model.unit_001.adsorption.ext_mcl_ka = np.array([1e-5, 1e-5, 1e-5])
model.root.input.model.unit_001.adsorption.ext_mcl_ka_t = np.array([1e6, 1e6, 1e6])
model.root.input.model.unit_001.adsorption.ext_mcl_ka_tt = np.array([0, 0, 0])
model.root.input.model.unit_001.adsorption.ext_mcl_ka_ttt = np.array([0, 0, 0])

model.root.input.model.unit_001.adsorption.ext_mcl_kd = np.array([1e4, 1e4, 1e4])
model.root.input.model.unit_001.adsorption.ext_mcl_kd_t = np.array([-0.99e4, -0.99e4, -0.99e4])
model.root.input.model.unit_001.adsorption.ext_mcl_kd_tt = np.array([0, 0, 0])
model.root.input.model.unit_001.adsorption.ext_mcl_kd_ttt = np.array([0, 0, 0])

model.root.input.model.unit_001.adsorption.ext_mcl_qmax = np.array([160, 160, 160])
model.root.input.model.unit_001.adsorption.ext_mcl_qmax_t = np.array([0, 0, 0])
model.root.input.model.unit_001.adsorption.ext_mcl_qmax_tt = np.array([0, 0, 0])
model.root.input.model.unit_001.adsorption.ext_mcl_qmax_ttt = np.array([0, 0, 0])

n_sections = len(model.root.input.solver.sections.section_times) - 1
## external source 0: Returns np.ndarray of K_a's in each section.
model.root.input.model.external.source_000.extfun_type = 'PIECEWISE_CUBIC_POLY'
model.root.input.model.external.source_000.section_times = [0, 2699, 2700, 5099]
model.root.input.model.external.source_000.const_coeff = [1, 1, 0.01]
model.root.input.model.external.source_000.lin_coeff = [0, -0.99 / 1, 0]
model.root.input.model.external.source_000.quad_coeff = [0, 0, 0]
model.root.input.model.external.source_000.cube_coeff = [0, 0, 0]
model.root.input.model.external.source_000.velocity = 1

model.root.input.model.external.source_001.extfun_type = 'LINEAR_INTERP_DATA'
model.root.input.model.external.source_001.time = [0, 2699, 2700, 5099]
model.root.input.model.external.source_001.data = [1, 1, 1, 1]
model.root.input.model.external.source_001.velocity = 1

model.root.input.model.external.source_002.extfun_type = 'LINEAR_INTERP_DATA'
model.root.input.model.external.source_002.time = [0, 2699, 2700, 5099]
model.root.input.model.external.source_002.data = [1, 1, 2, 2]
model.root.input.model.external.source_002.velocity = 1

# ## external source 1: EXTFUN(param, t) = 1.0 * param
# model.root.input.model.external.source_001.extfun_type = 'PIECEWISE_CUBIC_POLY'
# model.root.input.model.external.source_001.section_times = model.root.input.solver.sections.section_times
# model.root.input.model.external.source_001.velocity = 1 / simulation_end_time
# model.root.input.model.external.source_001.const_coeff = [1.0] * n_sections
# model.root.input.model.external.source_001.lin_coeff = [0.0] * n_sections
# model.root.input.model.external.source_001.quad_coeff = [0.0] * n_sections
# model.root.input.model.external.source_001.cube_coeff = [0.0] * n_sections

[[k_prime_func(component, m_acid) * 1.75 for component in component_system.names] for m_acid in section_acid_concentrations]

# Adjust number of threads the solver can use if desired:
model.root.input.solver.nthreads = 1

model.save()
# Solve the model
return_info = model.run()
print(return_info)

# Plot the Results
model.load()
print(model.root.input.model.external)
print(model.root.input.model.unit_001.adsorption)
time = model.root.output.solution.solution_times / 60
c = model.root.output.solution.unit_001.solution_outlet_port_000

fig = plt.figure()
ax = fig.add_subplot()
ax.plot(time, c)
ax.set_xlabel(r'time / min')
ax.set_ylabel(r'c / mM')
ax.legend(component_system.names)
plt.title(f"Load [component]={load_comp[0]} mM, Col_length={column.length * 100:.2f} cm")
plt.axvline(column.calculate_interstitial_rt(initial_flowrate) / 60, color='k', lw=1)
plt.text(column.calculate_interstitial_rt(initial_flowrate) / 60, 0, "Residence time", rotation="vertical",
         horizontalalignment="right", fontsize=12)
plt.axvline(process.section_times[5] / 60, color='k', lw=1)
plt.text(process.section_times[5] / 60, 0, "0.5M Start", rotation="vertical", horizontalalignment="right", fontsize=12)
plt.axvline(t / 60, color='k', lw=1)
plt.text(t / 60, 0, "Normal end", rotation="vertical", horizontalalignment="right", fontsize=12)
# Plot zoom settings:
# plt.xlim(0, 8)
# plt.ylim(0, 0.5e-10)
fig.tight_layout()
plt.savefig(os.path.join(WORKING_FOLDER, "ln-1_simple.png"))

# Calculate component yields
# NOTE: currently only accounts for the mobile phase.
# Construct flowrate array (fr): the flowrate at each time element.
idx_ifr = np.argwhere(time > process.events[5].time / 60)[0].item()
fr = np.ones([idx_ifr]) * initial_flowrate
fr = np.append(fr, np.ones([time.shape[0] - idx_ifr]) * elute_flowrate)
# How much goes in:
amt_in = {}
for component in range(3):
    amt_in[component] = integrate.simpson(
        y=np.multiply(model.root.output.solution.unit_001.solution_inlet_port_000[:, component], fr), x=time * 60) * 1E6
amt_in
# How much goes out:
amt_out = {}
for component in range(3):
    amt_out[component] = integrate.simpson(
        y=np.multiply(model.root.output.solution.unit_001.solution_outlet_port_000[:, component], fr),
        x=time * 60) * 1e6
    print(f"Recovery[{component_system[component].name}] = {100 * amt_out[component] / amt_in[component]:.2f}%")

# print("Column-bound component concentrations:")
# print(model.root.output.solution.unit_001.solution_solid[-1, :, :])
3 Likes

Thank you so much, @ronald.jaepel!! (And others that helped formulate it!)

Turns out I wasn’t over being sick the day I called into office hours. But I’m back in the saddle again… ANYway…

I’ve been able to run both of your solutions. The MPML solution is nicely elegant and seems to behave way more consistently to intuitive expectations. I really appreciate your translation of my problem to this binding model. It seems like a much more natural solution.

I’m still playing with the external functions solution. It seems to show behavior substantially different from the other. I’m working to understand the details.

I sense from the group’s discussion during the office hours, and your (can I call it hedging) reply that this approach is less certain? Of course all models are wrong, some are useful. So, in the end, useful models are wins. However, I’m wondering if our collective confidence in this line of effort should be more tentative, for now. For example, I need to explore if my “velocity” understanding is manifesting in this model’s results. It makes me appreciate the MPML model’s natural approach to modeling the [HNO3]. Regardless, I need to work with these solutions a bit more.

I really appreciate all the work that’s gone into the CADET framework!

Thanks, again!

4 Likes

Glad to hear it! I do agree that the MPML binding model is the more immediately useful one while also being more physically accurate. It will include processes such as dispersion effects on the HNO3 concentration front that would not be easily possible with external functions.

Another thing of note is that there is currently a project in the work that will overhaul the external functions functionality and interface, which is why we’re not going to invest a lot of time into creating comprehensive documentation for the current state.

Regarding velocity: it can be thought of as a “scaling” factor of column vs external profile. Maybe this quick illustration can help:
We move the profile along the column over time, starting at t=0 with the start of the profile at the outlet of the column until at t=t_end the end of the profile is at the outlet of the column. How “long” it takes the external profile to go from the inlet of the column to the outlet of the column is determined by the velocity. With a velocity of 1, the profile is stretched very long (illustrated below as a tiny column compared to the profile) and moves through the column with a high velocity. If the velocity is 1/t_end, it will take the profile t_end time to go from the inlet to the outlet. Therefore at t=0, the external function state we expect to see at the outlet at t_end is already at the inlet. Maybe numbers help: if the simulation takes 200 seconds (t_end = 200), and we set the velocity to 1/200, the external function value we expect to see at the outlet at t=200 is going to be at the inlet at t=0.

Well, that turned into more text than expected. Hope it helps for your curiosity, but as mentioned above, I’d recommend using the MPML model.

Please let us know if you need more input, and also if you don’t and things go well, that’s nice to hear as well.

3 Likes