Source code for biosteam.units.adsorption

# -*- coding: utf-8 -*-
# BioSTEAM: The Biorefinery Simulation and Techno-Economic Analysis Modules
# Copyright (C) 2020-2021, Yoel Cortes-Pena <yoelcortes@gmail.com>
# 
# This module is under the UIUC open-source license. See 
# github.com/BioSTEAMDevelopmentGroup/biosteam/blob/master/LICENSE.txt
# for license details.
"""
.. contents:: :local:

.. autoclass:: biosteam.units.adsorption.SingleComponentAdsorptionColumn

References
----------
.. [1] Adsorption basics Alan Gabelman (2017) Adsorption basics Part 1. AICHE

.. [2] Seader, J. D., Separation Process Principles: Chemical and Biochemical Operations,” 3rd ed., Wiley, Hoboken, NJ (2011).

.. [3] Seider, W. D., Lewin,  D. R., Seader, J. D., Widagdo, S., Gani,
    R., & Ng, M. K. (2017). Product and Process Design Principles. Wiley.
    Cost Accounting and Capital Cost Estimation (Chapter 16)

"""
import biosteam as bst
from .design_tools import PressureVessel
from numpy.testing import assert_allclose
import numpy as np
from numba import njit
import flexsolve as flx
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp
from scipy.ndimage.filters import gaussian_filter
from thermosteam.units_of_measure import format_units

__all__ = ('SingleComponentAdsorptionColumn', 'AdsorptionColumn',)

@njit(cache=True)
def fixed_step_odeint(f, x0, step, tf, args):
    M = int(tf/step)
    N = len(x0)
    ts = np.linspace(0, tf, M)
    xs = np.zeros((M, N))
    xs[0] = x0
    for i in range(1, M):
        dx_dt = f(ts[i], x0, *args)
        x0 += dx_dt * step
        xs[i] = x0 
    return (ts, xs)

@njit(cache=True)
def equilibrium_loading_Langmuir_isotherm(
        C,  # Solute concentration in the feed [g / L]
        KL,  # Equilibrium constant
        q_max,  # Maximum equilibrium loading mg/g
    ):
    return C * KL * q_max / (1 + KL * C)  # g / kg

@njit(cache=True)
def equilibrium_loading_Freundlich_isotherm(
        C,  # Solute concentration in the feed [g / L]
        KL,  
        n,  
    ):
    return KL * C**(1/n) # g / kg

@njit(cache=True)
def estimate_equilibrium_bed_length(
        C,  # Solute concentration in the feed [kg / m3]
        u,  # Superficial velocity [m / h]
        cycle_time,  # Cycle time [h]
        rho_adsorbent,  # Bulk density of the bed [kg / m3]
        q0,  # Equilibrium loading [g / kg]
    ):
    q_capacity = q0 * rho_adsorbent / 1000 # kg / m3
    L = C * u * cycle_time / q_capacity  # m
    return L

@njit(cache=True)
def dCdt_optimized_Langmuir(
        t, C, N_slices, 
        Da, dz, KL_qm,
        q0_over_C0,
        q0_KL,
        beta_q0_rho_over_C0,
    ):
    CL = C[:N_slices] 
    qt = C[N_slices:] # q-avg
    CL_ = CL.copy()
    CL_[CL < 0] = 0
    qe = (CL_ * KL_qm)/(q0_over_C0 + q0_KL * CL_)
    dCL_dz = CL_
    dCL_dz[1:] = (CL[1:] - CL[:-1]) / dz
    dCL_dz[0] = (CL[0] - 1) / dz
    dC_dt = C.copy()
    dq_dt = Da * (qe - qt)
    dCL_dt = -dCL_dz - beta_q0_rho_over_C0 * dq_dt
    dC_dt[:N_slices] = dCL_dt
    dC_dt[N_slices:] = dq_dt
    return dC_dt 

