# -*- 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