Source code for biosteam.units.drying

# -*- coding: utf-8 -*-
# BioSTEAM: The Biorefinery Simulation and Techno-Economic Analysis Modules
# Copyright (C) 2020-2023, 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.
"""
This module contains unit operations for drying solids.

.. contents:: :local:
    
.. autoclass:: biosteam.units.drying.DrumDryer

References
----------
.. [1] Kwiatkowski, J. R.; McAloon, A. J.; Taylor, F.; Johnston, D. B. 
    Modeling the Process and Costs of Fuel Ethanol Production by the Corn 
    Dry-Grind Process. Industrial Crops and Products 2006, 23 (3), 288–296.
    https://doi.org/10.1016/j.indcrop.2005.08.004.

"""
import flexsolve as flx
import numpy as np
from thermosteam import separations as sep
from .design_tools import (
    CEPCI_by_year,
    cylinder_diameter_from_volume, 
    cylinder_area,
)
from .decorators import cost
from .._unit import Unit
from math import exp, log
from thermosteam import separations
from warnings import warn
from ..exceptions import DesignWarning

__all__ = ('SprayDryer', 'DrumDryer', 'ThermalOxidizer')

@cost('Evaporation rate', CE=567, units='lb/hr', # lb=30, ub=3000, 
      BM=2.06, f=lambda W: exp(8.5133 + 0.9847*(logW:=log(W)) - 0.0561 * logW * logW)
)
class SprayDryer(Unit):
    _units = {'Evaporation rate': 'lb/hr'}
    _N_ins = 1
    _N_outs = 2
    
    def _init(self, moisture_content=0.90):
        self.moisture_content = moisture_content
        
    def _run(self):
        feed = self.ins[0]
        water, solids = self.outs
        solids.copy_like(feed)
        water.copy_flow(solids, 'Water', remove=True)
        separations.adjust_moisture_content(solids, water, self.moisture_content)
        water.phase = 'g'
        
    def _design(self):
        water, solids = self.outs
        self.design_results['Evaporation rate'] = water.get_total_flow('lb/hr')