@njit(cache=True)
def dCdt(
        t, C, N_slices, 
        Da, dz, isotherm_model, isotherm_args,
        beta_q0_rho_over_C0, C0, q0
    ):
    CL = C[:N_slices] 
    qt = C[N_slices:] # q-avg
    CL_ = CL * C0
    mask = CL_ < 0
    CL_[mask] = 0
    qe = isotherm_model(CL_, *isotherm_args) / q0
    qe[qe > q0] = q0
    dCL_dz = CL.copy()
    dCL_dz[1:] = (CL[1:] - CL[:-1]) / dz
    dCL_dz[0] = (CL[0] - 1) / dz
    dC_dt = C.copy()
    dq_dt = Da * (qe - qt)
    dCL_dt = -dCL_dz - beta_q0_rho_over_C0 * dq_dt
    dC_dt[:N_slices] = dCL_dt
    dC_dt[N_slices:] = dq_dt
    return dC_dt 

def adsorption_bed_pressure_drop(
        D = 0.001, # particle diameter [m]
        rho = 800, # liquid density [kg/m3]
        mu = .001, # viscosity [kg/m*s]
        epsilon = 1 - 0.38/0.8, # void fraction
        u = 14/3600, # superficial velosity [m/s]
        L = 5, # length of bed [m]
    ):
    re = 1 - epsilon
    e2 = epsilon * epsilon
    e3 = e2 * epsilon
    N_Re = D * u * rho/(mu*re)
    if N_Re < 10: # Kozeny-Carman equation
        dP = 150 * mu * u * re ** 2 * L/(D ** 2 * e3)
    elif 10 < N_Re < 1000: # Ergun equation 
        dP = 1.75 * u**2 * L * re * rho/(D * e3) + 150 * mu * u * re**2/(D**2 * e3)
    else: # Burke-Plummer equation
        dP = 1.75 * u**2 * L * re * rho/(D * e3) + 150
    return dP

