LC System Characterization#

The characterization module sets up parameter estimation problems for LC systems. Each CharacterizeBase subclass is an OptimizationProblem specialized for a particular experiment type, encoding which parameters are fitted, their default bounds and transforms, and any experiment-specific constraints or parameter couplings. Users supply the process, the simulator, and the comparator; the class wires them into a ready-to-optimize problem.

Choosing a subclass:

  • Dead volume of a single tubing segment: CharacterizeTubing

  • Pre-injection path (tubing length + mixer volume): CharacterizePreInjection

  • Packed bed (porosity + axial dispersion): CharacterizeBed

  • Particle-phase transport (film/pore diffusion, particle porosity): CharacterizeParticles

  • Binding model capacity: CharacterizeCapacity

  • SMA adsorption parameters: CharacterizeAdsorptionParameters

import numpy as np
from CADETProcess.processModel import (
    ComponentSystem, LumpedRateModelWithPores, StericMassAction,
)
from CADETProcess.instruments import LCFlowSheet, PulseInjection, LWE
from CADETProcess.comparison import Comparator
from CADETProcess.reference import ReferenceIO
from CADETProcess.characterization import (
    setup_comparators,
    CharacterizeBed,
    CharacterizeParticles,
    CharacterizeAdsorptionParameters,
)

Workflow#

A typical characterization workflow has three steps.

1. Build the process. Create an LCFlowSheet and an LCProcess template matching the experimental protocol. The example below runs a pulse injection of a non-binding tracer through the packed bed to characterize its transport parameters.

cs = ComponentSystem(["Salt"])
Q = 8.3e-9  # m³/s

fs = LCFlowSheet(
    cs,
    sample_loop_volume=50e-9,
    ColumnModel=LumpedRateModelWithPores,
)
process = PulseInjection(
    "bed_characterization",
    fs,
    c_buffer_a=[100.0],
    c_sample=[0.0],
    cycle_time=600.0,
    flow_rate=Q,
)

2. Build the comparator. A Comparator holds one or more references and the difference metrics used to score the simulation against the experiment. The solution_path locates the simulated signal inside SimulationResults.solution_cycles, e.g. the outlet concentration of a column or tubing segment.

comparator = Comparator("bed_characterization")
comparator.add_reference(reference)
comparator.add_difference_metric(
    "Shape",
    reference,
    solution_path="column.outlet.outlet[0]",
)
/tmp/ipykernel_2667/2324938077.py:2: DeprecationWarning: add_reference() is deprecated and will be removed in v1.0. Pass a pre-constructed metric instance to add_difference_metric() instead: metric = SSE(reference); comparator.add_difference_metric(metric, solution_path)
  comparator.add_reference(reference)