# TODO: The drum dryer is carbon steel. Add material factors later
[docs] @cost('Peripheral drum area', CE=CEPCI_by_year[2007], ub=7854.0, BM=2.06, S=1235.35, units='m2', n=0.6, cost=0.52 * 2268000., kW=938.866) class DrumDryer(Unit): """ Create a drum dryer that dries solids by passing hot air (heated by burning natural gas). Parameters ---------- ins : * [0] Wet solids. * [1] Dry gas. * [2] Natural gas. outs : * [0] Dried solids * [1] Hot gas * [2] Emissions split : dict[str, float] Component splits to hot gas (stream [1]). RH : float, optional Relative humidity of hot gas as a fraction. Defaults to 0.80. H : float, optional Specific evaporation rate [kg/hr/m3]. Defaults to 20. length_to_diameter : float, optional Note that the drum is horizontal. Defaults to 25. T : float, optional Operating temperature [K]. Defaults to 343.15. moisture_content : float Moisture content of solids [wt / wt]. Defaults to 0.10. Notes ----- The flow rate for gas in the inlet is calculated to meet the `RH` specification (i.e. relative humidity of hot gas depending on moisture_ID evaporated). The flow rate of inlet natural gas is also altered to meet the heat demand. The default parameter values are based on heuristics for drying dried distillers grains with solubles (DDGS). Examples -------- >>> import biosteam as bst >>> from biorefineries import corn as c >>> bst.settings.set_thermo(c.create_chemicals()) >>> feed = bst.Stream('feed', phase='l', T=352.33, P=101325, ... Water=0.6749, Ethanol=5.041e-06, Ash=0.01978, Yeast=0.008452, ... CaO=0.0001446, TriOlein=0.02702, H2SO4=0.001205, Fiber=0.1508, ... SolubleProtein=0.04805, InsolubleProtein=0.06967, ... total_flow=32720, units='kg/hr', ... ) >>> dryer = bst.DrumDryer('D610', ... (feed, 'dryer_air', 'natural_gas'), ... ('dryed_solids', 'hot_air', 'emissions'), ... moisture_content=0.10, split=dict(Ethanol=1.0) ... ) >>> dryer.simulate() >>> dryer.show('cwt100') DrumDryer: D610 ins... [0] feed phase: 'l', T: 352.33 K, P: 101325 Pa flow (%): Water 67.5 Ethanol 0.000504 Ash 1.98 Yeast 0.845 CaO 0.0145 TriOlein 2.7 H2SO4 0.12 Fiber 15.1 SolubleProtein 4.8 InsolubleProtein 6.97 ---------------- 3.27e+04 kg/hr [1] dryer_air phase: 'g', T: 298.15 K, P: 1.01325e+06 Pa flow (%): O2 23.3 N2 76.7 -- 1.33e+06 kg/hr [2] natural_gas phase: 'g', T: 298.15 K, P: 101325 Pa flow: 2.45e+03 kg/hr CH4 outs... [0] dryed_solids phase: 'l', T: 343.15 K, P: 101325 Pa flow (%): Water 10 Ash 5.48 Yeast 2.34 CaO 0.04 TriOlein 7.48 H2SO4 0.334 Fiber 41.7 SolubleProtein 13.3 InsolubleProtein 19.3 ---------------- 1.18e+04 kg/hr [1] hot_air phase: 'g', T: 343.15 K, P: 1.01325e+06 Pa flow (%): Water 1.55 Ethanol 1.23e-05 O2 22.9 N2 75.5 ------- 1.35e+06 kg/hr [2] emissions phase: 'g', T: 373.15 K, P: 101325 Pa flow (%): Water 45 CO2 55 ----- 1.22e+04 kg/hr >>> dryer.results() Drum dryer Units D610 Electricity Power kW 845 Cost USD/hr 66 Natural gas (inlet) Flow kg/hr 2.45e+03 Cost USD/hr 534 Design Evaporation kg/hr 2.09e+04 Volume 1.05e+03 Diameter m 3.76 Length 94 Peripheral drum area m2 1.11e+03 Purchase cost Drum dryer USD 1.2e+06 Total purchase cost USD 1.2e+06 Installed equipment cost USD 2.46e+06 Utility cost USD/hr 600 """ # auxiliary_unit_names = ('heat_exchanger',) _units = {'Evaporation': 'kg/hr', 'Peripheral drum area': 'm2', 'Diameter': 'm'} _N_ins = 3 _N_outs = 3 @property def isplit(self): """[ChemicalIndexer] Componentwise split of feed to 0th outlet stream.""" return self._isplit @property def split(self): """[Array] Componentwise split of feed to 0th outlet stream.""" return self._isplit.data @property def natural_gas(self): """[Stream] Natural gas to satisfy steam and electricity requirements.""" return self.ins[2] def _init(self, split, RH=0.80, H=20., length_to_diameter=25, T=343.15, P=10*101325, moisture_content=0.15, utility_agent='Natural gas', gas_composition=None, moisture_ID=None): self._isplit = self.chemicals.isplit(split) self.define_utility('Natural gas', self.natural_gas) self.P = P self.T = T self.RH = RH self.H = H self.gas_composition = [('N2', 0.79), ('O2', 0.21)] if gas_composition is None else gas_composition self.length_to_diameter = length_to_diameter self.moisture_content = moisture_content self.utility_agent = utility_agent self.moisture_ID = 'Water' if moisture_ID is None else moisture_ID @property def utility_agent(self): return self._utility_agent @utility_agent.setter def utility_agent(self, utility_agent): if utility_agent not in ('Natural gas', 'Steam'): raise ValueError(f"utility agent must be either 'Steam' or 'Natural gas'; not '{utility_agent}'") self._utility_agent = utility_agent def _get_moisture_vapor_pressure(self, T): chemical = self.thermo.chemicals[self.moisture_ID] return chemical.Psat(T) def _convert_air_mol_to_mass(self, n_air, gas_composition): mol_weight_air = 0. for chem, x in gas_composition: chem_mol_weight = self.thermo.chemicals[chem].MW mol_weight_air += x / chem_mol_weight return n_air / mol_weight_air def _run(self): wet_solids, dry_gas, natural_gas = self.ins dry_solids, hot_air, emissions = self.outs wet_solids.split_to(hot_air, dry_solids, self.split) sep.adjust_moisture_content(dry_solids, hot_air, self.moisture_content, self.moisture_ID) hot_air.P = dry_gas.P = self.P emissions.phase = dry_gas.phase = natural_gas.phase = hot_air.phase = 'g' design_results = self.design_results design_results['Evaporation'] = hot_air.F_mass # Calculate n_moisture_ID and n_evap_compounds n_moisture = hot_air.imol[self.moisture_ID] # Calculate y_moisture_ID Psat = self._get_moisture_vapor_pressure(self.T) P = self.P if Psat > P: warn( f'saturated pressure of {self.moisture_ID} ({int(Psat)} Pa) ' f'is greater than operating pressure ({int(P)} Pa)', category=DesignWarning, ) Psat = P y_moisture = self.RH * Psat / P # Calculate total gas flow (molar basis) n_other = n_moisture * (1 - y_moisture) / y_moisture dry_gas.reset_flow(**dict(self.gas_composition), total_flow=n_other) # In kmol / hr hot_air.mol += dry_gas.mol dry_solids.T = hot_air.T = self.T emissions.T = self.T + 30. natural_gas.empty() emissions.empty() if self.utility_agent == 'Natural gas': LHV = self.chemicals.CH4.LHV def f(CH4): CO2 = CH4 H2O = 2. * CH4 natural_gas.imol['CH4'] = CH4 emissions.imol['CO2', 'H2O'] = [CO2, H2O] duty = (dry_solids.H + hot_air.H + emissions.H) - (wet_solids.H + dry_gas.H + natural_gas.H) CH4 = duty / LHV return CH4 flx.wegstein(f, 0., 1e-3) def _design(self): length_to_diameter = self.length_to_diameter design_results = self.design_results design_results['Volume'] = volume = design_results['Evaporation'] / self.H design_results['Diameter'] = diameter = cylinder_diameter_from_volume(volume, length_to_diameter) design_results['Length'] = length = diameter * length_to_diameter design_results['Peripheral drum area'] = cylinder_area(diameter, length) if self.utility_agent == 'Steam': self.add_heat_utility(self.H_out - self.H_in, self.T)
class ThermalOxidizer(Unit): """ Create a ThermalOxidizer that burns any remaining combustibles. Parameters ---------- ins : [0] Feed gas [1] Air [2] Natural gas outs : Emissions. tau : float, optional Residence time [hr]. Defaults to 0.00014 (0.5 seconds). duty_per_kg : float, optional Duty per kg of feed. Defaults to 105858 kJ / kg. V_wf : float, optional Fraction of working volume. Defaults to 0.95. Notes ----- Adiabatic operation is assumed. Simulation and cost is based on [1]_. """ _N_ins = 3 _N_outs = 1 max_volume = 20. # m3 _F_BM_default = {'Vessels': 2.06} # Assume same as dryer @property def natural_gas(self): """[Stream] Natural gas to satisfy steam and electricity requirements.""" return self.ins[2] def _init(self, tau=0.00014, duty_per_kg=61.): self.define_utility('Natural gas', self.natural_gas) self.tau = tau self.duty_per_kg = duty_per_kg def _run(self): feed, air, ng = self.ins ng.imol['CH4'] = self.duty_per_kg * feed.F_mass / self.chemicals.CH4.LHV ng.phase = 'g' air.phase = 'g' emissions, = self.outs ng.P = air.P = emissions.P = feed.P emissions.phase = 'g' ng_burned = ng.copy() combustion_rxns = self.chemicals.get_combustion_reactions() # Enough oxygen must be present in air to burn natural gas combustion_rxns.force_reaction(ng_burned) O2 = max(-ng_burned.imol['O2'], 0.) air.imol['N2', 'O2'] = [0.79/0.21 * O2, O2] # Enough oxygen must be present in air to burn feed as well emissions.mix_from(self.ins) dummy_emissions = emissions.copy() combustion_rxns.force_reaction(dummy_emissions) O2 = max(-dummy_emissions.imol['O2'], 0.) # Missing oxygen air.imol['N2', 'O2'] += [0.79/0.21 * O2, O2] emissions.mix_from(self.ins) # Account for temperature raise combustion_rxns.adiabatic_reaction(emissions) def _design(self): design_results = self.design_results volume = self.tau * self.ins[0].F_vol V_max = self.max_volume design_results['Number of vessels'] = N = np.ceil(volume / V_max) design_results['Vessel volume'] = volume / N design_results['Total volume'] = volume def _cost(self): design_results = self.design_results N = design_results['Number of vessels'] vessel_volume = design_results['Vessel volume'] C = self.baseline_purchase_costs C['Vessels'] = N * 918300. * (vessel_volume / 13.18)**0.6