[docs] class SingleComponentAdsorptionColumn(PressureVessel, bst.Unit): """ Create an adsorption column which can operate by temperature or pressure swing, or by disposing the adsorbent. Heats of adsorption are neglected but may become an option with future development in BioSTEAM. Default parameters are heuristic values for adsorption of water and polar components using activated carbon. The design and cost algorithm of the adsorption column is based on [1]_, [2]_, and [3]_. By default, three vessels are used: a lead, a guard, and a standby vessel. The lead is in equilibrium, guard holds the breakthrough curve and mass transfer zone, and the standby is in regeneration. Once the standby vessel is regenerated, it becomes the guard, the lead becomes the standby, and the guard becomes the lead. Therefore, the cycle time is the regeneration time. For temperature or pressure swing adsorption, set the temperature or pressure of the regeneration fluid. If a regeneration fluid is used, the flow rate of regeneration fluid solved for such that it meets the required cycle time. Parameters ---------- ins : * [0] Feed * [1] Regeneration fluid * [2] Adsorbent (if no regeneration fluid is used). outs : * [0] Effluent * [1] Purge * [2] Spent adsorbent (if no regeneration fluid is used) k : float, optional Mass transfer coefficient [1/h]. If not given, mass tranfer model is ignored. superficial_velocity : float, optional Superficial velocity of the feed. The diameter of the receiving vessel adjusts accordingly. Defaults to 7.2 [m / hr]. Typical velocities are 4 to 14.4 m / hr for liquids [1]_. Common velocity range for gasses is 504-2160 m / hr [1]_. cycle_time : float, optional Time at which the receiving vessel is switched. Defaults to 3 [hr]. Note that 1-2 hours required for thermal-swing-adsorption (TSA) for silica gels [2]_. isotherm_args : tuple[float, ...], optional Arguments to pass to the isotherm model. If isotherm model is 'Langmuir', arguments must be: * KL: Equilibrium constant [L/mg] for Langmuir isotherm * q_max: Maximum equilibrium loading [mg/g] for Langmuir isotherm If isotherm model is 'Freundlich', arguments must be: * K: Equilibrium constant [L/mg] for Freundlich isotherm * n: exponential coefficient for Freundlich isotherm isotherm_model : Callable|str, optional, Can be 'Langmuir', 'Freundlich', or a function. If a function is given, it should be in the form of f(C, *args) -> g absorbate / kg absorbent. void_fraction : 0.525 Fraction of empty space in the adsorbent by vol. rho_adsorbent : float, optional The density of the adsorbent. Defaults to 380 [kg/m3], which is common for activated carbon granules. regeneration_fluid : dict[str, float] Arguments to initialize fluid used to regenerate the bed. LUB : float, optional Length of unused bed. Defaults to 1.219 m if no mass transfer coefficient is given. This parameter is ignored if isotherms are provided. P : float, optional Operating pressure of the column. Defaults to 101325. C_final_scaled : float, optional Final outlet concentration at breakthrough relative to inlet. Defaults to 0.05. regeneration_fluid : dict[str, float] Regeneration fluid composition and thermal conditions. k_regeneration : float, optional Mass transfer coefficient of the regeration solvent [1/h]. regeneration_isotherm_args : tuple[float, ...], optional Arguments to regeneration isotherm model. regeneration_isotherm_model : Callable|str Isotherm model for regenerating the adsorbent. N_slices : int, optional Number of slices to model mass transfer. Defaults to 80. adsorbate : str Name of adsorbate. vessel_material : float, optional Vessel material. Defaults to 'Stainless steel 316', vessel_type : float, optional Vessel type. Defaults to 'Vertical'. adsorbent : str Name of adsorbent. Defaults to 'ActivatedCarbon'. N_columns : int Number of columns can be either 2 or 3. The 3-column configuration minimizes cost of adsorbent regeneration. The 2-column configuration minimizes capital cost. particle_diameter : float Particle diameter assumed for pressure drop estimation [m]. Defaults to 0.004 m. Examples -------- >>> import biosteam as bst >>> bst.settings.set_thermo([ ... 'Water', ... bst.Chemical('Adsorbate', search_db=False, default=True, phase='l'), ... bst.Chemical('ActivatedCarbon', search_db=False, default=True, phase='s') ... ]) >>> feed = bst.Stream(ID='feed', phase='l', T=298, P=1.01e+06, ... Water=1000, Adsorbate=0.001, units='kg/hr') >>> A1 = bst.AdsorptionColumn( ... 'A1', ins=feed, outs='effluent', ... cycle_time=1000, ... superficial_velocity=9.2, ... isotherm_model='Langmuir', ... isotherm_args=(1e3, 7.), # K [g / L], q_max [g / kg] ... k=0.3, # [1 / hr] ... void_fraction=0.525, ... C_final_scaled=0.05, ... adsorbate='Adsorbate', ... particle_diameter=0.004, ... ) >>> A1.simulate() >>> # A1.simulation_gif() # Create a gif of the fluid concentration profile over time. .. image:: ../../images/adsorption_column.gif :align: center :alt: Adsorption column fluid concentration profile over time. >>> A1.results() Single component adsorption column Units A1 Electricity Power kW 0.241 Cost USD/hr 0.0188 Design Diameter ft 1.22 Length ft 12.2 Vessel type Vertical Weight lb 521 Wall thickness in 0.25 Pressure drop Pa 103 Vessel material Stainless steel 316 Purchase cost Vertical pressure vessel (x3) USD 6.12e+04 Platform and ladders (x3) USD 8.37e+03 Pump - Pump (x3) USD 1.31e+04 Pump - Motor (x3) USD 174 Total purchase cost USD 8.28e+04 Utility cost USD/hr 0.0188 >>> A1.show('wt') SingleComponentAdsorptionColumn: A1 ins... [0] feed phase: 'l', T: 298 K, P: 1.01e+06 Pa flow (kg/hr): Water 1e+03 Adsorbate 0.001 [1] - phase: 'l', T: 298.15 K, P: 101325 Pa flow: 0 [2] - phase: 'l', T: 298.15 K, P: 101325 Pa flow (kg/hr): ActivatedCarbon 0.154 outs... [0] effluent phase: 'l', T: 298 K, P: 101325 Pa flow (kg/hr): Water 1e+03 [1] - phase: 'l', T: 298.15 K, P: 101325 Pa flow: 0 [2] - phase: 'l', T: 298.15 K, P: 101325 Pa flow (kg/hr): ActivatedCarbon 0.154 """ auxiliary_unit_names = ( 'pump', 'heat_exchanger', 'regeneration_pump' ) _N_ins = 3 _N_outs = 3 _units = {'Pressure drop': 'Pa', **PressureVessel._units} # Cost of regeneration in $/m3 adsorbent_cost = { 'ActivatedAlumina': 72 * 0.0283168, 'ActivatedCarbon': 41 * 0.0283168, 'SilicaGel': 210 * 0.0283168, 'MolecularSieves': 85 * 0.0283168, } # TODO: Update later with reference # This is only active if modeling regeneration of solute. _default_equipment_lifetime = { 'ActivatedAlumina': 100, 'ActivatedCarbon': 100, 'SilicaGel': 100, 'MolecularSieves': 100, } isotherm_models = { 'Langmuir': equilibrium_loading_Langmuir_isotherm, 'Freundlich': equilibrium_loading_Freundlich_isotherm, } def _init(self, cycle_time, # If not given, mass tranfer model is ignored k=None, # Mass transfer coefficient [1/h] # Langmuir: # - KL: Equilibrium constant [L/mg] for Langmuir isotherm # - q_max: Maximum equilibrium loading [mg/g] for Langmuir isotherm # Freundlich: # - K: Equilibrium constant [L/mg] for Freundlich isotherm # - n: exponential coefficient for Freundlich isotherm isotherm_args=None, # Can be a function, 'Langmuir', or 'Freundlich'. # If a function is given, it should be in the form of f(C, *args) -> g absorbate / kg absorbent isotherm_model=None, k_regeneration=None, # Mass transfer coefficient of the regeration solvent [1/h] regeneration_isotherm_args=None, regeneration_isotherm_model=None, # Note that the columns are sized according to the limiting isotherm. LUB_forced=None, # Defaults to 0.6096 m if no mass transfer coefficient is given. void_fraction=1 - 0.38 / 0.8, # Solid by vol [%] rho_adsorbent=380, # kg/m3 superficial_velocity=14.4, # [m / h] P=101325, C_final_scaled=0.05, # Final outlet concentration at breakthrough relative to inlet. N_slices=int(50), # Number of slices to model mass transfer. regeneration_fluid=None, # [dict] Regeneration fluid composition and thermal conditions. adsorbate=None, # [str] Name of adsorbate. vessel_material='Stainless steel 316', vessel_type='Vertical', adsorbent='ActivatedCarbon', N_columns=3, # 3 column configuration minimizes cost of adsorbent regeneration. 2 column configuration minimizes capital cost. particle_diameter = 0.004, # [m] ): if N_columns not in (2, 3): raise ValueError('only 2 or 3-column configurations are valid') if isotherm_model is None: if isotherm_args is not None: raise ValueError('no isotherm model given') isotherm_model = lambda C: 0.1 elif isinstance(isotherm_model, str): if isotherm_model in self.isotherm_models: isotherm_model = self.isotherm_models[isotherm_model] else: raise ValueError( f'isotherm model {isotherm_model} is not available; ' f'valid options are {list(self.isotherm_models)}' ) if regeneration_isotherm_model is None: if regeneration_isotherm_args is not None: raise ValueError('no regeneration isotherm model given') elif isinstance(isotherm_model, str): if regeneration_isotherm_model in self.isotherm_models: regeneration_isotherm_model = self.isotherm_models[regeneration_isotherm_model] else: raise ValueError( f'isotherm model {regeneration_isotherm_model} is not available; ' f'valid options are {list(self.isotherm_models)}' ) if k is None and k_regeneration is None: if LUB_forced is None: LUB_forced = 0.6096 elif LUB_forced is not None: raise ValueError('length of unused bed given, but will be ' 'estimated based on mass transfer modeling') if regeneration_isotherm_args is None: regeneration_isotherm_args = () if isotherm_args is None: isotherm_args = () self.adsorbate = adsorbate self.regeneration_fluid = regeneration_fluid self.N_columns = N_columns self.cycle_time = cycle_time self.superficial_velocity = superficial_velocity self.P = P self.isotherm_args = isotherm_args self.isotherm_model = isotherm_model self.regeneration_isotherm_args = regeneration_isotherm_args self.regeneration_isotherm_model = regeneration_isotherm_model self.k = k self.k_regeneration = k_regeneration self.void_fraction = void_fraction self.rho_adsorbent = rho_adsorbent self.C_final_scaled = C_final_scaled self.vessel_material = vessel_material self.vessel_type = vessel_type self.N_slices = N_slices self.particle_diameter = particle_diameter self.adsorbent = adsorbent self.LUB_forced = LUB_forced self.auxiliary('pump', bst.Pump, ins=self.ins[0]) if regeneration_fluid: regeneration_pump = self.auxiliary('regeneration_pump', bst.Pump, ins=self.ins[1]) self.auxiliary('heat_exchanger', bst.HXutility, ins=regeneration_pump.outs[0]) def _run(self): feed, regeneration_fluid, adsorbent = self.ins outlet, spent_fluid, spent_adsorbent = self.outs regeneration_fluid.empty() spent_fluid.empty() adsorbent.empty() spent_adsorbent.empty() outlet.copy_like(feed) outlet.P = self.P outlet.imol[self.adsorbate] = 0 if self.regeneration_fluid: regeneration_fluid = self.heat_exchanger.outs[0] regeneration_fluid.reset(**self.regeneration_fluid) self.heat_exchanger.T = regeneration_fluid.T self._size_columns() self.regeneration_superficial_velocity = u = self._solve_regeneration_velocity() Q = self.area * u regeneration_fluid.F_vol = Q * 3600 # m3/s to m3/h self.heat_exchanger.ins[0].copy_flow(regeneration_fluid) self.heat_exchanger.run() spent_fluid.copy(regeneration_fluid) spent_fluid.imol[self.adsorbate] = feed.imol[self.adsorbate] def column_widget(self): # Not currently working import ipywidgets as widgets from ipywidgets import interact t = self.t N_time = len(t) tf = t[-1] z = np.arange(0, 1, 1/self.N_slices) CL_scaled = self.CL_scaled plt.xlabel('z') plt.yticks([0, 0.2, 0.4, 0.6, 0.8, 1]) plt.ylabel('Concentration') def plot(time): time_index = int(time / tf * N_time) - 1 plt.clf() plt.plot(z, CL_scaled[:, time_index], label = '$\theta$ vs z') plt.show() slider = widgets.FloatSlider(min=0, max=tf, step=tf/100, value=0.5*tf) return interact(plot, time=slider) def simulation_gif(self, liquid=True): import matplotlib.animation as animation from scipy.interpolate import interp1d t = self.t_scaled z = np.arange(0, 1, 1/self.N_slices) y = self.CL_scaled if liquid else self.q_scaled fig = plt.figure() N_frames = 120 f = interp1d(t, y) x = np.linspace(0, t[-1], N_frames) y = f(x) def animate(frame): plt.clf() artist = plt.plot(z, y[:, frame]) plt.xlabel(r'z = x / L') plt.ylabel(r'$\theta$ = C / C$_{feed}$') plt.yticks([0, 0.2, 0.4, 0.6, 0.8, 1]) plt.ylim([0, 1]) return (artist,) ani = animation.FuncAnimation( fig, animate, repeat=True, frames=N_frames - 1, interval=66, ) # To save the animation using Pillow as a gif writer = animation.PillowWriter(fps=15, metadata=dict(artist='Me'), bitrate=1800) ani.save('adsorption_column.gif', writer=writer) return ani def _simulate_adsorption_bed( self, regeneration, L, # m u, # [m / hr] ): cycle_time = self.cycle_time N_slices = self.N_slices C_init = np.zeros(2 * N_slices) void_fraction = self.void_fraction rho_adsorbent = self.rho_adsorbent if regeneration: C_init[N_slices:] = 1 # saturated column for regeneration isotherm_model = self.regeneration_isotherm_model isotherm_args = self.regeneration_isotherm_args k = self.k_regeneration else: k = self.k isotherm_model = self.isotherm_model isotherm_args = self.isotherm_args Da = k * L / u beta = (1 - void_fraction)/void_fraction self.dz = dz = 1 / (N_slices - 1) t_scale = (L / u) C0 = self.C0 q0 = isotherm_model(C0, *isotherm_args) beta_q0_rho_over_C0 = (beta * q0 * rho_adsorbent / 1000) / C0 if isotherm_model is equilibrium_loading_Langmuir_isotherm: KL, q_m = isotherm_args KL_qm = KL * q_m q0_over_C0 = q0 / C0 q0_KL = q0 * KL args = (N_slices, Da, dz, KL_qm, q0_over_C0, q0_KL, beta_q0_rho_over_C0) f = dCdt_optimized_Langmuir sol = solve_ivp( f, t_span=(0, cycle_time / t_scale), y0=C_init, method='BDF', args=args, ) CL = sol.y[:N_slices] q = sol.y[N_slices:] t = sol.t else: args = (N_slices, Da, dz, isotherm_model, isotherm_args, beta_q0_rho_over_C0, C0, q0) f = dCdt time_step = 0.2 / N_slices t, Y = fixed_step_odeint( f, C_init, time_step, cycle_time / t_scale, args ) t = np.array(t) Y = gaussian_filter(np.array(Y).T, 5, axes=1) CL = Y[:N_slices] q = Y[N_slices:] if regeneration: self.rt_scaled = t self.rCL_scaled = CL self.rq_scaled = q self.rt_scale = t_scale else: self.t_scaled = t self.CL_scaled = CL self.q_scaled = q self.t_scale = t_scale def _estimate_length_of_unused_bed(self, LUB): L_guess = self.LES + LUB self._simulate_adsorption_bed( False, L_guess, self.superficial_velocity, ) CL = self.CL_scaled C_min = self.C_final_scaled C_max = 1 - C_min mask = (CL > C_max).any(axis=0) & (CL < C_min).any(axis=0) # slices that have breakthrough curve time_index = np.where(mask)[0][-1] # As close to breakthrough as possible start_index = np.where(CL[:, time_index] >= C_max)[0][-1] end_index = np.where(CL[:, time_index] <= C_min)[0][0] length = end_index - start_index self.t_scaled = self.t_scaled[:time_index] self.CL_scaled = self.CL_scaled[:, :time_index] self.q_scaled = self.q_scaled[:, :time_index] self.MTZ = length * self.dz * L_guess self.LUB = LUB = self.MTZ / 2 return LUB def _regeneration_time_objective(self, u, # [m / hr] ): self._simulate_adsorption_bed( True, self.column_length, u, ) q = self.q_scaled mask = (q > 1e-6).all(axis=0) & (q < 1e-3).all(axis=0) # slices that have finished regenerating time_index = np.where(mask)[0][-1] # Conservative time point return self.t_scaled[time_index] * self.t_scale - self.cycle_time def _solve_regeneration_velocity(self): self.regeneration_velocity = u = flx.IQ_interpolation( self._estimate_regeneration_time, 4, 14.4, self.cycle_time, xtol=1e-3, ytol=1e-2, ) return u # def _final_concentration_objective(self, # L_guess, # m # u, # [m / hr] # cycle_time, # [h] # void_fraction, # external void fraction # q_m, # [g / kg] # k, # [1 / hr] # KL, # [L / g] # feed_rho, # [kg / L] # C0, # [g / L] # ): # self._simulate_adsorption_bed( # L_guess, u, cycle_time, void_fraction, q_m, k, KL, feed_rho, C0, # ) # C_final = self.CL_scaled[-1, -1] # Final concentration at the end of the bed after cycle time # return C_final - self.C_final # def _solve_length_of_unused_bed(self, # u, # [m / hr] # cycle_time, # [h] # void_fraction, # external void fraction # q_m, # [g / kg] # k, # [1 / hr] # KL, # [L / g] # feed_rho, # [kg / L] # C0, # [g / L] # ): # L_guess = self.LES + getattr(self, 'LUB', 2/3) # # 2/3 is a heuristic from AICHE adsorption basics part 1 # L = secant( # self._final_concentration_objective, L_guess, # xtol=self.LES/100, ytol=self.C_final/100, # args=(u, cycle_time, void_fraction, q_m, k, KL, feed_rho, C0) # ) # LUB = L - self.LES # assert LUB > 0 # return LUB def estimate_length_of_unused_bed(self): LUB_guess = getattr(self, 'LUB', 2/3) # 2/3 is a heuristic from AICHE adsorption basics part 1 return flx.wegstein( self._estimate_length_of_unused_bed, LUB_guess, xtol=self.LES * 1e-2, maxiter=3, checkiter=False, ) def _size_columns(self): # Design based on capacity (as estimated by the Langmuir isotherm). # The length of the mass transfer zone (MTZ) is assumed to be 4 ft based # on AICHE adsorption basics part 1. However, future work will use the rate # constant and mass transfer modeling to estimate the length of the MTZ. feed = self.ins[0] Q = feed.F_vol # m3 / hr u = self.superficial_velocity self.area = A = Q / u self.diameter = diameter = 2 * (A/np.pi) ** 0.5 self.C0 = C_feed = feed.imass[self.adsorbate] / Q # g/L or kg/m3 if C_feed == 0: self.q0 = self.LES = self.LUB = self.total_length = self.column_length = 0 return 0, 0 cycle_time = self.cycle_time self.q0 = q0 = self.isotherm_model( C_feed, *self.isotherm_args ) self.LES = LES = estimate_equilibrium_bed_length( C_feed, u, cycle_time, self.rho_adsorbent, q0 ) if self.LUB_forced is None: self.LUB = LUB = self.estimate_length_of_unused_bed() else: self.LUB = LUB = self.LUB_forced self.total_length = total_length = LES + LUB if self.N_columns == 3: column_length = total_length / 2 elif self.N_columns == 2: column_length = total_length self.column_length = column_length return diameter, column_length def _design(self): diameter, column_length = self._size_columns() feed = self.ins[0] if column_length == 0: self.design_results['Weight'] = 0 self.design_results['Diameter'] = 0 self.design_results['length'] = 0 return self.set_design_result('Diameter', 'm', diameter) self.set_design_result('Length', 'm', column_length) self.design_results.update( self._vertical_vessel_design( feed.get_property('P', 'psi'), self.design_results['Diameter'], self.design_results['Length'] ) ) dP = adsorption_bed_pressure_drop( D = self.particle_diameter, rho = feed.rho, mu = feed.get_property('mu', 'kg/m/s'), # viscosity [kg/m*s] epsilon = self.void_fraction, # void fraction u = self.superficial_velocity / 3600, # superficial velosity [m/s] L = self.total_length, # length of bed [m] ) self.set_design_result('Pressure drop', 'Pa', dP) self.pump.P = (self.P - feed.P) + dP self.pump.simulate() if self.regeneration_fluid: feed = self.ins[1] outlet = self.outs[1] self.regeneration_pump.P = (outlet.P - feed.P) + adsorption_bed_pressure_drop( D = self.particle_diameter, rho = feed.rho, mu = feed.get_property('mu', 'kg/m/s'), # viscosity [kg/m*s] epsilon = self.void_fraction, # void fraction u = self.regeneration_superficial_velocity / 3600, # superficial velosity [m/s] L = self.total_length, # length of bed [m] ) def _cost(self): design_results = self.design_results weight = design_results['Weight'] baseline_purchase_costs = self.baseline_purchase_costs if weight == 0: baseline_purchase_costs.clear() self.parallel.clear() return baseline_purchase_costs.update( self._vessel_purchase_cost( weight, design_results['Diameter'], design_results['Length'] ) ) self.parallel['self'] = self.N_columns if self.regeneration_fluid: baseline_purchase_costs[self.adsorbent] = ( self.N_columns * self.area * self.column_length * self.adsorbent_cost[self.adsorbent] ) else: self.ins[2].imass[self.adsorbent] = self.outs[2].imass[self.adsorbent] = self.area * self.column_length / self.cycle_time * self.rho_adsorbent self.ins[2].price = self.adsorbent_cost[self.adsorbent] / self.rho_adsorbent @staticmethod def fit_Freundlich_isotherm(C, q): y = np.log(q) x = np.log(C) m, b = np.polyfit(x, y, 1) n = 1 / m K = np.exp(b) y_ = m * x + b q_ = equilibrium_loading_Freundlich_isotherm(C, K, n) R2 = np.corrcoef(y, y_)**2 R2q = np.corrcoef(q, q_)**2 return { 'K': K, 'n': n, 'parameters': (K, n), 'linear coefficients': (m, b), 'linearized data': (x, y), 'linearized prediction': (x, y_), 'R2': R2[0, 1], 'R2-q': R2q[0, 1], } @staticmethod def fit_Langmuir_isotherm(C, q): y = C / q x = C m, b = np.polyfit(x, y, 1) qmax = 1 / m KL = 1 / (b * qmax) y_ = m * x + b q_ = equilibrium_loading_Langmuir_isotherm(C, KL, qmax) R2q = np.corrcoef(q, q_)**2 R2 = np.corrcoef(y, y_)**2 return { 'K': KL, 'qmax': qmax, 'parameters': (KL, qmax), 'linear coefficients': (m, b), 'linearized data': (x, y), 'linearized prediction': (x, y_), 'R2': R2[0, 1], 'R2-q': R2q[0, 1], } @classmethod def plot_isotherm_fit(cls, C, q, method): if method == 'Langmuir': dct = cls.fit_Langmuir_isotherm(C, q) elif method == 'Freundlich': dct = cls.fit_Freundlich_isotherm(C, q) else: raise ValueError("method must be either 'Langmuir' or 'Freundlich'") plt.scatter(*dct['linearized data']) R2 = dct['R2'] plt.plot(*dct['linearized prediction'], label=f'R$^2$ = {np.round(R2, 2)}') if method == 'Langmuir': plt.xlabel(f'C') plt.ylabel(f'C / q') else: plt.xlabel(f'log(C)') plt.ylabel(f'log(q)') @staticmethod def fit_solid_mass_transfer_coefficient(t, C, volume, adsorbent, model, args): C0 = C[0] q = volume * (C0 - C) / adsorbent qe = model(C, *args) y = np.log((qe - q) / qe) x = t m = (x * y).sum() / (x * x).sum() y_ = m * x R2 = np.corrcoef(y, y_)**2 return { 'k': -m, 'linearized data': (x, y), 'linearized prediction': (x, y_), 'R2': R2[0, 1], } @staticmethod def plot_isotherm_and_mass_transfer_coefficient_fit( Ce, qe, t, C, volume, adsorbent, method=None, C_units=None, q_units=None, t_units=None, ): if C_units is None: C_units = '' else: C_units = f' [{format_units(C_units)}]' if q_units is None: q_units = '' else: q_units = f' [{format_units(q_units)}]' if t_units is None: t_units = '' else: t_units = f' [{format_units(t_units)}]' def plot_fit_isotherm(dct, method): plt.scatter(*dct['linearized data']) if method == 'Langmuir': plt.plot( *dct['linearized prediction'], label=f"R$^2$ = {dct['R2']:.3g}, K={dct['K']:.3g}, q$_{{max}}$={dct['qmax']:.3g}" ) xlabel = 'C {}' ylabel = 'C / q {}' else: plt.plot( *dct['linearized prediction'], label=f"R$^2$ = {dct['R2']:.3g}, K={dct['K']:.3g}, n={dct['n']:.3g}" ) xlabel = 'log(C{})'.format(C_units) ylabel = 'log(q{})'.format(q_units) plt.xlabel(xlabel) plt.ylabel(ylabel) plt.legend() return dct dct_L = bst.AdsorptionColumn.fit_Langmuir_isotherm(Ce, qe) dct_F = bst.AdsorptionColumn.fit_Freundlich_isotherm(Ce, qe) if method == 'Langmuir' or (method is None and dct_L['R2-q'] > dct_F['R2-q']): isotherm_model = 'Langmuir' dct = dct_L else: isotherm_model = 'Freundlich' dct = dct_F fig, axes = plt.subplots(2, 1) ax1, ax2 = axes plt.sca(ax1) plot_fit_isotherm(dct, isotherm_model) model = bst.AdsorptionColumn.isotherm_models[isotherm_model] args = dct['parameters'] dct_k = bst.AdsorptionColumn.fit_solid_mass_transfer_coefficient( t, C, volume, adsorbent, model, args ) plt.sca(ax2) plt.scatter(*dct_k['linearized data']) R2 = dct_k['R2'] plt.plot( *dct_k['linearized prediction'], label=f"R$^2$ = {R2:.2g}, k={dct_k['k']:.3g}" ) plt.ylabel('log((qe - q) / q)') plt.xlabel(f't{t_units}') plt.legend() plt.subplots_adjust(hspace=0.3, wspace=0.3) return fig, axes, { 'isotherm_model': isotherm_model, 'isotherm_args': args, 'k': dct_k['k'], }
AdsorptionColumn = SingleComponentAdsorptionColumn