Source code for CADETProcess.processModel.unitOperation

import math
import warnings
from abc import abstractmethod
from typing import Any, Optional

import numpy as np

from CADETProcess import CADETProcessError
from CADETProcess.dataStructure import (
    Constant,
    NdPolynomial,
    Polynomial,
    SizedFloatList,
    SizedNdArray,
    SizedUnsignedList,
    String,
    Structure,
    Switch,
    UnsignedFloat,
    frozen_attributes,
)

from .binding import BindingBaseClass, NoBinding
from .componentSystem import ComponentSystem
from .discretization import (
    DiscretizationParametersBase,
    GRMDiscretizationDG,
    GRMDiscretizationFV,
    LRMDiscretizationDG,
    LRMDiscretizationFV,
    LRMPDiscretizationDG,
    LRMPDiscretizationFV,
    MCTDiscretizationFV,
    NoDiscretization,
)
from .reaction import BulkReactionBase, NoReaction, ParticleReactionBase
from .solutionRecorder import (
    CSTRRecorder,
    GRMRecorder,
    IORecorder,
    LRMPRecorder,
    LRMRecorder,
    MCTRecorder,
    TubularReactorRecorder,
)

__all__ = [
    "UnitBaseClass",
    "SourceMixin",
    "SinkMixin",
    "Inlet",
    "Outlet",
    "Cstr",
    "TubularReactorBase",
    "TubularReactor",
    "ChromatographicColumnBase",
    "LumpedRateModelWithoutPores",
    "LumpedRateModelWithPores",
    "GeneralRateModel",
    "MCT",
]