/tmp/ipykernel_2667/2324938077.py:3: DeprecationWarning: Passing a metric class name as a string to add_difference_metric() is deprecated and will be removed in v1.0. Construct the metric directly and pass the instance instead: metric = SSE(reference); comparator.add_difference_metric(metric, solution_path)
  comparator.add_difference_metric(
<CADETProcess.comparison.difference.Shape at 0x78eb1bfdb0e0>

3. Create the characterization problem and optimize. Pass the process, comparator, and simulator to the appropriate subclass. The result is an OptimizationProblem ready for any optimizer.

from CADETProcess.simulator import Cadet
from CADETProcess.optimization import U_NSGA3

simulator = Cadet()

char = CharacterizeBed("bed", process, comparator, simulator)

optimizer = U_NSGA3()
optimizer.optimize(char)

Optimization structure#

Every Characterize* instance is an ordinary OptimizationProblem.

  • Variables correspond to the selected physical parameters with predefined bounds and transforms.

  • Objectives are provided by the comparator metrics; each comparator contributes its own objective (or objectives if the comparator has multiple metrics).

  • Multi-process characterization creates one objective per process while all processes share the same variable set. Each evaluation automatically generates comparison plots in the optimizer callback directory.

Comparator helper#

setup_comparators() builds one Comparator per process from a matching list of ReferenceIO objects. It covers the common case where all comparators use the same solution_path and difference metrics. Optional arguments (components, start, end) are forwarded per comparator. For multi-channel comparisons (e.g. UV and conductivity on the same process), build the comparator directly.

comparators = setup_comparators(
    processes=[process_4cv, process_8cv, process_12cv],
    references=[ref_4cv, ref_8cv, ref_12cv],
    solution_path="column.outlet.outlet[0]",
    metrics=["Shape"],
    components=["Protein"],
    start=[t0_4cv, t0_8cv, t0_12cv],  # per-process peak windows
    end=[t1_4cv, t1_8cv, t1_12cv],
)

start and end accept either a scalar (applied to all processes) or a list (one value per process).

Subclasses#

CharacterizeTubing#

Fits length \(l \in [0.01, 2.0]\) m and axial dispersion \(D_{ax} \in [10^{-9}, 10^{-2}]\) m²/s of a named tubing segment. The tubing argument must match the unit name in the flow sheet.

from CADETProcess.characterization import CharacterizeTubing

char = CharacterizeTubing(
    "tubing", process, "tubing_post_column", comparator, simulator
)
print(char.variable_names)
# ['tubing_post_column_length', 'tubing_post_column_axial_dispersion']

CharacterizePreInjection#

Fits tubing_pre_injection length \(\in [0.01, 2.0]\) m and mixer volume \(\in [10^{-8}, 10^{-5}]\) m³.

from CADETProcess.characterization import CharacterizePreInjection

char = CharacterizePreInjection("pre_inj", process, comparator, simulator)
print(char.variable_names)
# ['tubing_pre_injection_length', 'mixer_volume']

CharacterizeBed#

Fits column bed porosity \(\varepsilon_c \in [0.2, 0.6]\) and axial dispersion \(D_{ax} \in [10^{-11}, 10^{-3}]\) m²/s. Run a pulse injection with a non-binding tracer so that peak broadening reflects hydrodynamic transport rather than adsorption.

char = CharacterizeBed("bed", process, comparator, simulator)
print(char.variable_names)
# ['bed_porosity', 'axial_dispersion']

CharacterizeParticles#

Fits a selectable combination of particle-phase transport parameters for a non-binding tracer. At least one flag must be set. In practice, fitting many transport parameters simultaneously requires sufficiently informative experiments; start with the parameters most likely to dominate the observed peak shape. pore_diffusion is only meaningful for GeneralRateModel columns; for LumpedRateModelWithPores, only the remaining parameters should be fitted.

from CADETProcess.characterization import CharacterizeParticles

char = CharacterizeParticles(
    "particles", process, comparator, simulator,
    include_particle_porosity=True,
    include_film_diffusion=True,
    component_index=0,
)
print(char.variable_names)
# ['particle_porosity', 'film_diffusion']

CharacterizeCapacity#

Fits binding model capacity \(q_{max} \in [0.01, 2.0]\) mol/m³. The binding model attached to the column must expose a capacity parameter, such as StericMassAction.

from CADETProcess.characterization import CharacterizeCapacity

char = CharacterizeCapacity("capacity", process, comparator, simulator)
print(char.variable_names)
# ['capacity']

CharacterizeAdsorptionParameters#

Fits StericMassAction adsorption parameters from gradient elution data. Constructing the object modifies the attached binding model configuration to match the selected kinetic mode, so the process passed in is mutated in place. Two modes are available.

Rapid-equilibrium mode (is_kinetic=False, default): desorption_rate is fixed to 1, so adsorption_rate numerically equals the equilibrium constant. Two free parameters: characteristic_charge and adsorption_rate.

Kinetic mode (is_kinetic=True): two optimization variables (equilibrium_constant and kinetic_constant) drive adsorption_rate and desorption_rate as dependent variables through the relations \(k_\text{ads} = K_\text{eq} / k_\text{kin}\) and \(k_\text{des} = 1 / k_\text{kin}\), which enforces \(K_\text{eq} = k_\text{ads} / k_\text{des}\) by construction. A Pareto-front selector picks the best individual by summing objectives.

from CADETProcess.processModel import StericMassAction
from CADETProcess.characterization import CharacterizeAdsorptionParameters

cs2 = ComponentSystem(["Salt", "Protein"])
fs2 = LCFlowSheet(
    cs2,
    sample_loop_volume=50e-9,
    ColumnModel=LumpedRateModelWithPores,
    BindingModel=StericMassAction,
)
lwe = LWE(
    "lwe", fs2,
    c_buffer_a=[20.0, 0.0], c_buffer_b=[1000.0, 0.0], c_sample=[20.0, 0.5],
    delta_t_wash=120.0, delta_t_elute=600.0, delta_t_final_wash=120.0,
    flow_rate_wash=Q,
)

char = CharacterizeAdsorptionParameters(
    "sma", lwe, comparator, simulator,
    is_kinetic=False,
    component_index=1,
)
print(char.variable_names)
# ['characteristic_charge', 'adsorption_rate']

Multi-process example#

When the same characterization is run across several gradient lengths simultaneously, pass a list of processes and a matching list of comparators. setup_comparators() builds the comparator list; the Characterize* class receives it directly.

processes = [setup_lwe(cv) for cv in [4, 8, 12, 16]]  # one LWE per gradient length
references = [load_reference(cv) for cv in [4, 8, 12, 16]]
start_times = [peak_start[cv] for cv in [4, 8, 12, 16]]
end_times   = [peak_end[cv]   for cv in [4, 8, 12, 16]]

comparators = setup_comparators(
    processes, references,
    solution_path="tubing_post_column.outlet",
    metrics=["Shape"],
    components=["Protein"],
    start=start_times,
    end=end_times,
)

char = CharacterizeAdsorptionParameters(
    "sma_multi", processes, comparators, simulator,
    component_index=1,
)

The optimization problem has one objective per process (one per gradient), all sharing the same parameter variables. The optimizer searches for a parameter vector that jointly improves agreement across all gradients.