Source code for biosteam.units.aerated_bioreactor

# -*- coding: utf-8 -*-
"""
.. contents:: :local:

.. autoclass:: biosteam.units.aerated_bioreactor.AeratedBioreactor
.. autoclass:: biosteam.units.aerated_bioreactor.GasFedBioreactor

References
----------
.. [1] Benz, G. T. Optimize Power Consumption in Aerobic Fermenters. 
    Chem. Eng. Progress 2003, 99 (5), 100–103.

.. [2] Benz, G. T. Bioreactor Design for Chemical Engineers. Chem. Eng.\
    Progress 2011, 21–26.

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

"""
import biosteam as bst
from .abstract_stirred_tank_reactor import AbstractStirredTankReactor
from math import pi
import numpy as np
from scipy.constants import g
import flexsolve as flx
from warnings import filterwarnings, catch_warnings
from scipy.optimize import minimize_scalar, minimize, least_squares
from biosteam.units.design_tools import aeration

__all__ = (
    'AeratedBioreactor', 'ABR',
    'GasFedBioreactor', 'GFB',
)

[docs] class AeratedBioreactor(AbstractStirredTankReactor): """ Create an aerated bioreactor which satisfies the oxygen mass tranfer requirement of the mass balance. The reactor is designed as a pressure vessel with a given aspect ratio and residence time. A pump-heat exchanger recirculation loop can be used to satisfy the duty, if any. By default, a turbine agitator is also included if the power usage, `kW_per_m3`, is positive. A vacuum system is also automatically added if the operating pressure is at a vacuum. Parameters ---------- theta_O2 : Fraction of oxygen saturation in the broth. Defaults to 0.5. Q_O2_consumption : Forced duty per O2 consummed [kJ/kmol]. optimize_power : If true, the agitator power is solved to minimize the total power requirement of both the compressor and agitator such that the required oxygen transfer rate is met. design : Bioreactor design configuration. Valid options include 'Stirred tank' and 'Bubble column'. Defaults to the former. method : Method to calculate the overall mass transfer coefficient, kLa. Can be a name or a function. Valid method names are listed in `biosteam.aeration.kLa_methods`. For stirred tanks, defaults to the 'Riet'. For bubble columns, defaults to 'Dewes'. kLa_kwargs : Additional arguments to pass to the kLa method. cooler_pressure_drop : Pressure drop at the cooler [Pa]. Defaults to 20684.28 Pa, a heuristic value for a gas. compressor_isentropic_efficiency : Isentropic efficiency of the compressor. Defaults to 0.85. tau : Residence time [hr]. T : Operating temperature [K]. P : Operating pressure [Pa]. V_wf : Fraction of working volume over total volume. Defaults to 0.8. length_to_diameter : Length to diameter ratio of bioreactor. V_max : Maximum volume of a reactor [m3]. Defaults to 355. kW_per_m3 : Power usage of agitator. Defaults to 0.2955 [kW / m3] from [1]_. vessel_material : Vessel material. Defaults to 'Stainless steel 316'. vessel_type : Vessel type. Valid options are 'Horizontal' or 'Vertical'. Defaults to 'Vertical' batch : Whether to use batch operation mode. If False, operation mode is continuous. Defaults to `continuous`. tau_0 : Cleaning and unloading time (if batch mode). Defaults to 3 hr. N : Number of reactors. heat_exchanger_configuration : What kind of heat exchanger to default to (if any). Valid options include 'jacketed', 'recirculation loop', and 'internal coil'. Defaults to 'recirculation loop'. dT_hx_loop : Maximum change in temperature for the heat exchanger loop. Defaults to 5 K. jacket_annular_diameter : Annular diameter of heat exchanger jacket to vessel [m]. Defaults to 0.1 m. loading_time : Loading time of batch reactor. If not given, it will assume each vessel is constantly being filled. Notes ----- The heat exchanger configuration can be one of the following: * 'recirculation loop': The recirculation loop takes into account the required flow rate needed to reach the maximum temperature change of the heat exchanger, `dT_hx_loop`. Increasing `dT_hx_loop` decreases the required recirculation flow rate and therefore decreases pump costs. When parallel reactors are required, one recirculation loop (each with a pump and heat exchanger) is assumed. Although it is possible to use the same recirculation loop for all reactors, this conservative assumption allows for each reactor to be operated independently from each other. * 'jacketed': The jacket does not account for the heat transfer area requirement. It simply assumes that a full jacket can provide the necessary heat transfer area to meet the duty requirement. A heuristic annular diameter is assumed through `jacket_annular_diameter` (which can be adjusted by the user). The temperature at the wall is assumed to be the operating temperature. The weight of the jacket is added to the weight of the vessel and the cost is compounded together as a jacketed vessel. * 'internal coil': The internal coil is costed as an ordinary helical tube heat exchanger with the added assumption that the temperature at the wall is the operating temperature. This method is still not implemented in BioSTEAM yet. Examples -------- >>> import biosteam as bst >>> from biorefineries.sugarcane import chemicals >>> bst.settings.set_thermo(chemicals) >>> feed = bst.Stream('feed', ... Water=1.20e+05, ... Glucose=2.5e+04, ... units='kg/hr', ... T=32+273.15) >>> # Model oxygen uptake as combustion >>> rxn = bst.Rxn( ... 'Glucose + O2 -> H2O + CO2', reactant='Glucose', X=0.5, ... correct_atomic_balance=True ... ) >>> R1 = bst.AeratedBioreactor( ... ins=[feed, bst.Stream('air', phase='g')], ... outs=('vent', 'product'), tau=12, V_max=500, ... reactions=rxn, ... ) >>> R1.simulate() >>> R1.show() AeratedBioreactor: R2 ins... [0] feed phase: 'l', T: 305.15 K, P: 101325 Pa flow (kmol/hr): Water 6.66e+03 Glucose 139 [1] air phase: 'g', T: 305.15 K, P: 101325 Pa flow (kmol/hr): O2 1.02e+03 N2 3.84e+03 outs... [0] vent phase: 'g', T: 305.15 K, P: 101325 Pa flow (kmol/hr): Water 240 CO2 416 O2 605 N2 3.84e+03 [1] product phase: 'l', T: 305.15 K, P: 101325 Pa flow (kmol/hr): Water 6.84e+03 Glucose 69.4 """ _N_ins = 2 _N_outs = 2 _ins_size_is_fixed = False auxiliary_unit_names = ( 'compressor', 'air_cooler', *AbstractStirredTankReactor.auxiliary_unit_names ) T_default = 273.15 + 32 P_default = 101325 kW_per_m3_default = 0.2955 # Reaction in homogeneous liquid; reference [1] batch_default = True default_methods = { 'Stirred tank': 'Riet', 'Bubble column': 'Dewes', } def _init( self, theta_O2=0.5, Q_O2_consumption=None, optimize_power=None, design=None, method=None, kLa_kwargs=None, cooler_pressure_drop=None, compressor_isentropic_efficiency=None, **kwargs, ): if compressor_isentropic_efficiency is None: compressor_isentropic_efficiency = 0.85 #: Isentropic efficiency of the compressor. Defaults to 0.85. self.compressor_isentropic_efficiency = compressor_isentropic_efficiency #: Pressure drop at the cooler [Pa]. Defaults to 20684.28 Pa, a heuristic value for a gas. self.cooler_pressure_drop = 20684.28 if cooler_pressure_drop is None else cooler_pressure_drop AbstractStirredTankReactor._init(self, **kwargs) self.theta_O2 = theta_O2 # Average concentration of O2 in the liquid as a fraction of saturation. self.Q_O2_consumption = Q_O2_consumption # Forced duty per O2 consummed [kJ/kmol]. if design is None: if self.kW_per_m3 == 0: design = 'Bubble column' else: design = 'Stirred tank' elif design not in aeration.kLa_method_names: raise ValueError( f"{design!r} is not a valid design; only " f"{list(aeration.kLa_method_names)} are valid" ) self.design = design self.optimize_power = ( design == 'Stirred tank' if optimize_power is None else optimize_power ) self.design = design if method is None: method = self.default_methods[design] if (key:=(design, method)) in aeration.kLa_methods: self.kLa = aeration.kLa_methods[key] elif hasattr(method, '__call__'): self.kLa = method else: raise ValueError( f"{method!r} is not a valid kLa method; only " f"{aeration.kLa_method_names[design]} are valid" ) self.kLa_kwargs = {} if kLa_kwargs is None else kLa_kwargs def get_kLa(self): if self.kLa is aeration.kLa_stirred_Riet: V = self.get_design_result('Reactor volume', 'm3') * self.V_wf operating_time = self.tau / self.design_results.get('Batch time', 1.) N_reactors = self.parallel['self'] P = 1000 * self.kW_per_m3 * V # W air_in = self.sparged_gas D = self.get_design_result('Diameter', 'm') F = air_in.get_total_flow('m3/s') / N_reactors / operating_time R = 0.5 * D A = pi * R * R self.superficial_gas_flow = U = F / A # m / s return aeration.kLa_stirred_Riet(P, V, U, **self.kLa_kwargs) # 1 / s elif self.kLa is aeration.kla_bubcol_Dewes: V = self.get_design_result('Reactor volume', 'm3') * self.V_wf operating_time = self.tau / self.design_results.get('Batch time', 1.) N_reactors = self.parallel['self'] air_in = self.sparged_gas D = self.get_design_result('Diameter', 'm') F = air_in.get_total_flow('m3/s') / N_reactors / operating_time R = 0.5 * D A = pi * R * R self.superficial_gas_flow = U = F / A # m / s feed = self.ins[0] try: return aeration.kla_bubcol_Dewes(U, feed.get_property('mu', 'mPa*s'), air_in.get_property('rho', 'kg/m3')) except: breakpoint() else: raise NotImplementedError('kLa method has not been implemented in BioSTEAM yet') def get_agitation_power(self, kLa): if self.kLa is aeration.kLa_stirred_Riet: air_in = self.sparged_gas N_reactors = self.parallel['self'] operating_time = self.tau / self.design_results.get('Batch time', 1.) V = self.get_design_result('Reactor volume', 'm3') * self.V_wf D = self.get_design_result('Diameter', 'm') F = air_in.get_total_flow('m3/s') / N_reactors / operating_time R = 0.5 * D A = pi * R * R self.superficial_gas_flow = U = F / A # m / s return aeration.P_at_kLa_Riet(kLa, V, U, **self.kLa_kwargs) else: raise NotImplementedError( 'cannot solve for the required agitation power using the ' f'kLa method {self.kLa!r} in BioSTEAM yet' ) def _get_duty(self): if self.Q_O2_consumption is None: H_in = sum([i.H for i in self.ins if i.phase != 'g'], self.air_cooler.outs[0].H) return self.H_out - H_in + self.Hf_out - self.Hf_in else: return self.Q_O2_consumption * ( sum([i.imol['O2'] for i in self.ins]) - sum([i.imol['O2'] for i in self.outs]) ) @property def feed(self): return self._ins[0] @property def air(self): for i in self._ins: if i.phase == 'g': return i @property def sparged_gas(self): return self.air_cooler.outs[0] @property def vent(self): return self._outs[0] def load_auxiliaries(self): super().load_auxiliaries() compressor = self.auxiliary( 'compressor', bst.IsentropicCompressor, self.air, eta=self.compressor_isentropic_efficiency, P=2 * 101325, ) self.auxiliary( 'air_cooler', bst.HXutility, compressor-0, T=self.T, dP=False, ) def _run_vent(self, vent, effluent): aeration.vent_broth(vent, effluent) def _run(self): air = self.air if air is None: air = bst.Stream(phase='g', thermo=self.thermo) self.ins.insert(1, air) self.compressor.ins[0] = self.auxlet(air) feeds = [i for i in self.ins if i.phase != 'g'] vent, effluent = self.outs air.P = vent.P = effluent.P = self.P air.T = vent.T = effluent.T = self.T vent.empty() vent.phase = 'g' air.phase = 'g' air.empty() compressor = self.compressor effluent.mix_from(feeds, energy_balance=False) self._run_reactions(effluent) effluent_no_air_data = effluent.get_data() OUR = -effluent.get_flow('mol/s', 'O2') # Oxygen uptake rate if OUR <= 1e-2: if OUR > 0: effluent.imol['O2'] = 0. self._run_vent(vent, effluent) return air_cc = self.sparged_gas air_cc.copy_like(air) compressor.P = self._inlet_air_pressure() air_cc.P = compressor.P - self.cooler_pressure_drop air_cc.T = self.T if self.optimize_power: def total_power_at_oxygen_flow(O2): air.set_flow([O2, O2 * 79. / 21.], 'mol/s', ['O2', 'N2']) air_cc.copy_flow(air) # Skip simulation of air cooler compressor.simulate() effluent.set_data(effluent_no_air_data) effluent.mix_from([effluent, air_cc], energy_balance=False) vent.empty() self._run_vent(vent, effluent) total_power = self._solve_total_power(OUR) return total_power f = total_power_at_oxygen_flow minimize_scalar(f, 1.2 * OUR, bounds=[OUR, 10 * OUR], options=dict(xtol=OUR * 1e-3)) else: def air_flow_rate_objective(O2): air.set_flow([O2, O2 * 79. / 21.], 'mol/s', ['O2', 'N2']) air_cc.copy_flow(air) # Skip simulation of air cooler effluent.set_data(effluent_no_air_data) effluent.mix_from([effluent, air_cc], energy_balance=False) vent.empty() self._run_vent(vent, effluent) return OUR - self.get_OTR() f = air_flow_rate_objective x0 = OUR y0 = air_flow_rate_objective(OUR) # Correlation is not perfect and special cases lead to OTR > OUR if y0 <= 0.: return f = air_flow_rate_objective x1 = 10 * OUR y1 = air_flow_rate_objective(x1) if y1 > 0: x1 = 50 * OUR y1 = air_flow_rate_objective(x1) # It is possible an infinite flow rate of air is not enough to # satisfy mass transfer if the titer is super high or gas O2 concentration # is too low. if y1 > 0: raise RuntimeError( 'bioreactor conversion/titer cannot be satisfied; ' 'even an infinite flow rate of gas is not enough' ) # There is a known solution because y1 < 0 < y0 flx.IQ_interpolation( f, x0=x0, x1=x1, y0=y0, y1=y1, ytol=1e-3, xtol=1e-3 ) def _run_reactions(self, effluent): self.reactions.force_reaction(effluent) def _solve_total_power(self, OUR): # For OTR = OUR [mol / s] air_in = self.sparged_gas N_reactors = self.parallel['self'] operating_time = self.tau / self.design_results.get('Batch time', 1.) V = self.get_design_result('Reactor volume', 'm3') * self.V_wf vent = self.vent P_O2_air = air_in.get_property('P', 'bar') * air_in.imol['O2'] / air_in.F_mol P_O2_vent = 0. if vent.isempty() else vent.get_property('P', 'bar') * vent.imol['O2'] / vent.F_mol C_O2_sat_air = aeration.C_O2_L(self.T, P_O2_air) # mol / kg C_O2_sat_vent = aeration.C_O2_L(self.T, P_O2_vent) # mol / kg theta_O2 = self.theta_O2 LMDF = aeration.log_mean_driving_force(C_O2_sat_vent, C_O2_sat_air, theta_O2 * C_O2_sat_vent, theta_O2 * C_O2_sat_air) kLa = OUR / (LMDF * V * self.effluent_density * N_reactors * operating_time) P = self.get_agitation_power(kLa) agitation_power_kW = P / 1000 total_power_kW = (agitation_power_kW + self.compressor.power_utility.consumption / N_reactors) / V self.kW_per_m3 = agitation_power_kW / V return total_power_kW def get_OUR(self): """Return the oxygen uptake rate in mol/s.""" feeds = [i for i in self.ins if i.phase != 'g'] effluent = self.effluent.copy() effluent.mix_from(feeds, energy_balance=False) self._run_reactions(effluent) return -effluent.get_flow('mol/s', 'O2') # Oxygen uptake rate def get_OTR(self): """Return the oxygen transfer rate in mol/s.""" V = self.get_design_result('Reactor volume', 'm3') * self.V_wf operating_time = self.tau / self.design_results.get('Batch time', 1.) N_reactors = self.parallel['self'] kLa = self.get_kLa() air_in = self.sparged_gas vent = self.vent P_O2_air = air_in.get_property('P', 'bar') * air_in.imol['O2'] / air_in.F_mol P_O2_vent = vent.get_property('P', 'bar') * vent.imol['O2'] / vent.F_mol C_O2_sat_air = aeration.C_O2_L(self.T, P_O2_air) # mol / kg C_O2_sat_vent = aeration.C_O2_L(self.T, P_O2_vent) # mol / kg theta_O2 = self.theta_O2 LMDF = aeration.log_mean_driving_force(C_O2_sat_vent, C_O2_sat_air, theta_O2 * C_O2_sat_vent, theta_O2 * C_O2_sat_air) OTR = kLa * LMDF * self.effluent_density * V * N_reactors * operating_time # mol / s return OTR def _inlet_air_pressure(self): AbstractStirredTankReactor._design(self, size_only=True) liquid = bst.Stream(None, thermo=self.thermo) liquid.mix_from([i for i in self.ins if i.phase != 'g'], energy_balance=False) liquid.copy_thermal_condition(self.outs[0]) self.effluent_density = rho = liquid.rho length = self.get_design_result('Length', 'm') * self.V_wf return g * rho * length + 101325 + self.cooler_pressure_drop # Pa def _design(self): AbstractStirredTankReactor._design(self) if self.air.isempty(): return self.compressor.simulate() air_cooler = self.air_cooler air_cooler.T = self.T air_cooler.dP = self.cooler_pressure_drop air_cooler.simulate() self.parallel['compressor'] = 1 self.parallel['air_cooler'] = 1 # For robust process control, do not include in HXN for unit in self.auxiliary_units: for hu in unit.heat_utilities: hu.hxn_ok = False
[docs] class GasFedBioreactor(AbstractStirredTankReactor): """ Create an gas-fed bioreactor which satisfies the substrate mass tranfer requirement of the mass balance. The gas-fed bioreactor may include multiple gas feeds. The user-specified `titer` will be satisfied by varying the flow rates of the `variable_gas_feeds` so long as the `backward_reactions` are specified. Otherwise, the titer will be estimated assuming constant gas flow rates. The reactor is designed as a pressure vessel with a given aspect ratio and residence time. A pump-heat exchanger recirculation loop can be used to satisfy the duty, if any. By default, a turbine agitator is also included if the power usage, `kW_per_m3`, is positive. A vacuum system is also automatically added if the operating pressure is at a vacuum. Parameters ---------- gas_substrates : Substrates within the gas phase. titer : Dictionary of substrate/titer pairs [g / L]. backward_reactions : Backwards reactions to get the substrate mass transfer requirement. variable_gas_feeds : Feeds that can be varied to meet mass transfer requirement. theta : Fraction of gas substrate saturation in the broth. Defaults to 0.5. Q_consumption : Forced duty per gas substrate consummed [kJ/kmol]. optimize_power : If true, the agitator power is solved to minimize the total power requirement of both the compressor and agitator such that the required oxygen transfer rate is met. design : Bioreactor design configuration. Valid options include 'Stirred tank' and 'Bubble column'. Defaults to the former. method : Method to calculate the overall mass transfer coefficient, kLa. Can be a name or a function. Valid method names are listed in `biosteam.aeration.kLa_methods`. For stirred tanks, defaults to the 'Riet'. For bubble columns, defaults to 'Dewes'. kLa_kwargs: Additional arguments to pass to the kLa method. cooler_pressure_drop : Pressure drop at the cooler [Pa]. Defaults to 20684.28 Pa, a heuristic value for a gas. compressor_isentropic_efficiency : Isentropic efficiency of the compressor. Defaults to 0.85. tau : Residence time [hr]. T : Operating temperature [K]. P : Operating pressure [Pa]. V_wf : Fraction of working volume over total volume. Defaults to 0.8. length_to_diameter : Length to diameter ratio of bioreactor. V_max : Maximum volume of a reactor [m3]. Defaults to 355. kW_per_m3 : Power usage of agitator. Defaults to 0.985 [kW / m3] converted from 5 hp/1000 gal as in [1]_, for liquid–liquid reaction or extraction. vessel_material : Vessel material. Defaults to 'Stainless steel 316'. vessel_type : Vessel type. Valid options are 'Horizontal' or 'Vertical'. Defaults to 'Vertical' batch : Whether to use batch operation mode. If False, operation mode is continuous. Defaults to `continuous`. tau_0 : Cleaning and unloading time (if batch mode). Defaults to 3 hr. N : Number of reactors. heat_exchanger_configuration : What kind of heat exchanger to default to (if any). Valid options include 'jacketed', 'recirculation loop', and 'internal coil'. Defaults to 'recirculation loop'. dT_hx_loop : Maximum change in temperature for the heat exchanger loop. Defaults to 5 K. jacket_annular_diameter : Annular diameter of heat exchanger jacket to vessel [m]. Defaults to 0.1 m. loading_time : Loading time of batch reactor. If not given, it will assume each vessel is constantly being filled. Notes ----- The heat exchanger configuration can be one of the following: * 'recirculation loop': The recirculation loop takes into account the required flow rate needed to reach the maximum temperature change of the heat exchanger, `dT_hx_loop`. Increasing `dT_hx_loop` decreases the required recirculation flow rate and therefore decreases pump costs. When parallel reactors are required, one recirculation loop (each with a pump and heat exchanger) is assumed. Although it is possible to use the same recirculation loop for all reactors, this conservative assumption allows for each reactor to be operated independently from each other. * 'jacketed': The jacket does not account for the heat transfer area requirement. It simply assumes that a full jacket can provide the necessary heat transfer area to meet the duty requirement. A heuristic annular diameter is assumed through `jacket_annular_diameter` (which can be adjusted by the user). The temperature at the wall is assumed to be the operating temperature. The weight of the jacket is added to the weight of the vessel and the cost is compounded together as a jacketed vessel. * 'internal coil': The internal coil is costed as an ordinary helical tube heat exchanger with the added assumption that the temperature at the wall is the operating temperature. This method is still not implemented in BioSTEAM yet. Examples -------- >>> import biosteam as bst >>> bst.settings.set_thermo(['H2', 'CO2', 'N2', 'O2', 'H2O', 'AceticAcid']) >>> media = bst.Stream(ID='media', H2O=10000, units='kg/hr') >>> H2 = bst.Stream(ID='H2', H2=100, units='kg/hr', phase='g') >>> fluegas = bst.Stream(ID='fluegas', N2=70, CO2=25, H2O=3, O2=2, units='kg/hr', phase='g') >>> # Model acetic acid production from H2 and CO2 >>> rxn = bst.Rxn('H2 + CO2 -> AceticAcid + H2O', reactant='H2', correct_atomic_balance=True) >>> brxn = rxn.backwards(reactant='AceticAcid') >>> R1 = bst.GasFedBioreactor( ... 'R1', ins=[media, H2, fluegas], outs=('vent', 'product'), ... tau=68, V_max=500, ... reactions=rxn, backward_reactions=brxn, ... gas_substrates=('H2', 'CO2'), ... titer=dict(AceticAcid=5), ... optimize_power=False, ... kW_per_m3=0., ... T=305.15 ... ) >>> R1.simulate() >>> R1.show() GasFedBioreactor: R1 ins... [0] media phase: 'l', T: 298.15 K, P: 101325 Pa flow: 88.8 kmol/hr H2O [1] H2 phase: 'g', T: 298.15 K, P: 101325 Pa flow: 49.6 kmol/hr H2 [2] fluegas phase: 'g', T: 298.15 K, P: 101325 Pa flow (kmol/hr): CO2 0.568 N2 2.5 O2 0.0625 H2O 0.167 outs... [0] vent phase: 'g', T: 305.15 K, P: 101325 Pa flow (kmol/hr): H2 49 CO2 0.29 N2 2.5 O2 0.0625 H2O 2.55 AceticAcid 0.0082 [1] product phase: 'l', T: 305.15 K, P: 101325 Pa flow (kmol/hr): H2 0.00107 CO2 0.000242 N2 4.13e-05 O2 2.11e-06 H2O 86.7 AceticAcid 0.131 """ _N_ins = 2 _N_outs = 2 _ins_size_is_fixed = False auxiliary_unit_names = ( 'sparger', 'compressors', 'gas_coolers', *AbstractStirredTankReactor.auxiliary_unit_names ) T_default = 273.15 + 32 P_default = 101325 kW_per_m3_default = 0.2955 # Reaction in homogeneous liquid batch_default = True default_methods = AeratedBioreactor.default_methods get_kLa = AeratedBioreactor.get_kLa get_agitation_power = AeratedBioreactor.get_agitation_power def _init(self, gas_substrates, # Can vary either liquid or gas flows titer=None, # Only for variable gas flows backward_reactions=None, variable_gas_feeds=(), # General design/performance arguments design=None, method=None, kLa_kwargs=None, theta=0.5, Q_consumption=None, cooler_pressure_drop=None, compressor_isentropic_efficiency=None, # Only for agitated bioreactors (not bubble column) optimize_power=None, **kwargs, ): if compressor_isentropic_efficiency is None: compressor_isentropic_efficiency = 0.85 #: Isentropic efficiency of the compressor. Defaults to 0.85. self.compressor_isentropic_efficiency = compressor_isentropic_efficiency self.cooler_pressure_drop = 20684.28 if cooler_pressure_drop is None else cooler_pressure_drop self.backward_reactions = backward_reactions self.theta = theta # Average concentration of gas substrate in the liquid as a fraction of saturation. self.Q_consumption = Q_consumption # Forced duty per gas substrate consummed [kJ/kmol]. self.kLa_kwargs = {} if kLa_kwargs is None else kLa_kwargs self.optimize_power = True if optimize_power is None else optimize_power self.variable_gas_feeds = variable_gas_feeds # list[int|Stream] Feed index or stream. self.gas_substrates = gas_substrates self.titer = titer # dict[str, float] g / L AbstractStirredTankReactor._init(self, **kwargs) if design is None: if self.kW_per_m3 == 0: design = 'Bubble column' else: design = 'Stirred tank' elif design not in aeration.kLa_method_names: raise ValueError( f"{design!r} is not a valid design; only " f"{list(aeration.kLa_method_names)} are valid" ) self.design = design if method is None: method = self.default_methods[design] if (key:=(design, method)) in aeration.kLa_methods: self.kLa = aeration.kLa_methods[key] elif hasattr(method, '__call__'): self.kLa = method else: raise ValueError( f"{method!r} is not a valid kLa method; only " f"{aeration.kLa_method_names[design]} are valid" ) def _get_duty(self): if self.Q_consumption is None: H_in = sum( [i.H for i in self.ins if i.phase != 'g'] + [i.outs[0].H for i in self.gas_coolers] ) return self.H_out - H_in + self.Hf_out - self.Hf_in else: return self.Q_consumption * ( sum([i.imol['O2'] for i in self.ins]) - sum([i.imol['O2'] for i in self.outs]) ) @property def vent(self): return self._outs[0] @property def variable_gas_feeds(self): return [(i if isinstance(i, bst.Stream) else self.ins[i]) for i in self._variable_gas_feeds] @variable_gas_feeds.setter def variable_gas_feeds(self, variable_gas_feeds): self._variable_gas_feeds = variable_gas_feeds @property def normal_gas_feeds(self): variable = set(self.variable_gas_feeds) return [i for i in self.ins if i not in variable and i.phase == 'g'] @property def liquid_feeds(self): return [i for i in self.ins if i.phase != 'g'] @property def sparged_gas(self): return self.sparger-0 def load_auxiliaries(self): super().load_auxiliaries() self.compressors = [] self.gas_coolers = [] for i in self.variable_gas_feeds: i.phase = 'g' for inlet in self.ins: if inlet.phase != 'g': continue compressor = self.auxiliary( 'compressors', bst.IsentropicCompressor, inlet, eta=self.compressor_isentropic_efficiency, P=2 * 101325 ) self.auxiliary( 'gas_coolers', bst.HXutility, compressor-0, T=self.T ) self.auxiliary( 'sparger', bst.Mixer, [i-0 for i in self.gas_coolers] ) def get_SURs(self, effluent): F_vol = effluent.ivol['Water'] # m3 / hr produced = bst.Stream(None, thermo=self.thermo) for ID, concentration in self.titer.items(): produced.imass[ID] = F_vol * concentration consumed = produced.copy() self.backward_reactions.force_reaction(consumed) return consumed.get_flow('mol/s', self.gas_substrates), consumed, produced def _load_gas_feeds(self): for i in self.compressors: i.simulate() for i in self.gas_coolers: i.simulate() self.sparger.simulate() def _run_vent(self, vent, effluent): flows = vent.imol[self.gas_substrates] vent.copy_flow(self.sparged_gas) vent.imol[self.gas_substrates] = flows aeration.vent_broth(vent, effluent) def _run(self): variable_gas_feeds = self.variable_gas_feeds vent, effluent = self.outs vent.P = effluent.P = self.P vent.T = effluent.T = self.T vent.empty() vent.phase = 'g' liquid_feeds = [i for i in self.ins if i.phase != 'g'] if not self.titer: # Titer is given by the mass transfer. T = self.T P = self._inlet_gas_pressure() for i in self.compressors: i.P = P for i in self.gas_coolers: i.T = T self._load_gas_feeds() STRs = self.get_STRs() substrates = sum([i.get_flow(units='mol/s', key=self.gas_substrates) for i in self.ins]) STRs = np.minimum(STRs, substrates) effluent.mix_from(liquid_feeds, energy_balance=False) effluent.set_flow(STRs, units='mol/s', key=self.gas_substrates) remaining = substrates - STRs self._run_reactions(effluent) vent.empty() self.vent.set_flow(remaining, units='mol/s', key=self.gas_substrates) self._run_vent(vent, effluent) elif variable_gas_feeds: # Solve gas flow rates to meet titer. effluent.mix_from(liquid_feeds, energy_balance=False) T = self.T P = self._inlet_gas_pressure() for i in self.compressors: i.P = P for i in self.gas_coolers: i.T = T effluent_liquid_data = effluent.get_data() SURs, s_consumed, s_produced = self.get_SURs(effluent) # Gas substrate uptake rate [mol / s] if (SURs <= 1e-2).all(): effluent.imol[self.gas_substrates] = 0. self._run_vent(vent, effluent) return x_substrates = [] for gas, ID in zip(self.variable_gas_feeds, self.gas_substrates): x_substrates.append(gas.get_molar_fraction(ID)) index = range(len(self.gas_substrates)) def load_flow_rates(F_feeds): for i in index: gas = variable_gas_feeds[i] gas.set_total_flow(F_feeds[i], 'mol/s') self._load_gas_feeds() effluent.set_data(effluent_liquid_data) effluent.mix_from([self.sparged_gas, -s_consumed, s_produced, *liquid_feeds], energy_balance=False) if (effluent.mol < 0).any(): breakpoint() vent.empty() self._run_vent(vent, effluent) baseline_feed = bst.Stream.sum(self.normal_gas_feeds, energy_balance=False) baseline_flows = baseline_feed.get_flow('mol/s', self.gas_substrates) # Bounds must meet substrate uptake rate (minimally). # At most, 10x the substrate uptake rate as an abritrarily high number. bounds = np.array([ [max(1.01 * SURs[i] - baseline_flows[i], 1e-6), 10 * SURs[i]] for i in index ]) if self.optimize_power: def total_power_at_substrate_flow(F_substrates): load_flow_rates(F_substrates) total_power = self._solve_total_power(SURs) return total_power f = total_power_at_substrate_flow with catch_warnings(): filterwarnings('ignore') results = minimize(f, 1.2 * SURs, bounds=bounds, tol=SURs.max() * 1e-6) load_flow_rates(results.x / x_substrates) else: def gas_flow_rate_objective(F_substrates): F_feeds = F_substrates / x_substrates load_flow_rates(F_feeds) STRs = self.get_STRs() # Must meet all substrate demands F_ins = F_substrates + baseline_flows mask = STRs - F_ins > 0 STRs[mask] = F_ins[mask] diff = SURs - STRs diff[diff > 0] *= 1e3 # Force transfer rate to meet uptake rate SE = (diff * diff).sum() return SE f = gas_flow_rate_objective with catch_warnings(): filterwarnings('ignore') bounds = bounds.T results = least_squares(f, 1.2 * SURs, bounds=bounds, ftol=SURs.min() * 1e-6) self._results = results if not results.success: raise RuntimeError( 'bioreactor conversion/titer could not be satisfied' ) load_flow_rates(results.x / x_substrates) else: # Titer given, must adjust liquid flows to meet mass transfer. try: feed, = [i for i in self.ins if i.phase != 'g'] except: raise RuntimeError('gas-fed bioreactor must have exactly on liquid feed') T = self.T P = self._inlet_gas_pressure() for i in self.compressors: i.P = P for i in self.gas_coolers: i.T = T self._load_gas_feeds() substrates = sum([i.get_flow(units='mol/s', key=self.gas_substrates) for i in self.ins]) F_liquid_max = self._initialize_variable_liquid_guess(feed, effluent, vent) product, titer = next(iter(self.titer.items())) def liquid_flow_rate_objective(F_feed): feed.F_mass = F_feed def f(vent_flow_rates): self.vent.mol = vent_flow_rates STRs = self.get_STRs() STRs = np.minimum(STRs, substrates) effluent.copy_flow(feed) effluent.set_flow(STRs, units='mol/s', key=self.gas_substrates) remaining = substrates - STRs self._run_reactions(effluent) vent.empty() self.vent.set_flow(remaining, units='mol/s', key=self.gas_substrates) self._run_vent(vent, effluent) return self.vent.mol.to_array() flx.aitken(f, self.vent.mol.to_array(), checkiter=False, xtol=1e-3, checkconvergence=False) return effluent.imass[product] / effluent.ivol['Water'] - titer flx.IQ_interpolation(liquid_flow_rate_objective, 0.05 * F_liquid_max, F_liquid_max, ytol=1e-3) def _run_reactions(self, effluent): data = effluent.get_data() rxns = self.reactions rxns.force_reaction(effluent) for reactant in self.gas_substrates: if effluent.imol[reactant] < 0: if isinstance(rxns, bst.Rxn): rxns.reactant = reactant else: for rxn in rxns: if rxn.istoichiometry[reactant] < 0: rxn.reactant = reactant effluent.set_data(data) rxns.force_reaction(effluent) break def _initialize_variable_liquid_guess(self, feed, effluent, vent): effluent.mix_from(self.ins, energy_balance=False) data = vent.get_data() vent.empty() self._run_reactions(effluent) product, titer = next(iter(self.titer.items())) F_liquid_max = 1000 * effluent.imass[product] / titer # kg / hr vent.set_data(data) return F_liquid_max def _solve_total_power(self, SURs): # For STR = SUR [mol / s] gas_in = self.sparged_gas N_reactors = self.parallel['self'] operating_time = self.tau / self.design_results.get('Batch time', 1.) V = self.get_design_result('Reactor volume', 'm3') * self.V_wf D = self.get_design_result('Diameter', 'm') F = gas_in.get_total_flow('m3/s') / N_reactors / operating_time R = 0.5 * D A = pi * R * R self.superficial_gas_flow = U = F / A # m / s vent = self.vent Ps = [] for gas_substrate, SUR in zip(self.gas_substrates, SURs): Py_gas = gas_in.get_property('P', 'bar') * gas_in.imol[gas_substrate] / gas_in.F_mol Py_vent = 0. if vent.isempty() else vent.get_property('P', 'bar') * vent.imol[gas_substrate] / vent.F_mol C_sat_gas = aeration.C_L(self.T, Py_gas, gas_substrate) # mol / kg C_sat_vent = aeration.C_L(self.T, Py_vent, gas_substrate) # mol / kg theta = self.theta LMDF = aeration.log_mean_driving_force(C_sat_vent, C_sat_gas, theta * C_sat_vent, theta * C_sat_gas) kLa = SUR / (LMDF * V * self.effluent_density * N_reactors * operating_time) Ps.append(aeration.P_at_kLa_Riet(kLa, V, U, **self.kLa_kwargs)) P = max(Ps) agitation_power_kW = P / 1000 compressor_power_kW = sum([i.power_utility.consumption for i in self.compressors]) / N_reactors total_power_kW = (agitation_power_kW + compressor_power_kW) / V self.kW_per_m3 = agitation_power_kW / V return total_power_kW def get_STRs(self): """Return the gas substrate transfer rate in mol/s.""" V = self.get_design_result('Reactor volume', 'm3') * self.V_wf operating_time = self.tau / self.design_results.get('Batch time', 1.) N_reactors = self.parallel['self'] gas_in = self.sparged_gas kLa = self.get_kLa() # 1 / s vent = self.vent P_gas = gas_in.get_property('P', 'bar') P_vent = vent.get_property('P', 'bar') STRs = [] for ID in self.gas_substrates: Py_gas = P_gas * gas_in.imol[ID] / gas_in.F_mol Py_vent = P_vent * vent.imol[ID] / (vent.F_mol or 1) C_sat_gas = aeration.C_L(self.T, Py_gas, ID) # mol / kg C_sat_vent = aeration.C_L(self.T, Py_vent, ID) # mol / kg theta = self.theta LMDF = aeration.log_mean_driving_force(C_sat_vent, C_sat_gas, theta * C_sat_vent, theta * C_sat_gas) STRs.append( kLa * LMDF * self.effluent_density * V * N_reactors * operating_time # mol / s ) return np.array(STRs) def _inlet_gas_pressure(self): AbstractStirredTankReactor._design(self, size_only=True) liquid = bst.Stream(None, thermo=self.thermo) liquid.mix_from([i for i in self.ins if i.phase != 'g'], energy_balance=False) liquid.copy_thermal_condition(self.outs[0]) self.effluent_density = rho = liquid.rho length = self.get_design_result('Length', 'm') * self.V_wf return g * rho * length + 101325 + self.cooler_pressure_drop # Pa def _design(self): AbstractStirredTankReactor._design(self) self.parallel['sparger'] = 1 self.parallel['mixers'] = 1 self.parallel['compressors'] = 1 self.parallel['gas_coolers'] = 1 # For robust process control, do not include in HXN for unit in self.auxiliary_units: for hu in unit.heat_utilities: hu.hxn_ok = False
GFB = GasFedBioreactor ABR = AeratedBioreactor