[docs] @frozen_attributes class UnitBaseClass(Structure): """ Base class for all UnitOperation classes. A UnitOperation object stores model parameters and states of a unit. Every unit operation can be assotiated with a binding behavior and a reaction model. UnitOperations can be connected in a FlowSheet. Attributes ---------- n_comp : UnsignedInteger Number of components in a system. parameters : list list of parameter names. name : String name of the unit operation. has_ports : bool flag if unit has ports. The default is False. binding_model : BindingBaseClass binding behavior of the unit. Defaults to NoBinding. solution_recorder : IORecorder Solution recorder for the unit operation. See Also -------- CADETProcess.processModel.binding CADETProcess.processModel.reaction CADETProcess.processModel.FlowSheet """ name = String() _parameters = [] _section_dependent_parameters = [] _initial_state = [] has_ports = False supports_binding = False supports_bulk_reaction = False supports_particle_reaction = False discretization_schemes = () def __init__( self, component_system: ComponentSystem, name: str, *args: Optional[tuple], binding_model: Optional[BindingBaseClass] = None, bulk_reaction_model: Optional[BulkReactionBase] = None, particle_reaction_model: Optional[ParticleReactionBase] = None, discretization: Optional[DiscretizationParametersBase] = None, solution_recorder: Optional[IORecorder] = None, **kwargs: Optional[dict], ) -> None: """ Initialize UnitBaseClass. Parameters ---------- component_system : ComponentSystem The component system to which this unit belongs. name : str Name identifier for the unit instance. *args : tuple Positional arguments passed to the superclass constructor. binding_model : BindingBaseClass, optional A model describing specific binding interactions within the unit. If None, a default `NoBinding` model is used. bulk_reaction_model : BulkReactionBase, optional A model for bulk phase reactions. Defaults to `NoReaction` if not provided. particle_reaction_model : ParticleReactionBase, optional A model for reactions within particles. Defaults to `NoReaction`. discretization : DiscretizationParametersBase, optional Parameters defining spatial/temporal discretization of the unit. Defaults to `NoDiscretization` if None. solution_recorder : IORecorder, optional Object responsible for recording the simulation results. Defaults to a new `IORecorder` instance. **kwargs : dict Additional keyword arguments passed to the superclass constructor. """ self.name = name self.component_system = component_system if binding_model is None: binding_model = NoBinding() self.binding_model = binding_model if bulk_reaction_model is None: bulk_reaction_model = NoReaction() self.bulk_reaction_model = bulk_reaction_model if particle_reaction_model is None: particle_reaction_model = NoReaction() self.particle_reaction_model = particle_reaction_model if discretization is None: discretization = NoDiscretization() self.discretization = discretization if solution_recorder is None: solution_recorder = IORecorder() self.solution_recorder = solution_recorder super().__init__(*args, **kwargs) @property def model(self) -> str: """str: Return model name.""" return self.__class__.__name__ @property def component_system(self) -> ComponentSystem: """ComponentSystem: Component of the unit operation.""" return self._component_system @component_system.setter def component_system(self, component_system: ComponentSystem) -> None: if not isinstance(component_system, ComponentSystem): raise TypeError("Expected ComponentSystem") self._component_system = component_system @property def discretization(self) -> DiscretizationParametersBase: """DiscretizationParametersBase: Discretization parameters of the unit operation.""" return self._discretization @discretization.setter def discretization(self, discretization: DiscretizationParametersBase) -> None: if not isinstance(discretization, DiscretizationParametersBase): raise TypeError("Expected DiscretizationParametersBase") if not isinstance(discretization, NoDiscretization): if not isinstance(discretization, self.discretization_schemes): raise CADETProcessError( f"Unit does not support {type(discretization)}." ) self._discretization = discretization @property def n_comp(self) -> int: """int: Number of components.""" return self.component_system.n_comp @property def ports(self) -> list: """list: Ports of the unit operation.""" return [None] @property def n_ports(self) -> int: """int: Number of ports.""" return 1 @property def parameters(self) -> dict: """dict: Dictionary with parameter values.""" parameters = super().parameters if not isinstance(self.binding_model, NoBinding): parameters["binding_model"] = self.binding_model.parameters if not isinstance(self.bulk_reaction_model, NoReaction): parameters["bulk_reaction_model"] = self.bulk_reaction_model.parameters if not isinstance(self.particle_reaction_model, NoReaction): parameters['particle_reaction_model'] = \ self.particle_reaction_model.parameters if not isinstance(self.discretization, NoDiscretization): parameters["discretization"] = self.discretization.parameters return parameters @parameters.setter def parameters(self, parameters: dict) -> None: try: self.binding_model.parameters = parameters.pop("binding_model") except KeyError: pass try: self.bulk_reaction_model.parameters = parameters.pop("bulk_reaction_model") except KeyError: pass try: self.particle_reaction_model.parameters = parameters.pop( "particle_reaction_model" ) except KeyError: pass try: self.discretization.parameters = parameters.pop("discretization") except KeyError: pass super(UnitBaseClass, self.__class__).parameters.fset(self, parameters) @property def section_dependent_parameters(self) -> dict: """Return section dependent parameters.""" parameters = { key: value for key, value in self.parameters.items() if key in self._section_dependent_parameters } return parameters @property def initial_state(self) -> dict: """dict: Dictionary with initial states.""" initial_state = {st: getattr(self, st) for st in self._initial_state} return initial_state @initial_state.setter def initial_state(self, initial_state: dict) -> None: for st, value in initial_state.items(): if st not in self._initial_state: raise CADETProcessError("Not a valid parameter") if value is not None: setattr(self, st, value) @property def missing_parameters(self) -> list: """Return list of missing parameters.""" missing_parameters = super().missing_parameters missing_parameters += [ f"binding_model.{param}" for param in self.binding_model.missing_parameters ] return missing_parameters
[docs] def check_required_parameters(self) -> bool: """Checkf if there are missing parameters left.""" if len(self.missing_parameters) == 0: return True else: for param in self.missing_parameters: warnings.warn(f'Unit {self.name}: Missing parameter "{param}".') return False
@property def binding_model(self) -> BindingBaseClass: """ BindingBaseClass: binding model parameters of the unit operation. Raises ------ TypeError If binding_model object is not an instance of BindingBaseClass. CADETProcessError If number of components do not match. """ return self._binding_model @binding_model.setter def binding_model(self, binding_model: BindingBaseClass) -> None: if not isinstance(binding_model, BindingBaseClass): raise TypeError("Expected BindingBaseClass") if not isinstance(binding_model, NoBinding): if not self.supports_binding: raise CADETProcessError("Unit does not support binding models.") if ( binding_model.component_system is not self.component_system and not isinstance(binding_model, NoBinding) ): raise CADETProcessError("Component systems do not match.") self._binding_model = binding_model @property def n_bound_states(self) -> int: """int: Return number of bound states.""" if isinstance(self.binding_model, NoBinding): return 0 return self.binding_model.n_bound_states @property def bulk_reaction_model(self) -> BulkReactionBase: """ BulkReactionBase: Reaction in bulk phase. Raises ------ TypeError If bulk_reaction_model is not an instance of ReactionBaseClass. CADETProcessError If unit does not support bulk reaction model. If number of components do not match. """ return self._bulk_reaction_model @bulk_reaction_model.setter def bulk_reaction_model(self, bulk_reaction_model: BulkReactionBase) -> None: if not isinstance(bulk_reaction_model, NoReaction): if not isinstance(bulk_reaction_model, BulkReactionBase): raise TypeError("Expected BulkReactionBase") if not self.supports_bulk_reaction: raise CADETProcessError("Unit does not support bulk reactions.") if bulk_reaction_model.component_system is not self.component_system: raise CADETProcessError("Component systems do not match.") self._bulk_reaction_model = bulk_reaction_model @property def particle_reaction_model(self) -> ParticleReactionBase: """ ParticleReactionBase: Reaction in particle liquid phase. Raises ------ TypeError If particle_reaction_model is not an instance of ReactionBaseClass. CADETProcessError If unit does not support particle reaction model. If number of components do not match. """ return self._particle_reaction_model @particle_reaction_model.setter def particle_reaction_model( self, particle_reaction_model: ParticleReactionBase | BulkReactionBase ) -> None: if isinstance(particle_reaction_model, BulkReactionBase): try: warnings.warn( "Detected Bulk Reaction Model. " "Attempt casting to Particle Reaction Model." ) particle_reaction_model = particle_reaction_model.to_particle_model() except NotImplementedError: pass if not isinstance(particle_reaction_model, NoReaction): if not isinstance(particle_reaction_model, ParticleReactionBase): raise TypeError("Expected ReactionBaseClass") if not self.supports_particle_reaction: raise CADETProcessError("Unit does not support particle reactions.") if particle_reaction_model.component_system is not self.component_system: raise CADETProcessError("Component systems do not match.") self._particle_reaction_model = particle_reaction_model def __repr__(self) -> str: """str: String-representation of the object.""" return f"{self.__class__.__name__}(n_comp={self.n_comp}, name={self.name})" def __str__(self) -> str: """str: String-representation of the object.""" return self.name
class SourceMixin(Structure): """ Mixin class for Units that have Source-like behavior. See Also -------- SinkMixin Inlet Cstr """ _n_poly_coeffs = 4 flow_rate = Polynomial(size=("_n_poly_coeffs")) _parameters = ["flow_rate"] _section_dependent_parameters = ["flow_rate"] class SinkMixin: """ Mixin class for Units that have Sink-like behavior. See Also -------- SourceMixin Cstr """ pass
[docs] class Inlet(UnitBaseClass, SourceMixin): """ Pseudo unit operation model for streams entering the system. Attributes ---------- c : NdPolynomial Polynomial coefficients for component concentration. flow_rate : NdPolynomial Polynomial coefficients for volumetric flow rate. solution_recorder : IORecorder Solution recorder for the unit operation. """ c = NdPolynomial(size=("n_comp", "_n_poly_coeffs"), default=0) flow_rate = Polynomial(size=("_n_poly_coeffs"), default=0) _n_poly_coeffs = 4 _parameters = ["c"] _section_dependent_parameters = ( UnitBaseClass._section_dependent_parameters + SourceMixin._section_dependent_parameters + ["c"] )
[docs] class Outlet(UnitBaseClass, SinkMixin): """ Pseudo unit operation model for streams leaving the system. Attributes ---------- solution_recorder : IORecorder Solution recorder for the unit operation. """ pass
class MixerSplitter(UnitBaseClass): """Pseudo unit operation for mixing/splitting streams in the system.""" pass class TubularReactorBase(UnitBaseClass): """ Base class for tubular reactors and chromatographic columns. Provides methods for calculating geometric properties such as the cross section area and volume, as well as methods for convective and dispersive properties like mean residence time or NTP. Attributes ---------- length : UnsignedFloat Length of column. diameter : UnsignedFloat Diameter of column. axial_dispersion : List of unsigned floats. Length depends on n_comp. Axial dispersion coefficient for each component. flow_direction : Switch If 1: Forward flow. If -1: Backwards flow. discretization : DiscretizationParametersBase Discretization scheme of the unit. Notes ----- For subclassing, check that the total porosity and interstitial cross section area are computed correctly depending on the model porosities! """ length = UnsignedFloat() diameter = UnsignedFloat() axial_dispersion = SizedUnsignedList(size="n_comp") flow_direction = Switch(valid=[-1, 1], default=1) c = SizedFloatList(size="n_comp", default=0) _initial_state = UnitBaseClass._initial_state + ["c"] _parameters = [ "length", "diameter", "axial_dispersion", "flow_direction", ] + _initial_state _section_dependent_parameters = UnitBaseClass._section_dependent_parameters + [ "axial_dispersion", "flow_direction", ] @property @abstractmethod def total_porosity(self) -> None: """Float | None: Total porosity of the unit operation.""" pass @property def cross_section_area(self) -> float: """float: Cross section area of a Column. See Also -------- volume cross_section_area_interstitial """ if self.diameter is not None: return math.pi / 4 * self.diameter**2 @cross_section_area.setter def cross_section_area(self, cross_section_area: float) -> None: self.diameter = (4 * cross_section_area / math.pi) ** 0.5 def set_diameter_from_interstitial_velocity(self, Q: float, u0: float) -> None: """ Set diamter from flow rate and interstitial velocity. In literature, often only the interstitial velocity is given. This method, the diameter / cross section area can be inferred from the flow rate, velocity, and porosity. Parameters ---------- Q : float Volumetric flow rate. u0 : float Interstitial velocity. Notes ----- Needs to be overwritten depending on the model porosities! """ self.cross_section_area = Q / (u0 * self.total_porosity) @property def cross_section_area_interstitial(self) -> float: """float: Interstitial area between particles. See Also -------- cross_section_area Notes ----- Needs to be overwritten depending on the model porosities! """ return self.total_porosity * self.cross_section_area @property def volume(self) -> float: """ float: Volume of the TubularReactor. See Also -------- cross_section_area """ return self.cross_section_area * self.length @property def volume_interstitial(self) -> float: """ float: Interstitial volume between particles. See Also -------- cross_section_area """ return self.cross_section_area_interstitial * self.length @property def volume_liquid(self) -> float: """float: Volume of the liquid phase.""" return self.total_porosity * self.cross_section_area * self.length @property def volume_solid(self) -> float: """float: Volume of the solid phase.""" return (1 - self.total_porosity) * self.cross_section_area * self.length def calculate_interstitial_rt(self, flow_rate: float) -> float: """ Calculate mean residence time of a (non adsorbing) volume element. Parameters ---------- flow_rate : float Volumetric flow rate. Returns ------- t0 : float Mean residence time of packed bed. See Also -------- calculate_interstitial_velocity calculate_superficial_rt """ return self.volume_interstitial / flow_rate def calculate_superficial_rt(self, flow_rate: float) -> float: """ Calculate mean residence time of a volume element in an empty column. Parameters ---------- flow_rate : float Volumetric flow rate. Returns ------- t_s : float Mean residence time of empty column. See Also -------- calculate_superficial_velocity calculate_interstitial_rt """ return self.volume / flow_rate def calculate_interstitial_velocity(self, flow_rate: float) -> float: """ Calculate flow velocity of a (non adsorbing) volume element. Parameters ---------- flow_rate : float Volumetric flow rate. Returns ------- interstitial_velocity : float Interstitial flow velocity. See Also -------- calculate_interstitial_rt calculate_superficial_velocity """ return self.length / self.calculate_interstitial_rt(flow_rate) def calculate_superficial_velocity(self, flow_rate: float) -> float: """ Calculate superficial flow velocity of a volume element in an empty column. Parameters ---------- flow_rate : float Volumetric flow rate. Returns ------- u_s : float Superficial flow velocity. See Also -------- calculate_superficial_rt calculate_interstitial_velocity NTP """ return self.length / self.calculate_superficial_rt(flow_rate) def calculate_flow_rate_from_velocity(self, u0: float) -> float: """ Calculate volumetric flow rate from interstitial velocity. Parameters ---------- u0 : float Interstitial flow velocity. Returns ------- Q : float Volumetric flow rate. See Also -------- calculate_interstitial_velocity calculate_interstitial_rt """ return u0 * self.cross_section_area_interstitial def NTP(self, flow_rate: float) -> float: r""" Calculate number of theoretical plates. Parameters ---------- flow_rate : float volumetric flow rate Calculated using the axial dispersion coefficient: .. math:: NTP = \frac{u \cdot L_{Column}}{2 \cdot D_a} Returns ------- NTP : float Number of theretical plates """ u0 = self.calculate_interstitial_velocity(flow_rate) return u0 * self.length / (2 * np.array(self.axial_dispersion)) def set_axial_dispersion_from_NTP(self, NTP: float, flow_rate: float) -> float: r""" Set axial dispersion from number of theoretical plates (NTP). Parameters ---------- NTP : float Number of theroetical plates flow_rate : float volumetric flow rate Calculated using the axial dispersion coefficient: .. math:: NTP = \frac{u_{int} \cdot L_{Column}}{2 \cdot D_{ax}} Returns ------- NTP : float Number of theretical plates See Also -------- calculate_interstitial_velocity NTP """ u0 = self.calculate_interstitial_velocity(flow_rate) self.axial_dispersion = u0 * self.length / (2 * NTP) def calculate_bodenstein_number(self, flow_rate: float) -> float: r""" Calculate the Bodenstein number for a given flow rate. Parameters ---------- flow_rate : float volumetric flow rate The Bodenstein number is calculated as follows: .. math:: Bo = \frac{u_{int} \cdot L_{Column}}{D_{ax}}, where $u_int$ is the interstitial velocity Returns ------- Bo : float Bodenstein number See Also -------- calculate_interstitial_velocity NTP """ u0 = self.calculate_interstitial_velocity(flow_rate) return u0 * self.length / np.array(self.axial_dispersion)
[docs] class TubularReactor(TubularReactorBase): """ Class for tubular reactors and tubing. Class can be used for a regular tubular reactor. Attributes ---------- c : List of unsigned floats. Length depends on n_comp Initial concentration of the reactor. solution_recorder : TubularReactorRecorder Solution recorder for the unit operation. """ supports_bulk_reaction = True discretization_schemes = (LRMDiscretizationFV, LRMDiscretizationDG) total_porosity = Constant(1) def __init__( self, *args: Any, discretization_scheme: Optional[str] = "FV", **kwargs: Any ) -> None: if discretization_scheme == "FV": discretization = LRMDiscretizationFV() elif discretization_scheme == "DG": discretization = LRMDiscretizationDG() super().__init__( *args, discretization=discretization, solution_recorder=TubularReactorRecorder(), **kwargs, )
class ChromatographicColumnBase(TubularReactorBase): """Base class for chromatogrpahic columns.""" pass
[docs] class LumpedRateModelWithoutPores(ChromatographicColumnBase): """ Parameters for a lumped rate model without pores. Attributes ---------- total_porosity : UnsignedFloat between 0 and 1. Total porosity of the column. c : List of unsigned floats. Length depends on n_comp Initial concentration of the reactor. q : List of unsigned floats. Length depends on n_comp Initial concentration of the bound phase. solution_recorder : LRMRecorder Solution recorder for the unit operation. Notes ----- Although technically the LumpedRateModelWithoutPores does not have particles, the particle reactions interface is used to support reactions in the solid phase and cross-phase reactions. """ supports_binding = True supports_bulk_reaction = False supports_particle_reaction = True discretization_schemes = (LRMDiscretizationFV, LRMDiscretizationDG) total_porosity = UnsignedFloat(ub=1) _parameters = ["total_porosity"] _q = SizedUnsignedList(size="n_bound_states", default=0) _initial_state = ChromatographicColumnBase._initial_state + ["q"] _parameters = _parameters + _initial_state def __init__( self, *args: Any, discretization_scheme: Optional[str] = "FV", **kwargs: Any ) -> None: super().__init__(*args, **kwargs) if discretization_scheme == "FV": self.discretization = LRMDiscretizationFV() elif discretization_scheme == "DG": self.discretization = LRMDiscretizationDG() self.solution_recorder = LRMRecorder() @property def q(self) -> list: """list[float]: Initial solid phase concentration.""" return self._q @q.setter def q(self, q: list) -> None: if isinstance(self.binding_model, NoBinding) and q not in (None, []): raise CADETProcessError("Cannot set q without binding model.") self._q = q self.parameters["q"] = self._q
[docs] class LumpedRateModelWithPores(ChromatographicColumnBase): """ Parameters for the lumped rate model with pores. Attributes ---------- bed_porosity : UnsignedFloat between 0 and 1. Porosity of the bed particle_porosity : UnsignedFloat between 0 and 1. Porosity of particles. particle_radius : UnsignedFloat Radius of the particles. film_diffusion : List of unsigned floats. Length depends on n_comp. Film diffusion coefficients for each component. pore_accessibility : List of unsigned floats. Length depends on n_comp. Accessibility of pores for components. c : List of unsigned floats. Length depends on n_comp Initial concentration of the reactor. cp : List of unsigned floats. Length depends on n_comp Initial concentration of the pores q : List of unsigned floats. Length depends on n_comp Initial concntration of the bound phase. solution_recorder : LRMPRecorder Solution recorder for the unit operation. """ supports_binding = True supports_bulk_reaction = True supports_particle_reaction = True discretization_schemes = (LRMPDiscretizationFV, LRMPDiscretizationDG) bed_porosity = UnsignedFloat(ub=1) particle_porosity = UnsignedFloat(ub=1) particle_radius = UnsignedFloat() film_diffusion = SizedUnsignedList(size="n_comp") pore_accessibility = SizedUnsignedList(ub=1, size="n_comp", default=1) _parameters = [ "bed_porosity", "particle_porosity", "particle_radius", "film_diffusion", "pore_accessibility", ] _section_dependent_parameters = TubularReactorBase._section_dependent_parameters + [ "film_diffusion", "pore_accessibility", ] _cp = SizedUnsignedList(size="n_comp") _q = SizedUnsignedList(size="n_bound_states", default=0) _initial_state = ChromatographicColumnBase._initial_state + ["cp", "q"] _parameters = _parameters + _initial_state def __init__( self, *args: Any, discretization_scheme: Optional[str] = "FV", **kwargs: Any ) -> None: super().__init__(*args, **kwargs) if discretization_scheme == "FV": self.discretization = LRMPDiscretizationFV() elif discretization_scheme == "DG": self.discretization = LRMPDiscretizationDG() self.solution_recorder = LRMPRecorder() @property def total_porosity(self) -> float: """float: Total porosity of the column.""" return self.bed_porosity + (1 - self.bed_porosity) * self.particle_porosity @property def cross_section_area_interstitial(self) -> float: """float: Interstitial area between particles. See Also -------- cross_section_area """ return self.bed_porosity * self.cross_section_area
[docs] def set_diameter_from_interstitial_velocity(self, Q: float, u0: float) -> None: """ Set diamter from flow rate and interstitial velocity. In literature, often only the interstitial velocity is given. This method, the diameter / cross section area can be inferred from the flow rate, velocity, and bed porosity. Parameters ---------- Q : float Volumetric flow rate. u0 : float Interstitial velocity. Notes ----- Overwrites parent method. """ self.cross_section_area = Q / (u0 * self.bed_porosity)
@property def cp(self) -> list[float]: """list[float]: Initial particle liquid concentration.""" if self._cp is None: return self.c else: return self._cp @cp.setter def cp(self, cp: list[float]) -> None: self._cp = cp self.parameters["cp"] = cp @property def q(self) -> list[float]: """list[float]: Initial solid phase concentration.""" return self._q @q.setter def q(self, q: list) -> None: if isinstance(self.binding_model, NoBinding) and q not in (None, []): raise CADETProcessError("Cannot set q without binding model.") self._q = q self.parameters["q"] = q
[docs] class GeneralRateModel(ChromatographicColumnBase): """ Parameters for the general rate model. Attributes ---------- bed_porosity : UnsignedFloat between 0 and 1. Porosity of the bed particle_porosity : UnsignedFloat between 0 and 1. Porosity of particles. particle_radius : UnsignedFloat Radius of the particles. film_diffusion : List of unsigned floats. Length depends on n_comp. Film diffusion coefficients for each component. pore_accessibility : List of unsigned floats. Length depends on n_comp. Accessibility of pores for components. pore_diffusion : List of unsigned floats. Length depends on n_comp. Diffusion rate for components in pore volume. surface_diffusion : List of unsigned floats. Length depends on n_comp. Diffusion rate for components in adsorbed state. c : List of unsigned floats. Length depends on n_comp Initial concentration in the mobile phase. cp : List of unsigned floats. Length depends on n_comp Initial concentration in the stagnant mobile phase within the pores. q : List of unsigned floats. Length depends on n_comp Initial concentration of the bound phase. solution_recorder : GRMRecorder Solution recorder for the unit operation. """ supports_binding = True supports_bulk_reaction = True supports_particle_reaction = True discretization_schemes = (GRMDiscretizationFV, GRMDiscretizationDG) bed_porosity = UnsignedFloat(ub=1) particle_porosity = UnsignedFloat(ub=1) particle_radius = UnsignedFloat() film_diffusion = SizedUnsignedList(size="n_comp") pore_accessibility = SizedUnsignedList(ub=1, size="n_comp", default=1) pore_diffusion = SizedUnsignedList(size="n_comp") _surface_diffusion = SizedUnsignedList(size="n_bound_states") _parameters = [ "bed_porosity", "particle_porosity", "particle_radius", "film_diffusion", "pore_accessibility", "pore_diffusion", "surface_diffusion", ] _section_dependent_parameters = TubularReactorBase._section_dependent_parameters + [ "film_diffusion", "pore_accessibility", "pore_diffusion", "surface_diffusion", ] _cp = SizedUnsignedList(size="n_comp") _q = SizedUnsignedList(size="n_bound_states", default=0) _initial_state = ChromatographicColumnBase._initial_state + ["cp", "q"] _parameters = _parameters + _initial_state def __init__( self, *args: Any, discretization_scheme: Optional[str] = "FV", **kwargs: Any ) -> None: super().__init__(*args, **kwargs) if discretization_scheme == "FV": self.discretization = GRMDiscretizationFV() elif discretization_scheme == "DG": self.discretization = GRMDiscretizationDG() self.solution_recorder = GRMRecorder() @property def total_porosity(self) -> float: """float: Total porosity of the column.""" return self.bed_porosity + (1 - self.bed_porosity) * self.particle_porosity @property def cross_section_area_interstitial(self) -> float: """float: Interstitial area between particles. See Also -------- cross_section_area """ return self.bed_porosity * self.cross_section_area
[docs] def set_diameter_from_interstitial_velocity(self, Q: float, u0: float) -> None: """ Set diamter from flow rate and interstitial velocity. In literature, often only the interstitial velocity is given. This method, the diameter / cross section area can be inferred from the flow rate, velocity, and bed porosity. Parameters ---------- Q : float Volumetric flow rate. u0 : float Interstitial velocity. Notes ----- Overwrites parent method. """ self.cross_section_area = Q / (u0 * self.bed_porosity)
@property def cp(self) -> list[float]: """list[float]: Initial particle liquid concentration.""" if self._cp is None: return self.c else: return self._cp @cp.setter def cp(self, cp: list[float]) -> None: self._cp = cp self.parameters["cp"] = cp @property def q(self) -> list[float]: """list[float]: Initial solid phase concentration.""" return self._q @q.setter def q(self, q: list[float]) -> None: if isinstance(self.binding_model, NoBinding) and q not in (None, []): raise CADETProcessError("Cannot set q without binding model.") self._q = q self.parameters["q"] = q @property def surface_diffusion(self) -> list[float]: """list[float] | None: Surface diffusion coefficients.""" return self._surface_diffusion @surface_diffusion.setter def surface_diffusion(self, surface_diffusion: list[float]) -> None: if isinstance(self.binding_model, NoBinding): raise CADETProcessError( "Cannot set surface diffusion without binding model." ) self._surface_diffusion = surface_diffusion self.parameters["surface_diffusion"] = surface_diffusion
[docs] class Cstr(UnitBaseClass, SourceMixin, SinkMixin): """ Parameters for an ideal mixer. Parameters ---------- c : List of unsigned floats. Length depends on n_comp Initial concentration of the reactor. q : List of unsigned floats. Length depends on n_comp Initial concentration of the bound phase. V : unsigned float Initial volume of the reactor. porosity : UnsignedFloat between 0 and 1. Total porosity of the Cstr. initial_liquid_volume : UnsignedFloat above 0. Initial liquid volume of the reactor. const_solid_volume : UnsignedFloat above or equal 0. Initial and constant solid volume of the reactor. flow_rate_filter: float Flow rate of pure liquid without components to reduce volume. solution_recorder : CSTRRecorder Solution recorder for the unit operation. Notes ----- CADET generally supports particle reactions for the CSTR, however, this is currently not exposed since there are some issues with the interface (. """ supports_binding = True supports_bulk_reaction = True supports_particle_reaction = False flow_rate_filter = UnsignedFloat(default=0) _parameters = ["const_solid_volume", "flow_rate_filter"] _section_dependent_parameters = ( UnitBaseClass._section_dependent_parameters + SourceMixin._section_dependent_parameters + ["flow_rate_filter"] ) c = SizedFloatList(size="n_comp", default=0) _q = SizedUnsignedList(size="n_bound_states", default=0) init_liquid_volume = UnsignedFloat() const_solid_volume = UnsignedFloat(default=0) _V = UnsignedFloat() _initial_state = UnitBaseClass._initial_state + ["c", "q", "init_liquid_volume"] _parameters = _parameters + _initial_state def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.solution_recorder = CSTRRecorder() @property def required_parameters(self) -> list: """ Remove 'flow_rate' from required parameters. If flow rate is None, Q_in == Q_out'. """ required_parameters = super().required_parameters.copy() required_parameters.remove("flow_rate") return required_parameters @property def porosity(self) -> float: """float: Porosity of the unit operation.""" if self.const_solid_volume is None or self.init_liquid_volume is None: return None return self.init_liquid_volume / ( self.init_liquid_volume + self.const_solid_volume ) @porosity.setter def porosity(self, porosity: float) -> None: warnings.warn( "Field POROSITY is only supported for backwards compatibility, but the implementation " "of the CSTR has changed, please refer to the documentation. The POROSITY will be " "used to compute the constant solid volume from the total volume V." ) if self.V is None: raise RuntimeError("Please set the volume first before setting a porosity.") self.const_solid_volume = self.V * (1 - porosity) self.init_liquid_volume = self.V * porosity @property def V(self) -> float: """float: Total volume of the unit operation.""" return self._V @V.setter def V(self, V: float) -> None: warnings.warn( "The field V is only supported for backwards compatibility. " "Please set initial_liquid_volume and const_solid_volume." ) self.init_liquid_volume = V self._V = V @property def volume(self) -> float: """float: Alias for volume.""" return self.const_solid_volume + self.init_liquid_volume @property def volume_liquid(self) -> float: """float: Volume of the liquid phase.""" return self.init_liquid_volume @property def volume_solid(self) -> float: """float: Volume of the solid phase.""" return self.const_solid_volume
[docs] def calculate_interstitial_rt(self, flow_rate: float) -> float: """ Calculate mean residence time of a (non adsorbing) volume element. Parameters ---------- flow_rate : float Volumetric flow rate. Returns ------- t0 : float Mean residence time See Also -------- calculate_interstitial_velocity """ return self.volume_liquid / flow_rate
@property def q(self) -> list: """list[float]: Initial solid phase concentration.""" return self._q @q.setter def q(self, q: list) -> None: if isinstance(self.binding_model, NoBinding) and q not in (None, []): raise CADETProcessError("Cannot set q without binding model.") self._q = q self.parameters["q"] = q
[docs] class MCT(UnitBaseClass): """ Parameters for multi-channel transportmodel. Parameters ---------- length : UnsignedFloat Length of column. channel_cross_section_areas : List of unsinged floats. Lenght depends on nchannel. Diameter of column. axial_dispersion : List of unsigned floats. Length depends on n_comp and nchannel. Axial dispersion coefficient for component and each component. flow_direction : Switch If 1: Forward flow. If -1: Backwards flow. c : List of unsigned floats. Length depends n_comp or n_comp*nchannel. Initial concentration for components. exchange_matrix : List of unsigned floats. Lenght depends on nchannel. solution_recorder : MCTRecorder Solution recorder for the unit operation. n_channel : int Number of channels """ has_ports = True supports_bulk_reaction = True discretization_schemes = MCTDiscretizationFV length = UnsignedFloat() channel_cross_section_areas = SizedFloatList(size="nchannel") axial_dispersion = SizedUnsignedList(size=("n_comp", "nchannel")) flow_direction = Switch(valid=[-1, 1], default=1) exchange_matrix = SizedNdArray(size=("nchannel", "nchannel", "n_comp")) _parameters = [ "length", "channel_cross_section_areas", "axial_dispersion", "flow_direction", "exchange_matrix", "nchannel", ] _section_dependent_parameters = UnitBaseClass._section_dependent_parameters + [ "axial_dispersion", "flow_direction", ] _section_dependent_parameters = UnitBaseClass._section_dependent_parameters + [] c = SizedNdArray(size=("n_comp", "nchannel"), default=0) _initial_state = UnitBaseClass._initial_state + ["c"] _parameters = _parameters + _initial_state def __init__(self, *args: Any, nchannel: int, **kwargs: Any) -> None: """Initialize MCT.""" discretization = MCTDiscretizationFV() self._nchannel = nchannel super().__init__( *args, discretization=discretization, solution_recorder=MCTRecorder(), **kwargs, ) @property def nchannel(self) -> int: """int: The number of channels in the MCT.""" return self._nchannel @nchannel.setter def nchannel(self, nchannel: int) -> None: self._nchannel = nchannel @property def ports(self) -> list: """list[str]: Ports of the unit operation.""" return [f"channel_{i}" for i in range(self.nchannel)] @property def n_ports(self) -> int: """int: Number of ports (here the number of channels).""" return self.nchannel @property def volume(self) -> float: """ float: Combined Volumes of all channels. See Also -------- channel_cross_section_areas """ return sum(self.channel_cross_section_areas) * self.length @property def volume_liquid(self) -> float: """float: Volume of the liquid phase. Equals the volume, since there is no solid phase.""" return self.volume @property def volume_solid(self) -> float: """float: Volume of the solid phase. Equals zero, since there is no solid phase.""" return 0