diff --git a/qsdsan/_process.py b/qsdsan/_process.py index d795d362..27d4abe4 100644 --- a/qsdsan/_process.py +++ b/qsdsan/_process.py @@ -694,6 +694,7 @@ def _rate_eq2func(self): def f(state_arr, params={}): states = dict(zip(var_kw, state_arr)) return lamb(**states, **params) + self.kinetics(function=f, parameters=self.parameters) def _normalize_stoichiometry(self, new_ref): diff --git a/qsdsan/_sanunit.py b/qsdsan/_sanunit.py index 30f0bdcf..12c43a25 100644 --- a/qsdsan/_sanunit.py +++ b/qsdsan/_sanunit.py @@ -210,7 +210,7 @@ def __init__(self, ID='', ins=None, outs=(), thermo=None, init_with='WasteStream if not kwargs.get('skip_property_package_check'): self._assert_compatible_property_package() - + self._utility_cost = None ##### qsdsan-specific ##### diff --git a/qsdsan/_waste_stream.py b/qsdsan/_waste_stream.py index ceeb6296..3f597fbd 100644 --- a/qsdsan/_waste_stream.py +++ b/qsdsan/_waste_stream.py @@ -633,7 +633,7 @@ def composite(self, variable, flow=False, exclude_gas=True, if specification: try: specified_IDs = set(_get(all_cmps, specification)) - except AttributeError: # no pre-defined groups + except AttributeError: # no predefined groups try: specified_IDs = _specific_groups[specification] except KeyError: # specification not in the default ones @@ -1073,7 +1073,7 @@ def get_VSS(self, include_colloidal=False): return VSS def get_ISS(self): - '''[float] Inorganic/involatile suspended solids, in mg/L.''' + '''[float] Inorganic/non-volatile suspended solids, in mg/L.''' return self.composite('solids', particle_size='x', volatile=False) @@ -1219,7 +1219,11 @@ def scope(self, s): self._scope = s def _init_state(self): - self.state = np.append(self.conc.astype('float64'), self.get_total_flow('m3/d')) + if self.phase == 'l': + self.state = np.append(self.conc.astype('float64'), self.get_total_flow('m3/d')) + else: + Q = self.F_vol # m3/hr + self.state = np.append(self.mass.astype('float64')/Q*1e3, Q*24) self.dstate = np.zeros_like(self.state) def _state2flows(self): diff --git a/qsdsan/data/process_data/_adm1_p_extension.tsv b/qsdsan/data/process_data/_adm1_p_extension.tsv new file mode 100644 index 00000000..64b881ff --- /dev/null +++ b/qsdsan/data/process_data/_adm1_p_extension.tsv @@ -0,0 +1,21 @@ + S_su S_aa S_fa S_va S_bu S_pro S_ac S_h2 S_ch4 S_IC S_IN S_IP S_I X_ch X_pr X_li X_su X_aa X_fa X_c4 X_pro X_ac X_h2 X_I X_PHA X_PP X_PAO S_K S_Mg X_MeOH X_MeP +hydrolysis_carbs 1 ? ? ? -1 +hydrolysis_proteins 1 ? ? ? -1 +hydrolysis_lipids 1-f_fa_li f_fa_li ? ? ? -1 +uptake_sugars -1 (1-Y_su)*f_bu_su (1-Y_su)*f_pro_su (1-Y_su)*f_ac_su (1-Y_su)*f_h2_su ? ? ? Y_su +uptake_amino_acids -1 (1-Y_aa)*f_va_aa (1-Y_aa)*f_bu_aa (1-Y_aa)*f_pro_aa (1-Y_aa)*f_ac_aa (1-Y_aa)*f_h2_aa ? ? ? Y_aa +uptake_LCFA -1 (1-Y_fa)*f_ac_fa (1-Y_fa)*f_h2_fa ? ? ? Y_fa +uptake_valerate -1 (1-Y_c4)*f_pro_va (1-Y_c4)*f_ac_va (1-Y_c4)*f_h2_va ? ? ? Y_c4 +uptake_butyrate -1 (1-Y_c4)*f_ac_bu (1-Y_c4)*f_h2_bu ? ? ? Y_c4 +uptake_propionate -1 (1-Y_pro)*f_ac_pro (1-Y_pro)*f_h2_pro ? ? ? Y_pro +uptake_acetate -1 1-Y_ac ? ? ? Y_ac +uptake_h2 -1 1-Y_h2 ? ? ? Y_h2 +decay_Xsu ? ? ? f_sI_xb f_ch_xb f_pr_xb f_li_xb -1 f_xI_xb +decay_Xaa ? ? ? f_sI_xb f_ch_xb f_pr_xb f_li_xb -1 f_xI_xb +decay_Xfa ? ? ? f_sI_xb f_ch_xb f_pr_xb f_li_xb -1 f_xI_xb +decay_Xc4 ? ? ? f_sI_xb f_ch_xb f_pr_xb f_li_xb -1 f_xI_xb +decay_Xpro ? ? ? f_sI_xb f_ch_xb f_pr_xb f_li_xb -1 f_xI_xb +decay_Xac ? ? ? f_sI_xb f_ch_xb f_pr_xb f_li_xb -1 f_xI_xb +decay_Xh2 ? ? ? f_sI_xb f_ch_xb f_pr_xb f_li_xb -1 f_xI_xb +lysis_XPAO ? ? ? f_sI_xb f_ch_xb f_pr_xb f_li_xb f_xI_xb -1 +lysis_XPHA f_va_PHA f_bu_PHA f_pro_PHA f_ac_PHA ? ? ? -1 \ No newline at end of file diff --git a/qsdsan/processes/__init__.py b/qsdsan/processes/__init__.py index 9e0c5e9d..394a97f8 100644 --- a/qsdsan/processes/__init__.py +++ b/qsdsan/processes/__init__.py @@ -17,6 +17,7 @@ from ._asm1 import * from ._asm2d import * from ._adm1 import * +from ._adm1_p_extension import * from ._madm1 import * from ._decay import * from ._kinetic_reaction import * @@ -27,6 +28,7 @@ _asm1, _asm2d, _adm1, + _adm1_p_extension, _madm1, _decay, _kinetic_reaction, @@ -37,6 +39,7 @@ *_asm1.__all__, *_asm2d.__all__, *_adm1.__all__, + *_adm1_p_extension.__all__, *_madm1.__all__, *_decay.__all__, *_kinetic_reaction.__all__, diff --git a/qsdsan/processes/_adm1_p_extension.py b/qsdsan/processes/_adm1_p_extension.py new file mode 100644 index 00000000..95df5942 --- /dev/null +++ b/qsdsan/processes/_adm1_p_extension.py @@ -0,0 +1,850 @@ +# -*- coding: utf-8 -*- +''' +QSDsan: Quantitative Sustainable Design for sanitation and resource recovery systems + +This module is developed by: + Joy Zhang + Saumitra Rai + + +Part of this module is based on the Thermosteam package: +https://github.com/BioSTEAMDevelopmentGroup/thermosteam + +This module is under the University of Illinois/NCSA Open Source License. +Please refer to https://github.com/QSD-Group/QSDsan/blob/main/LICENSE.txt +for license details. +''' + +from thermosteam.utils import chemicals_user +from thermosteam import settings +from chemicals.elements import molecular_weight as get_mw +from qsdsan import Component, Components, Process, Processes, CompiledProcesses +import numpy as np +from qsdsan.utils import ospath, data_path +from scipy.optimize import brenth +from warnings import warn + +__all__ = ('create_adm1_p_extension_cmps', 'ADM1_p_extension', + 'non_compet_inhibit', 'substr_inhibit', + 'T_correction_factor', + 'pH_inhibit', 'Hill_inhibit', + 'rhos_adm1_p_extension') + +_path = ospath.join(data_path, 'process_data/_adm1_p_extension.tsv') +_load_components = settings.get_default_chemicals + +#%% +# ============================================================================= +# ADM1 (with P extension) -specific components +# ============================================================================= + +C_mw = get_mw({'C':1}) +N_mw = get_mw({'N':1}) +P_mw = get_mw({'P':1}) + +def create_adm1_p_extension_cmps(set_thermo=True): + cmps_all = Components.load_default() + + # varies + # X_c = cmps_all.X_OHO.copy('X_c') + # X_c.description = 'Composite' + # X_c.i_C = 0.02786 * C_mw + # X_c.i_N = 0.0376 + + X_ch = Component.from_chemical('X_ch', chemical='glycogen', Tc=1011.4, # glucan + description='Carbohydrates', + measured_as='COD', + particle_size='Particulate', + degradability='Slowly', + organic=True) + # X_ch = cmps_all.X_B_Subst.copy('X_ch') + # X_ch.i_N = 0 + # X_ch.i_C = 0.0313 * C_mw + + # varies + X_pr = cmps_all.X_B_Subst.copy('X_pr') + X_pr.i_N = 0.007 * N_mw + X_pr.i_C = 0.03 * C_mw + + X_li = Component.from_chemical('X_li', chemical='tripalmitin', + description='Lipids', + measured_as='COD', + particle_size='Particulate', + degradability='Slowly', + organic=True) + + # both varies + X_I = cmps_all.X_U_Inf.copy('X_I') + S_I = cmps_all.S_U_Inf.copy('S_I') + X_I.i_C = S_I.i_C = 0.03 * C_mw + X_I.i_N = S_I.i_N = 0.06 + + S_su = Component.from_chemical('S_su', chemical='glucose', + description='Monosaccharides', + measured_as='COD', + particle_size='Soluble', + degradability='Readily', + organic=True) + # S_su = cmps_all.S_F.copy('S_su') + # S_su.i_N = 0 + # S_su.i_C = 0.0313 * 12 + + # varies + S_aa = cmps_all.S_F.copy('S_aa') + S_aa.i_N = 0.007 * N_mw + S_aa.i_P = 0 + S_aa.i_C = 0.03 * C_mw + + S_fa = Component.from_chemical('S_fa', chemical='palmitate', + description='Total long-chain fatty acids', + measured_as='COD', + particle_size='Soluble', + degradability='Readily', + organic=True) + + S_va = Component.from_chemical('S_va', chemical='valerate', + description='Total valerate', + measured_as='COD', + particle_size='Soluble', + degradability='Readily', + organic=True) + + S_bu = Component.from_chemical('S_bu', chemical='butyrate', + description='Total butyrate', + measured_as='COD', + particle_size='Soluble', + degradability='Readily', + organic=True) + + S_pro = cmps_all.S_Prop.copy('S_pro') + S_ac = cmps_all.S_Ac.copy('S_ac') + S_h2 = cmps_all.S_H2.copy('S_h2') + S_ch4 = cmps_all.S_CH4.copy('S_ch4') + + S_IC = Component.from_chemical('S_IC', chemical='CO2', + measured_as='C', + description='Inorganic carbon', + particle_size='Dissolved gas', + degradability='Undegradable', + organic=False) + S_IC.copy_models_from(S_ch4, ('Cn',)) + + S_IN = Component.from_chemical('S_IN', chemical='NH3', + measured_as='N', + description='Inorganic nitrogen', + particle_size='Soluble', + degradability='Undegradable', + organic=False) + + S_IP = cmps_all.S_PO4.copy('S_IP') + + X_su = cmps_all.X_FO.copy('X_su') + X_su.description = 'Biomass uptaking sugars' + + X_aa = cmps_all.X_FO.copy('X_aa') + X_aa.description = 'Biomass uptaking amino acids' + + X_fa = cmps_all.X_FO.copy('X_fa') + X_fa.description = 'Biomass uptaking long chain fatty acids' + + X_c4 = cmps_all.X_FO.copy('X_c4') + X_c4.description = 'Biomass uptaking c4 fatty acids' + + X_pro = cmps_all.X_PRO.copy('X_pro') + X_ac = cmps_all.X_ACO.copy('X_ac') + X_h2 = cmps_all.X_HMO.copy('X_h2') + + X_PHA = cmps_all.X_PAO_PHA.copy('X_PHA') + + X_PP = cmps_all.X_PAO_PP_Lo.copy('X_PP') + + X_PAO = cmps_all.X_PAO.copy('X_PAO') + + S_K = Component.from_chemical('S_K', chemical='K', + measured_as='K', + description='Potassium', + particle_size='Soluble', + degradability='Undegradable', + organic=False) + + S_Mg = Component.from_chemical('S_Mg', chemical='Mg', + measured_as='Mg', + description='Magnesium', + particle_size='Soluble', + degradability='Undegradable', + organic=False) + + # X_MeOH and X_MeP are added at a later iteration of p_extension ADM1. + # They do not participate in any ADM1 process, and would be directly + # mapped to ASM2d components at the ASM-ADM-ASM interface. + X_MeOH = cmps_all.X_FeOH.copy('X_MeOH') + X_MeP = cmps_all.X_FePO4.copy('X_MeP') + + for bio in (X_su, X_aa, X_fa, X_c4, X_pro, X_ac, X_h2): + # bio.formula = 'C5H7O2N' + bio.i_C = 0.0313 * C_mw + bio.i_N = 0.08 + + S_cat = cmps_all.S_CAT.copy('S_cat') + S_an = cmps_all.S_AN.copy('S_an') + S_cat.i_mass = S_an.i_mass = 1 + + cmps_adm1_p_extension = Components([S_su, S_aa, S_fa, S_va, S_bu, S_pro, S_ac, S_h2, + S_ch4, S_IC, S_IN, S_IP, S_I, X_ch, X_pr, X_li, + X_su, X_aa, X_fa, X_c4, X_pro, X_ac, X_h2, X_I, + X_PHA, X_PP, X_PAO, S_K, S_Mg, X_MeOH, X_MeP, + S_cat, S_an, cmps_all.H2O]) + cmps_adm1_p_extension.default_compile() + if set_thermo: settings.set_thermo(cmps_adm1_p_extension) + return cmps_adm1_p_extension + +# create_adm1_p_extension_cmps() + + +#%% +# ============================================================================= +# kinetic rate functions +# ============================================================================= + +R = 8.3145e-2 # Universal gas constant, [bar/M/K] + +def non_compet_inhibit(Si, Ki): + return Ki/(Ki+Si) + +def substr_inhibit(Si, Ki): + return Si/(Ki+Si) + +def mass2mol_conversion(cmps): + '''conversion factor from kg[measured_as]/m3 to mol[component]/L''' + return cmps.i_mass / cmps.chem_MW + +# def T_correction_factor(T1, T2, theta): +# return np.exp(theta * (T2-T1)) + +def T_correction_factor(T1, T2, delta_H): + return np.exp(delta_H/(R*100) * (1/T1 - 1/T2)) # R converted to SI + +# def calc_Kas(pKas, T_base, T_op, theta): +# pKas = np.asarray(pKas) +# return 10**(-pKas) * T_correction_factor(T_base, T_op, theta) + +def acid_base_rxn(h_ion, weak_acids_tot, Kas): + # h, nh4, hco3, ac, pr, bu, va = mols + # S_cat, S_K, S_Mg, S_an, S_IN, S_IP, S_IC, S_ac, S_pro, S_bu, S_va = weak_acids_tot # in M + S_cat, S_K, S_Mg, S_an, S_IN, S_IP = weak_acids_tot[:6] + # Kw, Ka_nh, Ka_h2po4, Ka_co2, Ka_ac, Ka_pr, Ka_bu, Ka_va = Kas + Kw = Kas[0] + oh_ion = Kw/h_ion + nh3, h2po4, hco3, ac, pro, bu, va = Kas[1:] * weak_acids_tot[4:] / (Kas[1:] + h_ion) + return S_cat + S_K + 2*S_Mg + h_ion + (S_IN - nh3) - S_an - oh_ion - hco3 - ac - pro - bu - va - (3*S_IP + h2po4) + +# The function 'fprime_abr' is not used in the code +def fprime_abr(h_ion, weak_acids_tot, Kas): + S_cat, S_K, S_Mg, S_an, S_IN, S_IP = weak_acids_tot[:6] + Kw = Kas[0] + doh_ion = - Kw / h_ion ** 2 + dnh3, dh2po4, dhco3, dac, dpro, dbu, dva = - Kas[1:] * weak_acids_tot[4:] / (Kas[1:] + h_ion)**2 + return 1 + (-dnh3) - dh2po4 - doh_ion - dhco3 - dhco3 - dac - dpro - dbu - dva + +def pH_inhibit(pH, ul, ll, lower_only=True): + if lower_only: + # if pH >= ul: return 1 + # else: return exp(-3 * ((pH-ul)/(ul-ll))**2) + low_by = np.minimum(pH-ul, 0) + return np.exp(-3 * (low_by/(ul-ll))**2) + else: + return (1+2*10**(0.5*(ll-ul)))/(1+10**(pH-ul)+10**(ll-pH)) + +def Hill_inhibit(H_ion, ul, ll): + n = 3/(ul-ll) + K = 10**(-(ul+ll)/2) + return 1/(1+(H_ion/K) ** n) + +rhos = np.zeros(28) # 28 kinetic processes (25 as defined in modified ADM1 + 3 for gases) +Cs = np.empty(25) # 25 processes as defined in modified ADM1 + +def rhos_adm1_p_extension(state_arr, params): + + ks = params['rate_constants'] + Ks = params['half_sat_coeffs'] + + cmps = params['components'] + # n = len(cmps) + pH_ULs = params['pH_ULs'] + pH_LLs = params['pH_LLs'] + KS_IN = params['KS_IN'] + KS_IP = params['KS_IP'] + KI_nh3 = params['KI_nh3'] + KIs_h2 = params['KIs_h2'] + KHb = params['K_H_base'] + Kab = params['Ka_base'] + KH_dH = params['K_H_dH'] + Ka_dH = params['Ka_dH'] + kLa = params['kLa'] + T_base = params['T_base'] + root = params['root'] + + # state_arr_cmps stated just for readability of code + # state_arr_cmps = [S_su, S_aa, S_fa, S_va, S_bu, S_pro, S_ac, S_h2, S_ch4, S_IC, S_IN, + # S_IP, S_I, X_ch, X_pr, X_li, X_su, X_aa, X_fa, X_c4, X_pro, X_ac, + # X_h2, X_I, X_PHA, X_PP, X_PAO, S_K, S_Mg, X_MeOH, X_MeP] + 5 components + + # Cs_ids = cmps.indices(['X_ch', 'X_pr', 'X_li', 'X_su', 'X_aa', + # 'X_fa', 'X_c4', 'X_c4', 'X_pro', 'X_ac', 'X_h2', + # 'X_su', 'X_aa', 'X_fa', 'X_c4', 'X_pro', 'X_ac', 'X_h2', + # 'X_PAO', 'X_PAO', 'X_PAO', 'X_PAO', 'X_PAO', 'X_PP', 'X_PHA']) + # Cs = state_arr[Cs_ids] + Cs[:7] = state_arr[13:20] + Cs[7:11] = state_arr[19:23] + Cs[11:18] = state_arr[16:23] + Cs[18:23] = state_arr[26] + Cs[23] = state_arr[25] + Cs[24] = state_arr[24] + + # substrates_ids = cmps.indices(['S_su', 'S_aa', 'S_fa', 'S_va', + # 'S_bu', 'S_pro', 'S_ac', 'S_h2']) + # substrates = state_arr[substrates_ids] + substrates = state_arr[:8] + + # S_va, S_bu, S_h2, S_IN = state_arr[cmps.indices(['S_va', 'S_bu', 'S_h2', 'S_IN'])] + # S_va, S_bu, S_h2, S_ch4, S_IC, S_IN = state_arr[[3,4,7,8,9,10]] + S_va, S_bu, S_h2, S_IN, S_IP = state_arr[[3,4,7,10,11]] + unit_conversion = mass2mol_conversion(cmps) + # Should I let X_MeOH, and X_MeP unit convert? (ask Joy) + cmps_in_M = state_arr[:34] * unit_conversion + # weak acids (ADM1) = [S_ca, S_an, S_IN, S_IC, S_ac, S_pro, S_bu, S_va] + # weak acids (modified_ADM1) = [S_ca, S_K, S_Mg, S_an, S_IN, S_IP, S_IC, S_ac, S_pro, S_bu, S_va] + weak_acids = cmps_in_M[[31, 27, 28, 32, 10, 11, 9, 6, 5, 4, 3]] + + T_op = state_arr[-1] + # biogas_S = S_h2, S_ch4, S_IC + # biogas_p + biogas_S = state_arr[7:10].copy() + biogas_p = R * T_op * state_arr[34:37] + # Kas = Kab * T_correction_factor(T_base, T_op, Ka_dH) + # KH = KHb * T_correction_factor(T_base, T_op, KH_dH) / unit_conversion[7:10] + + if T_op == T_base: + Kas = Kab + KH = KHb / unit_conversion[7:10] + else: + T_temp = params.pop('T_op', None) + if T_op == T_temp: + params['T_op'] = T_op + Kas = params['Ka'] + KH = params['KH'] + else: + params['T_op'] = T_op + Kas = params['Ka'] = Kab * T_correction_factor(T_base, T_op, Ka_dH) + KH = params['KH'] = KHb * T_correction_factor(T_base, T_op, KH_dH) / unit_conversion[7:10] + + rhos[:-3] = ks * Cs + rhos[3:11] *= substr_inhibit(substrates, Ks[0:8]) + if S_va > 0: rhos[6] *= 1/(1+S_bu/S_va) + if S_bu > 0: rhos[7] *= 1/(1+S_va/S_bu) + + # substrates_ids = cmps.indices(['S_va', 'S_bu', 'S_pro', 'S_ac']) + # substrates_modified = state_arr[substrates_ids] + substrates_modified = state_arr[3:7] + K_a = Ks[-2] + rhos[18:22] *= substr_inhibit(substrates_modified, K_a) + + K_pp = Ks[-1] + S_conc = state_arr[25] + K_half_sat = K_pp*state_arr[26] + + rhos[18:22] *= substr_inhibit(S_conc, K_half_sat) + + # Multiplication by {Sva, Sbu, Spro, Sac}/(Sva + Sbu + Spro + Sac) + transformation_array = state_arr[3:7]/sum(state_arr[3:7]) + rhos[18:22] *= transformation_array + + h = brenth(acid_base_rxn, 1e-14, 1.0, + args=(weak_acids, Kas), + xtol=1e-12, maxiter=100) + + nh3 = Kas[1] * weak_acids[4] / (Kas[1] + h) + co2 = weak_acids[6] - Kas[2] * weak_acids[6] / (Kas[2] + h) + + biogas_S[-1] = co2 / unit_conversion[9] + + Iph = Hill_inhibit(h, pH_ULs, pH_LLs) + + Ih2 = non_compet_inhibit(S_h2, KIs_h2) + root.data = [-np.log10(h), Iph, Ih2] + + rhos[3:11] *= Iph * substr_inhibit(S_IN, KS_IN) * substr_inhibit(S_IP, KS_IP) + rhos[5:9] *= Ih2 + # rhos[4:12] *= Hill_inhibit(h, pH_ULs, pH_LLs) * substr_inhibit(S_IN, KS_IN) + # rhos[6:10] *= non_compet_inhibit(S_h2, KIs_h2) + rhos[9] *= non_compet_inhibit(nh3, KI_nh3) + rhos[-3:] = kLa * (biogas_S - KH * biogas_p) + return rhos +#%% +# ============================================================================= +# ADM1_p_extension class +# ============================================================================= +class TempState: + def __init__(self): + self.data = [] + + # def append(self, value): + # self.data += [value] + +@chemicals_user +class ADM1_p_extension(CompiledProcesses): + """ + Anaerobic Digestion Model No.1. [1]_, [2]_, [3]_ + + Parameters + ---------- + components : class:`CompiledComponents`, optional + Components corresponding to each entry in the stoichiometry array, + defaults to thermosteam.settings.chemicals. + path : str, optional + Alternative file path for the Petersen matrix. The default is None. + N_I : float, optional + Nitrogen content of inert organics [kmol N/kg COD]. The default is 4.286e-3. + N_aa : float, optional + Nitrogen content of amino acids [kmol N/kg COD]. The default is 7e-3. + f_sI_xb : float, optional + fraction of soluble inerts from biomass. The default is 0. + f_ch_xb : float, optional + fraction of carbohydrates from biomass. The default is 0.275. + f_pr_xb : float, optional + fraction of proteins from biomass. The default is 0.275. + f_li_xb : float, optional + fraction of lipids from biomass. The default is 0.35. + f_xI_xb : float, optional + fraction of particulate inerts from biomass. The default is 0.1. + f_fa_li : float, optional + Fraction of long chain fatty acids (LCFAs) from hydrolysis of lipids + [kg COD/kg COD]. The default is 0.95. + f_bu_su : float, optional + Fraction of butyrate from sugars [kg COD/kg COD]. The default is 0.13. + f_pro_su : float, optional + Fraction of propionate from sugars [kg COD/kg COD]. The default is 0.27. + f_ac_su : float, optional + Fraction of acetate from sugars [kg COD/kg COD]. The default is 0.41. + f_va_aa : float, optional + Fraction of valerate from amino acids [kg COD/kg COD]. The default is 0.23. + f_bu_aa : float, optional + Fraction of butyrate from amino acids [kg COD/kg COD]. The default is 0.26. + f_pro_aa : float, optional + Fraction of propionate from amino acids [kg COD/kg COD]. The default is 0.05. + f_ac_aa : float, optional + Fraction of acetate from amino acids [kg COD/kg COD]. The default is 0.4. + f_ac_fa : float, optional + Fraction of acetate from LCFAs [kg COD/kg COD]. The default is 0.7. + f_pro_va : float, optional + Fraction of propionate from LCFAs [kg COD/kg COD]. The default is 0.54. + f_ac_va : float, optional + Fraction of acetate from valerate [kg COD/kg COD]. The default is 0.31. + f_ac_bu : float, optional + Fraction of acetate from butyrate [kg COD/kg COD]. The default is 0.8. + f_ac_pro : float, optional + Fraction of acetate from propionate [kg COD/kg COD]. The default is 0.57. + f_ac_PHA : float, optional + Yield of acetate on PHA [kg COD/kg COD]. The default is 0.4. + f_bu_PHA : float, optional + Yield of butyrate on PHA [kg COD/kg COD]. The default is 0.1. + f_pro_PHA : float, optional + Yield of propionate on PHA [kg COD/kg COD]. The default is 0.4. + f_va_PHA : float, optional + Yield of valerate on PHA [kg COD/kg COD]. The default is 0.1. + Y_su : float, optional + Biomass yield of sugar uptake [kg COD/kg COD]. The default is 0.1. + Y_aa : float, optional + Biomass yield of amino acid uptake [kg COD/kg COD]. The default is 0.08. + Y_fa : float, optional + Biomass yield of LCFA uptake [kg COD/kg COD]. The default is 0.06. + Y_c4 : float, optional + Biomass yield of butyrate or valerate uptake [kg COD/kg COD]. + The default is 0.06. + Y_pro : float, optional + Biomass yield of propionate uptake [kg COD/kg COD]. The default is 0.04. + Y_ac : float, optional + Biomass yield of acetate uptake [kg COD/kg COD]. The default is 0.05. + Y_h2 : float, optional + Biomass yield of H2 uptake [kg COD/kg COD]. The default is 0.06. + Y_po4 : float, optional + Yield of biomass on phosphate [kmol P/kg COD]. The default is 0.013. + q_ch_hyd : float, optional + Carbohydrate hydrolysis rate constant [d^(-1)]. The default is 10. + q_pr_hyd : float, optional + Protein hydrolysis rate constant [d^(-1)]. The default is 10. + q_li_hyd : float, optional + Lipid hydrolysis rate constant [d^(-1)]. The default is 10. + k_su : float, optional + Sugar uptake rate constant [d^(-1)]. The default is 30. + k_aa : float, optional + Amino acid uptake rate constant [d^(-1)]. The default is 50. + k_fa : float, optional + LCFA uptake rate constant [d^(-1)]. The default is 6. + k_c4 : float, optional + Butyrate or valerate uptake rate constant [d^(-1)]. The default is 20. + k_pro : float, optional + Propionate uptake rate constant [d^(-1)]. The default is 13. + k_ac : float, optional + Acetate uptake rate constant [d^(-1)]. The default is 8. + k_h2 : float, optional + H2 uptake rate constant [d^(-1)]. The default is 35. + K_su : float, optional + Half saturation coefficient of sugar uptake [kg COD/m3]. + The default is 0.5. + K_aa : float, optional + Half saturation coefficient of amino acid uptake [kg COD/m3]. + The default is 0.3. + K_fa : float, optional + Half saturation coefficient of LCFA uptake [kg COD/m3]. + The default is 0.4. + K_c4 : float, optional + Half saturation coefficient of butyrate or valerate uptake [kg COD/m3]. + The default is 0.2. + K_pro : float, optional + Half saturation coefficient of propionate uptake [kg COD/m3]. + The default is 0.1. + K_ac : float, optional + Half saturation coefficient of acetate uptake [kg COD/m3]. + The default is 0.15. + K_h2 : float, optional + Half saturation coefficient of H2 uptake [kg COD/m3]. + The default is 7e-6. + K_a : float, optional + Saturation coefficient for acetate [kg COD/m3]. + The default is 0.004. + K_pp : float, optional + Saturation coefficient for polyphosphate [kg COD/m3]. + The default is 0.00032. + b_su : float, optional + Decay rate constant of sugar-uptaking biomass [d^(-1)]. + The default is 0.02. + b_aa : float, optional + Decay rate constant of amino-acid-uptaking biomass [d^(-1)]. + The default is 0.02. + b_fa : float, optional + Decay rate constant of LCFA-uptaking biomass [d^(-1)]. + The default is 0.02. + b_c4 : float, optional + Decay rate constant of valerate- or butyrate-uptaking biomass [d^(-1)]. + The default is 0.02. + b_pro : float, optional + Decay rate constant of propionate-uptaking biomass [d^(-1)]. + The default is 0.02. + b_ac : float, optional + Decay rate constant of acetate-uptaking biomass [d^(-1)]. + The default is 0.02. + b_h2 : float, optional + Decay rate constant of H2-uptaking biomass [d^(-1)]. The default is 0.02. + q_PHA : float, optional + Rate constant for storage of PHA [d^(-1)]. The default is 3. + b_PAO : float, optional + Lysis rate of PAOs [d^(-1)]. The default is 0.2. + b_PP : float, optional + Lysis rate of polyphosphates [d^(-1)]. The default is 0.2. + b_PHA : float, optional + Lysis rate of PHAs [d^(-1)]. The default is 0.2. + KI_h2_fa : float, optional + H2 inhibition coefficient for LCFA uptake [kg COD/m3]. The default is 5e-6. + KI_h2_c4 : float, optional + H2 inhibition coefficient for butyrate or valerate uptake [kg COD/m3]. + The default is 1e-5. + KI_h2_pro : float, optional + H2 inhibition coefficient for propionate uptake [kg COD/m3]. + The default is 3.5e-6. + KI_nh3 : float, optional + Free ammonia inhibition coefficient for acetate uptake [M]. + The default is 1.8e-3. + KS_IN : float, optional + Inorganic nitrogen (nutrient) inhibition coefficient for soluble + substrate uptake [M]. The default is 1e-4. + KS_IP : float, optional + P limitation for inorganic phosphorous [kmol P/m3]. + The default is 2e-5. + pH_limits_aa : 2-tuple, optional + Lower and upper limits of pH inhibition for acidogens and acetogens, + unitless. The default is (4,5.5). + pH_limits_ac : 2-tuple, optional + Lower and upper limits of pH inhibition for aceticlastic methanogens, + unitless. The default is (6,7). + pH_limits_h2 : 2-tuple, optional + Lower and upper limits of pH inhibition for H2-utilizing methanogens, + unitless. The default is (5,6). + T_base : float, optional + Base temperature for kinetic parameters [K]. The default is 298.15. + pKa_base : iterable[float], optional + pKa (equilibrium coefficient) values of acid-base pairs at the base + temperature, unitless, following the order of `ADM1._acid_base_pairs`. + The default is [14, 9.25, 7.20, 6.35, 4.76, 4.88, 4.82, 4.86]. + Ka_dH : iterable[float], optional + Heat of reaction of each acid-base pair at base temperature [J/mol], + following the order of `ADM1._acid_base_pairs`. The default is + [55900, 51965, 3600, 7646, 0, 0, 0, 0]. + ('H+', 'OH-'), ('NH4+', 'NH3'), ('H2PO4 2-', 'HPO4 2-'), ('CO2', 'HCO3-'), + ('HAc', 'Ac-'), ('HPr', 'Pr-'), ('HBu', 'Bu-'), ('HVa', 'Va-') + kLa : float, optional + Liquid-gas mass transfer coefficient [d^(-1)]. The default is 200. + K_H_base : iterable[float], optional + Henry's Law coefficients of biogas species at the base temperature + [M dissolved in liquid/bar]. Follows the order of `ADM1._biogas_IDs`. + The default is [7.8e-4, 1.4e-3, 3.5e-2]. + K_H_dH : iterable[float], optional + Heat of reaction of liquid-gas transfer of biogas species [J/mol]. + Follows the order of `ADM1._biogas_IDs`. The default is + [-4180, -14240, -19410]. + + Examples + -------- + >>> from qsdsan import processes as pc + >>> cmps = pc.create_adm1_p_extension_cmps() + >>> adm1_p = pc.ADM1_p_extension() + >>> adm1_p.show() + ADM1_p_extension([hydrolysis_carbs, hydrolysis_proteins, hydrolysis_lipids, uptake_sugars, uptake_amino_acids, uptake_LCFA, uptake_valerate, uptake_butyrate, uptake_propionate, uptake_acetate, uptake_h2, decay_Xsu, decay_Xaa, decay_Xfa, decay_Xc4, decay_Xpro, decay_Xac, decay_Xh2, storage_Sva_in_XPHA, storage_Sbu_in_XPHA, storage_Spro_in_XPHA, storage_Sac_in_XPHA, lysis_XPAO, lysis_XPP, lysis_XPHA, h2_transfer, ch4_transfer, IC_transfer]) + + References + ---------- + .. [1] Batstone, D. J.; Keller, J.; Angelidaki, I.; Kalyuzhnyi, S. V; + Pavlostathis, S. G.; Rozzi, A.; Sanders, W. T. M.; Siegrist, H.; + Vavilin, V. A. The IWA Anaerobic Digestion Model No 1 (ADM1). + Water Sci. Technol. 2002, 45 (10), 65–73. + .. [2] Rosen, C.; Jeppsson, U. Aspects on ADM1 Implementation within + the BSM2 Framework; Lund, 2006. + .. [3] Flores-Alsina, X.; Solon, K.; Kazadi Mbamba, C.; Tait, S.; + Gernaey, K. V.; Jeppsson, U.; Batstone, D. J. + Modelling phosphorus (P), sulfur (S) and iron (FE) interactions for + dynamic simulations of anaerobic digestion processes. Water Research. 2016, + 95, 370–382. + """ + + _stoichio_params = ('f_sI_xb', 'f_ch_xb', 'f_pr_xb', 'f_li_xb', 'f_xI_xb', + 'f_fa_li', 'f_bu_su', 'f_pro_su', 'f_ac_su', 'f_h2_su', + 'f_va_aa', 'f_bu_aa', 'f_pro_aa', 'f_ac_aa', 'f_h2_aa', + 'f_ac_fa', 'f_h2_fa', 'f_pro_va', 'f_ac_va', 'f_h2_va', + 'f_ac_bu', 'f_h2_bu', 'f_ac_pro', 'f_h2_pro', + 'f_ac_PHA', 'f_bu_PHA', 'f_pro_PHA', 'f_va_PHA', + 'Y_su', 'Y_aa', 'Y_fa', 'Y_c4', 'Y_pro', 'Y_ac', 'Y_h2', 'Y_po4', + 'K_XPP', 'Mg_XPP') + + _kinetic_params = ('rate_constants', 'half_sat_coeffs', 'pH_ULs', 'pH_LLs', + 'KS_IN', 'KS_IP', 'KI_nh3', 'KIs_h2', + 'Ka_base', 'Ka_dH', 'K_H_base', 'K_H_dH', 'kLa', + 'T_base', 'components', 'root') + + _acid_base_pairs = (('H+', 'OH-'), ('NH4+', 'NH3'), ('H2PO4-', 'HPO4 -2'), + ('CO2', 'HCO3-'), ('HAc', 'Ac-'), ('HPr', 'Pr-'), + + ('HBu', 'Bu-'), ('HVa', 'Va-')) + + _biogas_IDs = ('S_h2', 'S_ch4', 'S_IC') + + def __new__(cls, components=None, path=None, N_xc=2.686e-3, N_I=4.286e-3, N_aa=7e-3, + f_sI_xb=0, f_ch_xb=0.275, f_pr_xb=0.275, f_li_xb=0.350, + f_fa_li=0.95, f_bu_su=0.13, f_pro_su=0.27, f_ac_su=0.41, + f_va_aa=0.23, f_bu_aa=0.26, f_pro_aa=0.05, f_ac_aa=0.4, + f_ac_fa=0.7, f_pro_va=0.54, f_ac_va=0.31, f_ac_bu=0.8, f_ac_pro=0.57, + f_ac_PHA=0.4, f_bu_PHA=0.1, f_pro_PHA=0.4, + Y_su=0.1, Y_aa=0.08, Y_fa=0.06, Y_c4=0.06, Y_pro=0.04, Y_ac=0.05, Y_h2=0.06, Y_po4=0.013, + q_dis=0.5, q_ch_hyd=10, q_pr_hyd=10, q_li_hyd=10, + k_su=30, k_aa=50, k_fa=6, k_c4=20, k_pro=13, k_ac=8, k_h2=35, + K_su=0.5, K_aa=0.3, K_fa=0.4, K_c4=0.2, K_pro=0.1, K_ac=0.15, K_h2=7e-6, K_a=4e-3, K_pp=32e-5, + b_su=0.02, b_aa=0.02, b_fa=0.02, b_c4=0.02, b_pro=0.02, b_ac=0.02, b_h2=0.02, + q_PHA=3, b_PAO=0.2, b_PP=0.2, b_PHA=0.2, + KI_h2_fa=5e-6, KI_h2_c4=1e-5, KI_h2_pro=3.5e-6, KI_nh3=1.8e-3, KS_IN=1e-4, KS_IP=2e-5, + pH_limits_aa=(4,5.5), pH_limits_ac=(6,7), pH_limits_h2=(5,6), + T_base=298.15, pKa_base=[14, 9.25, 7.20, 6.35, 4.76, 4.88, 4.82, 4.86], + Ka_dH=[55900, 51965, 3600, 7646, 0, 0, 0, 0], + kLa=200, K_H_base=[7.8e-4, 1.4e-3, 3.5e-2], + K_H_dH=[-4180, -14240, -19410], + **kwargs): + + cmps = _load_components(components) + # Sure that some things are missing here! (Saumitra) + # cmps.X_c.i_N = N_xc * N_mw + cmps.X_I.i_N = cmps.S_I.i_N = N_I * N_mw + cmps.S_aa.i_N = cmps.X_pr.i_N = N_aa * N_mw + + if not path: path = _path + self = Processes.load_from_file(path, + components=cmps, + conserved_for=('C', 'N', 'P'), + parameters=cls._stoichio_params, + compile=False) + + # Need to add 5 processes separately for stoichiometry. See ASM2d for reference. + if path == _path: + _p19 = Process('storage_Sva_in_XPHA', + 'S_va + [Y_po4]X_PP -> [?]S_IC + [?]S_IN + [?]S_IP + X_PHA + [Y_po4*K_XPP]S_K + [Y_po4*Mg_XPP]S_Mg', + components=cmps, + ref_component='X_PHA', + rate_equation='q_PHA * S_va/(K_a+S_va) * (X_PP)/((K_PP*X_PAO) + X_PP) * X_PAO * S_va/(S_va+S_bu+S_pro+S_ac)', + parameters=('Y_po4', 'q_PHA', 'K_a', 'K_PP'), + conserved_for=('C', 'N', 'P')) + + _p20 = Process('storage_Sbu_in_XPHA', + 'S_bu + [Y_po4]X_PP -> [?]S_IC + [?]S_IN + [?]S_IP + X_PHA + [Y_po4*K_XPP]S_K + [Y_po4*Mg_XPP]S_Mg', + components=cmps, + ref_component='X_PHA', + rate_equation='q_PHA * S_bu/(K_a+S_bu) * (X_PP)/((K_PP*X_PAO) + X_PP) * X_PAO * S_bu/(S_va+S_bu+S_pro+S_ac)', + parameters=('Y_po4', 'q_PHA', 'K_a', 'K_PP'), + conserved_for=('C', 'N', 'P')) + + _p21 = Process('storage_Spro_in_XPHA', + 'S_pro + [Y_po4]X_PP -> [?]S_IC + [?]S_IN + [?]S_IP + X_PHA + [Y_po4*K_XPP]S_K + [Y_po4*Mg_XPP]S_Mg', + components=cmps, + ref_component='X_PHA', + rate_equation='q_PHA * S_pro/(K_a+S_pro) * (X_PP)/((K_PP*X_PAO) + X_PP) * X_PAO * S_pro/(S_va+S_bu+S_pro+S_ac)', + parameters=('Y_po4', 'q_PHA', 'K_a', 'K_PP'), + conserved_for=('C', 'N', 'P')) + + _p22 = Process('storage_Sac_in_XPHA', + 'S_ac + [Y_po4]X_PP -> [?]S_IC + [?]S_IN + [?]S_IP + X_PHA + [Y_po4*K_XPP]S_K + [Y_po4*Mg_XPP]S_Mg', + components=cmps, + ref_component='X_PHA', + rate_equation='q_PHA * S_ac/(K_a+S_ac) * (X_PP)/((K_PP*X_PAO) + X_PP) * X_PAO * S_ac/(S_va+S_bu+S_pro+S_ac)', + parameters=('Y_po4', 'q_PHA', 'K_a', 'K_PP'), + conserved_for=('C', 'N', 'P')) + + _p24 = Process('lysis_XPP', + 'X_PP -> [?]S_IC + [?]S_IN + [?]S_IP + [K_XPP]S_K + [Mg_XPP]S_Mg', + components=cmps, + ref_component='X_PP', + rate_equation='b_PP*X_PP', + parameters=('b_PP'), + conserved_for=('C', 'N', 'P')) + + self.insert(18, _p19) + self.insert(19, _p20) + self.insert(20, _p21) + self.insert(21, _p22) + self.insert(23, _p24) + + gas_transfer = [] + for i in cls._biogas_IDs: + new_p = Process('%s_transfer' % i.lstrip('S_'), + reaction={i:-1}, + ref_component=i, + conserved_for=(), + parameters=()) + gas_transfer.append(new_p) + self.extend(gas_transfer) + self.compile(to_class=cls) + + stoichio_vals = (f_sI_xb, f_ch_xb, f_pr_xb, f_li_xb, 1-f_sI_xb-f_ch_xb-f_pr_xb-f_li_xb, + f_fa_li, f_bu_su, f_pro_su, f_ac_su, 1-f_bu_su-f_pro_su-f_ac_su, + f_va_aa, f_bu_aa, f_pro_aa, f_ac_aa, 1-f_va_aa-f_bu_aa-f_pro_aa-f_ac_aa, + f_ac_fa, 1-f_ac_fa, f_pro_va, f_ac_va, 1-f_pro_va-f_ac_va, + f_ac_bu, 1-f_ac_bu, f_ac_pro, 1-f_ac_pro, + f_ac_PHA, f_bu_PHA, f_pro_PHA, 1-f_ac_PHA-f_bu_PHA-f_pro_PHA, + Y_su, Y_aa, Y_fa, Y_c4, Y_pro, Y_ac, Y_h2, Y_po4, cmps.X_PP.i_K, cmps.X_PP.i_Mg) + + pH_LLs = np.array([pH_limits_aa[0]]*6 + [pH_limits_ac[0], pH_limits_h2[0]]) + pH_ULs = np.array([pH_limits_aa[1]]*6 + [pH_limits_ac[1], pH_limits_h2[1]]) + + ks = np.array((q_ch_hyd, q_pr_hyd, q_li_hyd, + k_su, k_aa, k_fa, k_c4, k_c4, k_pro, k_ac, k_h2, + b_su, b_aa, b_fa, b_c4, b_pro, b_ac, b_h2, + q_PHA, q_PHA, q_PHA, q_PHA, b_PAO, b_PP, b_PHA)) + + Ks = np.array((K_su, K_aa, K_fa, K_c4, K_c4, K_pro, K_ac, K_h2, K_a, K_pp)) + + KIs_h2 = np.array((KI_h2_fa, KI_h2_c4, KI_h2_c4, KI_h2_pro)) + K_H_base = np.array(K_H_base) + K_H_dH = np.array(K_H_dH) + Ka_base = np.array([10**(-pKa) for pKa in pKa_base]) + Ka_dH = np.array(Ka_dH) + root = TempState() + # root.data = 10**(-7.4655) + dct = self.__dict__ + dct.update(kwargs) + + self.set_rate_function(rhos_adm1_p_extension) + dct['_parameters'] = dict(zip(cls._stoichio_params, stoichio_vals)) + + self.rate_function._params = dict(zip(cls._kinetic_params, + [ks, Ks, pH_ULs, pH_LLs, KS_IN*N_mw, KS_IP*P_mw, + KI_nh3, KIs_h2, Ka_base, Ka_dH, + K_H_base, K_H_dH, kLa, + T_base, self._components, root])) + + return self + + def set_pKas(self, pKas): + '''Set the pKa values of the acid-base reactions at the base temperature.''' + if len(pKas) != 8: + raise ValueError(f'pKas must be an array of 8 elements, one for each ' + f'acid-base pair, not {len(pKas)} elements.') + dct = self.rate_function._params + dct['Ka_base'] = np.array([10**(-pKa) for pKa in pKas]) + + def _find_index(self, process): + isa = isinstance + if isa(process, int): return process + elif isa(process, str): return self.index(process) + elif isa(process, Process): return self.index(process.ID) + else: raise TypeError(f'must input an int or str or :class:`Process`, ' + f'not {type(process)}') + + def set_rate_constant(self, k, process): + '''Set the reaction rate constant [d^(-1)] for a process given its ID.''' + i = self._find_index(process) + self.rate_function._params['rate_constants'][i] = k + + def set_half_sat_K(self, K, process): + '''Set the substrate half saturation coefficient [kg/m3] for a process given its ID.''' + i = self._find_index(process) + self.rate_function._params['half_sat_coeffs'][i-4] = K + + def set_pH_inhibit_bounds(self, process, lower=None, upper=None): + '''Set the upper and/or lower limit(s) of pH inhibition [unitless] for a process given its ID.''' + i = self._find_index(process) + dct = self.rate_function._params + if lower is None: lower = dct['pH_LLs'][i-4] + else: dct['pH_LLs'][i-4] = lower + if upper is None: upper = dct['pH_ULs'][i-4] + else: dct['pH_ULs'][i-4] = upper + if lower >= upper: + raise ValueError(f'lower limit for pH inhibition of {process} must ' + f'be lower than the upper limit, not {[lower, upper]}') + + def set_h2_inhibit_K(self, KI, process): + '''Set the H2 inhibition coefficient [kg/m3] for a process given its ID.''' + i = self._find_index(process) + self.rate_function._params['KIs_h2'][i-6] = KI + + def set_KS_IN(self, K): + '''Set inhibition coefficient for inorganic nitrogen as a secondary + substrate [M nitrogen].''' + self.rate_function._params['KS_IN'] = K * N_mw + + def set_KS_IP(self, K): + '''Set inhibition coefficient for inorganic phosphorous as a secondary + substrate [M phosphorous].''' + self.rate_function._params['KS_IP'] = K * P_mw + + def set_KI_nh3(self, K): + '''Set inhibition coefficient for free ammonia [M].''' + self.rate_function._params['KI_nh3'] = K + + def set_parameters(self, **parameters): + '''Set values to stoichiometric parameters in `ADM1._stoichio_params`.''' + non_stoichio = {} + for k,v in parameters.items(): + if k in self._stoichio_params: + if v >= 0 : self._parameters[k] = v + else: raise ValueError(f'{k} must >= 0, not {v}') + else: non_stoichio[k] = v + if non_stoichio: + warn(f'ignored value setting for non-stoichiometric parameters {non_stoichio}') + self.check_stoichiometric_parameters() + if self._stoichio_lambdified is not None: + self.__dict__['_stoichio_lambdified'] = None + + def check_stoichiometric_parameters(self): + '''Check whether product COD fractions sum up to 1 for each process.''' + stoichio = self.parameters + subst = ('xb', 'su', 'aa', 'fa', 'va', 'bu', 'pro', 'PHA') + for s in subst: + f_tot = sum([stoichio[k] for k in self._stoichio_params[:-7] \ + if k.endswith(s)]) + if f_tot != 1: + raise ValueError(f"the sum of 'f_()_{s}' values must equal 1") \ No newline at end of file diff --git a/qsdsan/sanunits/__init__.py b/qsdsan/sanunits/__init__.py index 625ad879..c8559ecc 100644 --- a/qsdsan/sanunits/__init__.py +++ b/qsdsan/sanunits/__init__.py @@ -25,6 +25,8 @@ Anna Kogler Jianan Feng + + Saumitra Rai This module is under the University of Illinois/NCSA Open Source License. Please refer to https://github.com/QSD-Group/QSDsan/blob/main/LICENSE.txt @@ -35,7 +37,6 @@ # Units that do not rely on other units from ._abstract import * -from ._clarifier import * from ._combustion import * from ._compressor import * from ._crop_application import * @@ -44,6 +45,7 @@ from ._excretion import * from ._heat_exchanging import * from ._junction import * +from ._membrane_gas_extraction import * from ._non_reactive import * from ._pumping import * from ._reactor import * @@ -57,6 +59,7 @@ # Units that rely on other units from ._activated_sludge_process import * from ._anaerobic_reactor import * +from ._clarifier import * from ._distillation import * from ._flash import * from ._hydroprocessing import * @@ -67,6 +70,7 @@ from ._membrane_distillation import * from ._polishing_filter import * from ._sedimentation import * +from ._sludge_treatment import * from ._septic_tank import * from ._toilet import * from ._treatment_bed import * @@ -98,6 +102,7 @@ _lagoon, _membrane_bioreactor, _membrane_distillation, + _membrane_gas_extraction, _non_reactive, _polishing_filter, _pumping, @@ -117,6 +122,7 @@ _biogenic_refinery, _eco_san, _reclaimer, + _sludge_treatment, ) @@ -160,4 +166,6 @@ *_biogenic_refinery.__all__, *_reclaimer.__all__, *_eco_san.__all__, + *_sludge_treatment.__all__, + *_membrane_gas_extraction.__all__, ) \ No newline at end of file diff --git a/qsdsan/sanunits/_activated_sludge_process.py b/qsdsan/sanunits/_activated_sludge_process.py index 9750ce5d..3f3ae89e 100644 --- a/qsdsan/sanunits/_activated_sludge_process.py +++ b/qsdsan/sanunits/_activated_sludge_process.py @@ -7,6 +7,8 @@ This module is developed by: Yalin Li + + Saumitra Rai This module is under the University of Illinois/NCSA Open Source License. Please refer to https://github.com/QSD-Group/QSDsan/blob/main/LICENSE.txt @@ -16,11 +18,21 @@ from warnings import warn from math import ceil from biosteam import Stream -from .. import SanUnit -from ..sanunits import HXutility, WWTpump +from .. import SanUnit, WasteStream +from ..sanunits import HXutility, WWTpump, Mixer + +from ..sanunits._pumping import ( + default_F_BM as default_WWTpump_F_BM, + default_equipment_lifetime as default_WWTpump_equipment_lifetime, + ) + from ..equipments import Blower, GasPiping from ..utils import auom, calculate_excavation_volume -__all__ = ('ActivatedSludgeProcess',) + +from qsdsan.utils import ospath, time_printer, load_data, get_SRT, get_oxygen_heterotrophs, get_oxygen_autotrophs, get_airflow, get_P_blower + + +__all__ = ('ActivatedSludgeProcess','TreatmentTrains',) _ft2_to_m2 = auom('ft2').conversion_factor('m2') F_BM_pump = 1.18*(1+0.007/100) # 0.007 is for miscellaneous costs @@ -361,6 +373,7 @@ def _run(self): 'Pump pipe stainless steel': 'kg', 'Pump stainless steel': 'kg', } + def _design(self): D = self.design_results D['HRT'] = self.HRT @@ -724,4 +737,432 @@ def constr_access(self): return self._constr_access @constr_access.setter def constr_access(self, i): - self._constr_access = i \ No newline at end of file + self._constr_access = i + + +class TreatmentTrains(Mixer): + ''' + Dummy unit with no run function of its own. To be used to calculate the + design and cost of treatment trains in ASP. Code largely dereived from code scripts + for [1]_. + + Parameters + ---------- + ins : Iterable + None expected (dummy unit). + outs : Iterable + None expected (dummy unit). + N_train : int + Number of treatment train, should be at least two in case one failing. + V_tank : float + Volume of tank (represents one treatment chain), [m3]. Default is 1000 m3. + W_tank : float + Width of tank, [m]. Default is 6.4 m. [1] + D_tank : float + Depth of tank, [m]. Default is 3.66 m. [1] + freeboard : float + Freeboard added to the depth of the reactor tank, [m]. Default is 0.61 m. [1] + W_dist : float + Width of distribution channel, [m]. Default is 1.37 m. [1] + W_eff: float + Width of effluent channel, [m]. Default is 1.5 m. [1] + W_recirculation: float + Width of recirculation channel, [m]. Default is 1.5 m. + excav_slope: float + Slope for excavation (horizontal/vertical). Default is 1.5. [1] + constr_access: float + Extra room for construction access, [m]. Default is 0.92 m. [1] + Q_air_design: float + Used to calculate the power of blower. + Air flow required for one treatment train [m3/hr]. + Q_recirculation: float + Used to calculate pumping power. + Design recirculated flow for one treatment train [m3/day]. ** confirm the logic of 'one' ** + kwargs : dict + Other attributes to be set. + + References + ---------- + .. [1] Shoener, B. D.; Zhong, C.; Greiner, A. D.; Khunjar, W. O.; Hong, P.-Y.; Guest, J. S. + Design of Anaerobic Membrane Bioreactors for the Valorization + of Dilute Organic Carbon Waste Streams. + Energy Environ. Sci. 2016, 9 (3), 1102–1112. + https://doi.org/10.1039/C5EE03715H. + + See Also + -------- + `MATLAB codes `_ used in ref 1, + especially the system layout `diagrams `_. + + ''' + + # Costs + wall_concrete_unit_cost = 1081.73 # $/m3 (Hydromantis. CapdetWorks 4.0. https://www.hydromantis.com/CapdetWorks.html) + slab_concrete_unit_cost = 582.48 # $/m3 (Hydromantis. CapdetWorks 4.0. https://www.hydromantis.com/CapdetWorks.html) + excav_unit_cost = (8 + 0.3) / 0.765 # $/m3, 0.765 is to convert from $/yd3 **NOT UPDATED** (taken from Shoener et al.) + + _t_wall = None + _t_slab = None + + pumps = ('recirculation_CSTR',) + + def __init__(self, ID='', ins= None, outs= (), thermo=None, init_with='WasteStream', + F_BM_default=1, isdynamic=False, N_train=2, V_tank=1000, + W_tank = 6.4, D_tank = 3.66, freeboard = 0.61, W_dist = 1.37, W_eff = 1.5, # all in meter (converted from feet to m, Shoener et al.2016) + W_recirculation = 1.5, # in m (assumed same as W_eff) + excav_slope = 1.5, # horizontal/vertical (Shoener et al.2016) + constr_access = 0.92, # in meter (converted from feet to m, Shoener et al. 2016) + Q_air_design = 1000, # in m3/hr **NO SOURCE FOR DEFAULT VALUE YET** + Q_recirculation = 1000, # in m3/day **NO SOURCE FOR DEFAULT VALUE YET** + F_BM=default_F_BM, lifetime=default_equipment_lifetime, rigorous=False, + **kwargs): + SanUnit.__init__(self, ID, ins, outs, thermo, init_with, + F_BM_default=F_BM_default, isdynamic=isdynamic) + + self._effluent = self.outs[0].copy(f'{ID}_effluent') + self.rigorous = rigorous + self._mixed = WasteStream(f'{ID}_mixed') + self.N_train = N_train + self.V_tank = V_tank + self.W_tank = W_tank + self.D_tank = D_tank + self.freeboard = freeboard + self.W_dist = W_dist + self.W_eff = W_eff + self.W_recirculation = W_recirculation + self.excav_slope = excav_slope + self.constr_access = constr_access + self.Q_air_design = Q_air_design # **NO SOURCE FOR DEFAULT VALUE YET** + self.Q_recirculation = Q_recirculation + + self.blower = blower = Blower('blower', linked_unit=self, N_reactor=N_train) + self.air_piping = air_piping = GasPiping('air_piping', linked_unit=self, N_reactor=N_train) + self.equipments = (blower, air_piping) + self.F_BM.update(F_BM) + + @property + def N_train(self): + ''' + [int] Number of treatment train, should be at least two in case one failing. + ''' + return self._N_train + + @N_train.setter + def N_train(self, i): + i = ceil(i) + if i < 2: + raise ValueError('`N_train` should be at least 2.') + self._N_train = i + + @property + def V_tank(self): + '''[float] Volume of tank, [m3].''' + return self._V_tank + + @V_tank.setter + def V_tank(self, i): + self._V_tank = i + + @property + def W_tank(self): + '''[float] Width of one tank, [m].''' + return self._W_tank + + @W_tank.setter + def W_tank(self, i): + self._W_tank = i + + @property + def D_tank(self): + '''[float] Depth of one tank, [m].''' + return self._D_tank + + @D_tank.setter + def D_tank(self, i): + self._D_tank = i + + @property + def W_dist(self): + '''[float] Width of the distribution channel, [m].''' + return self._W_dist + + @W_dist.setter + def W_dist(self, i): + self._W_dist = i + + @property + def W_eff(self): + '''[float] Width of the effluent channel, [m].''' + return self._W_eff + + @W_eff.setter + def W_eff(self, i): + self._W_eff = i + + @property + def W_recirculation(self): + '''[float] Width of the recirculation channel, [m].''' + return self._W_recirculation + + @W_recirculation.setter + def W_recirculation(self, i): + self._W_recirculation = i + + @property + def freeboard(self): + '''[float] Freeboard added to the depth of the reactor tank, [m].''' + return self._freeboard + + @freeboard.setter + def freeboard(self, i): + self._freeboard = i + + @property + def t_wall(self): + ''' + [float] Thickness of the wall concrete, [m]. + default to be minimum of 1 ft with 1 in added for every ft of depth over 12 ft. + ''' + D_tank = self.D_tank*39.37 # m to inches + return self._t_wall or (1 + max(D_tank - 12, 0)/12)*0.3048 # from feet to m + + @t_wall.setter + def t_wall(self, i): + self._t_wall = i + + @property + def t_slab(self): + ''' + [float] Concrete slab thickness, [m], + default to be 2 in thicker than the wall thickness. + ''' + return self._t_slab or (self.t_wall + 2/12)*0.3048 # from feet to m + + @t_slab.setter + def t_slab(self, i): + self._t_slab = i + + @property + def excav_slope(self): + '''[float] Slope for excavation (horizontal/vertical).''' + return self._excav_slope + + @excav_slope.setter + def excav_slope(self, i): + self._excav_slope = i + + @property + def constr_access(self): + '''[float] Extra room for construction access, [m].''' + return self._constr_access + + @constr_access.setter + def constr_access(self, i): + self._constr_access = i + + @property + def Q_air_design(self): + '''[float] Air flow required for one treatment train, [m3/hr].''' + return self._Q_air_design + + @Q_air_design.setter + def Q_air_design(self, i): + self._Q_air_design = i + + @property + def Q_recirculation(self): + '''[float] Design recirculated flow in the treatment train, [m3/day].''' + return self._Q_recirculation + + @Q_recirculation.setter + def Q_recirculation(self, i): + self._Q_recirculation = i + + def _design_pump(self): + + ID, pumps = self.ID, self.pumps + D = self.design_results + + self._effluent.copy_like(self.outs[0]) + effluent = self._effluent + + ins_dct = { + 'recirculation_CSTR': effluent, + } + + Q_recirculation = D['Q recirculation'] + meter_to_feet = 3.28 + Tank_length = D['Tank length']*meter_to_feet # in ft + + Q_recirculation_mgd = Q_recirculation*0.000264 #m3/day to MGD + + Q_mgd = { + 'recirculation_CSTR': Q_recirculation_mgd, + } + + type_dct = dict.fromkeys(pumps, 'recirculation_CSTR') + inputs_dct = dict.fromkeys(pumps, (self.N_train, Tank_length,)) + + for i in pumps: + if hasattr(self, f'{i}_pump'): + p = getattr(self, f'{i}_pump') + setattr(p, 'add_inputs', inputs_dct[i]) + else: + ID = f'{ID}_{i}' + capacity_factor=1 + pump = WWTpump( + ID=ID, ins= ins_dct[i], pump_type=type_dct[i], + Q_mgd=Q_mgd[i], add_inputs=inputs_dct[i], + capacity_factor=capacity_factor, + include_pump_cost=True, + include_building_cost=False, + include_OM_cost=True, + ) + setattr(self, f'{i}_pump', pump) + + pipe_ss, pump_ss = 0., 0. + for i in pumps: + p = getattr(self, f'{i}_pump') + p.simulate() + p_design = p.design_results + pipe_ss += p_design['Pump pipe stainless steel'] + pump_ss += p_design['Pump stainless steel'] + + return pipe_ss, pump_ss + + _units = { + 'Number of trains': 'ea', + 'Tank volume': 'm3', + 'HRT': 'hr', + 'Tank width': 'm', + 'Tank depth': 'm', + 'Tank length': 'm', + 'Wall concrete': 'm3', + 'Slab concrete': 'm3', + 'Excavation': 'm3', + 'Q recirculation': 'm3/day', + 'Pump pipe stainless steel': 'kg', + 'Pump stainless steel': 'kg', + } + + def _design(self): + self._mixed.mix_from(self.ins) + mixed = self._mixed + + D = self.design_results + + D['Number of trains'] = self.N_train + D['Tank volume'] = self.V_tank # in m3 + D['HRT'] = D['Tank volume']/mixed.get_total_flow('m3/hr') # in hr + D['Tank width'] = self.W_tank # in m + D['Tank depth'] = self.D_tank # in m + D['Tank length'] = D['Tank volume']/(D['Tank width']*D['Tank depth']) # in m + + + t_wall, t_slab = self.t_wall, self.t_slab + W_N_trains = (D['Tank width'] + 2*t_wall)*D['Number of trains'] - t_wall*(D['Number of trains'] - 1) + + D_tot = D['Tank depth'] + self.freeboard + t = t_wall + t_slab + + get_VWC = lambda L1, N: N * t_wall * L1 * D_tot # get volume of wall concrete + get_VSC = lambda L2: t * L2 * W_N_trains # get volume of slab concrete + + # Aeration tanks, [m3] + VWC = get_VWC(L1= D['Tank length'], N=(D['Number of trains'] + 1)) + VSC = get_VSC(L2= D['Tank length']) + + # Distribution channel, [m3] + W_dist, W_eff, W_recirculation = self.W_dist, self.W_eff, self.W_recirculation + VWC += get_VWC(L1=(W_N_trains+W_dist), N=2) # N =2 for two walls + VSC += get_VSC(L2=(W_dist + 2*t_wall)) + + # Effluent channel, [m3] + VWC += get_VWC(L1=(W_N_trains + W_eff), N=2) # N =2 for two walls + VSC += get_VSC(L2=(W_eff + 2*t_wall)) + + # RAS channel, [m3] + VWC += get_VWC(L1=(W_N_trains + W_recirculation), N=2) # N =2 for two walls + VSC += get_VSC(L2=(W_recirculation + 2*t_wall)) + + D['Wall concrete'] = VWC + D['Slab concrete'] = VSC + + # Excavation + excav_slope, constr_access = self.excav_slope, self.constr_access + # Aeration tank + VEX = calculate_excavation_volume( + L=(W_dist + D['Tank length']), W = W_N_trains, D = D['Tank depth'], + excav_slope=excav_slope, constr_access=constr_access) + + D['Excavation'] = VEX + + D['Q recirculation'] = self.Q_recirculation + + # Blower and gas piping (taken from 'ActivatedSludgeProcess' SanUnit) + + + # oxygen_autotroph = get_oxygen_autotrophs(inf.F_vol*24, inf.COD, eff_sCOD, + # inf_TKN, SRT=srt, Y_H=0.625, U_AUT=1.0, + # b_H=0.4, b_AUT=0.15, f_d=0.1, SF_DO=1.25) + + # oxygen_autotroph = get_oxygen_autotrophs(inf.F_vol*24, inf.COD, eff_sCOD, + # inf_TKN, SRT=srt, Y_H=0.625, U_AUT=1.0, + # b_H=0.4, b_AUT=0.15, f_d=0.1, SF_DO=1.25) + + # airflow = get_airflow(oxygen_heterotrophs = oxygen_heterotroph, oxygen_autotrophs = oxygen_autotroph, + # oxygen_transfer_efficiency = 15) # in m3/min + + + + Q_air_design = self.Q_air_design # in m3/min + air_cfm = auom('m3/min').convert(Q_air_design, 'cfm') + blower, piping = self.equipments + + blower.N_reactor = piping.N_reactor = D['Number of trains'] + blower.gas_demand_per_reactor = piping.gas_demand_per_reactor = air_cfm + self.add_equipment_design() + + # Pumps + pipe, pumps = self._design_pump() + D['Pump pipe stainless steel'] = pipe + D['Pump stainless steel'] = pumps + + def _cost(self): + D = self.design_results + C = self.baseline_purchase_costs + + ### Capital ### + # Concrete and excavation + C['Wall concrete'] = D['Wall concrete'] * self.wall_concrete_unit_cost + C['Slab concrete'] = D['Slab concrete'] * self.slab_concrete_unit_cost + C['Reactor excavation'] = D['Excavation'] * self.excav_unit_cost + + # Pump + pumps, add_OPEX = self.pumps, self.add_OPEX + pump_cost, building_cost, opex_o, opex_m = 0., 0., 0., 0. + for i in pumps: + p = getattr(self, f'{i}_pump') + p_cost, p_add_opex = p.baseline_purchase_costs, p.add_OPEX + pump_cost += p_cost['Pump'] + building_cost += p_cost['Pump building'] + opex_o += p_add_opex['Pump operating'] + opex_m += p_add_opex['Pump maintenance'] + + C['Pumps'] = pump_cost + C['Pump building'] = building_cost + add_OPEX['Pump operating'] = opex_o + add_OPEX['Pump maintenance'] = opex_m + + # Blower + self.add_equipment_cost() + + # Power + pumping = 0. + for ID in self.pumps: + p = getattr(self, f'{ID}_pump') + if p is None: + continue + pumping += p.power_utility.rate + + self.power_utility.rate = self.blower.design_results['Total blower power'] + pumping \ No newline at end of file diff --git a/qsdsan/sanunits/_clarifier.py b/qsdsan/sanunits/_clarifier.py index 0f7ca3fe..6b7ec213 100644 --- a/qsdsan/sanunits/_clarifier.py +++ b/qsdsan/sanunits/_clarifier.py @@ -6,7 +6,9 @@ Joy Zhang - Yalin Li + Yalin Li + + Saumitra Rai This module is under the University of Illinois/NCSA Open Source License. Please refer to https://github.com/QSD-Group/QSDsan/blob/main/LICENSE.txt @@ -14,25 +16,48 @@ ''' from numpy import maximum as npmax, minimum as npmin, exp as npexp +from warnings import warn from .. import SanUnit, WasteStream import numpy as np +from ..sanunits import WWTpump +from ..sanunits._pumping import default_F_BM as default_WWTpump_F_BM __all__ = ('FlatBottomCircularClarifier', - 'IdealClarifier',) - + 'IdealClarifier', + 'PrimaryClarifierBSM2', + 'PrimaryClarifier') + +F_BM_pump = 1.18*(1 + 0.007/100) # 0.007 is for miscellaneous costs + +default_F_BM = { + 'Pumps': F_BM_pump, + 'Pump building': F_BM_pump, + } + +default_equipment_lifetime = { + 'Pumps': 15, + 'Pump pipe stainless steel': 15, + 'Pump stainless steel': 15, + } + +# Assign a bare module of 1 to all +default_F_BM = { + 'Wall concrete': 1., + 'Slab concrete': 1., + 'Wall stainless steel': 1., + 'Scraper': 1, + 'v notch weir': 1, + 'Pumps': 1 + } +default_F_BM.update(default_WWTpump_F_BM) + +#%% Takács Clarifer def _settling_flux(X, v_max, v_max_practical, X_min, rh, rp, n0): X_star = npmax(X-X_min, n0) v = npmin(v_max_practical, v_max*(npexp(-rh*X_star) - npexp(-rp*X_star))) return X*npmax(v, n0) -# from math import exp -# def _settling_flux(X, v_max, v_max_practical, X_min, rh, rp, n0): -# X_star = max(X-X_min, 0) -# v = min(v_max_practical, v_max*(exp(-rh*X_star) - exp(-rp*X_star))) -# return X*max(v, 0) - - class FlatBottomCircularClarifier(SanUnit): """ A flat-bottom circular clarifier with a simple 1-dimensional @@ -74,30 +99,62 @@ class FlatBottomCircularClarifier(SanUnit): fns : float, optional Non-settleable fraction of the suspended solids, dimensionless. Must be within [0, 1]. The default is 2.28e-3. - + + downward_flow_velocity : float, optional + Speed on the basis of which center feed diameter is designed [m/hr]. The default is 42 m/hr (0.7 m/min). [2] + design_influent_TSS : float, optional + The design TSS concentration [mg/L] in the influent going to the secondary clarifier. + design_influent_flow : float, optional + The design influent tptal volumetric flow [m3/hr] going to the secondary clarifier. + design_solids_loading_rate : float, optional + Rate of total suspended solids entering the secondary clarifier (kg/(m2*hr)). + The default is 5 kg/(m2*hr) [3, 4] + References ---------- .. [1] Takács, I.; Patry, G. G.; Nolasco, D. A Dynamic Model of the Clarification -Thickening Process. Water Res. 1991, 25 (10), 1263–1271. https://doi.org/10.1016/0043-1354(91)90066-Y. - + .. [2] Chapter-12: Suspended-growth Treatment Processes. WEF Manual of Practice No. 8. + 6th Edition. Virginia: McGraw-Hill, 2018. + .. [3] Metcalf, Leonard, Harrison P. Eddy, and Georg Tchobanoglous. Wastewater + engineering: treatment, disposal, and reuse. Vol. 4. New York: McGraw-Hill, 1991. + .. [4] Introduction to Wastewater Clarifier Design by Nikolay Voutchkov, PE, BCEE. + .. [5] RECOMMENDED STANDARDS for WASTEWATER FACILITIES. 10 state standards. 2014 edition. """ _N_ins = 1 _N_outs = 3 - + + # Costs + wall_concrete_unit_cost = 1081.73 # $/m3 (Hydromantis. CapdetWorks 4.0. https://www.hydromantis.com/CapdetWorks.html) + slab_concrete_unit_cost = 582.48 # $/m3 (Hydromantis. CapdetWorks 4.0. https://www.hydromantis.com/CapdetWorks.html) + stainless_steel_unit_cost=1.8 # Alibaba. Brushed Stainless Steel Plate 304. https://www.alibaba.com/product-detail/brushed-stainless-steel-plate-304l-stainless_1600391656401.html?spm=a2700.details.0.0.230e67e6IKwwFd + + pumps = ('ras', 'was',) + def __init__(self, ID='', ins=None, outs=(), thermo=None, init_with='WasteStream', underflow=2000, wastage=385, surface_area=1500, height=4, N_layer=10, feed_layer=4, X_threshold=3000, v_max=474, v_max_practical=250, - rh=5.76e-4, rp=2.86e-3, fns=2.28e-3, isdynamic=True, **kwargs): + rh=5.76e-4, rp=2.86e-3, fns=2.28e-3, F_BM_default=default_F_BM, isdynamic=True, + downward_flow_velocity=42, design_influent_TSS = None, design_influent_flow = None, + design_solids_loading_rate = 6, **kwargs): - SanUnit.__init__(self, ID, ins, outs, thermo, init_with, isdynamic=isdynamic) + SanUnit.__init__(self, ID, ins, outs, thermo, init_with, isdynamic=isdynamic, F_BM_default=1) + self._h = height self._Qras = underflow self._Qwas = wastage self._sludge = WasteStream() - self._V = surface_area * height - self._A = surface_area + + if surface_area != None: + self._A = surface_area + elif design_influent_TSS != None and design_influent_flow != None: + self._A = (design_influent_TSS*design_influent_flow)/(design_solids_loading_rate*1000) # 1000 in denominator for unit conversion + else: + RuntimeError('Either surface_area, or design_influent_TSS and design_influent_flow expected from user') + + self._V = self._A * height self._hj = height/N_layer self._N_layer = N_layer self.feed_layer = feed_layer @@ -111,11 +168,30 @@ def __init__(self, ID='', ins=None, outs=(), thermo=None, self._solubles = None self._X_comp = np.zeros(len(self.components)) self._dX_comp = self._X_comp.copy() + + self._downward_flow_velocity = downward_flow_velocity # in m/hr (converted from 12 mm/sec) + self._design_tss = design_influent_TSS + self._design_flow = design_influent_flow + self._slr = design_solids_loading_rate + + self._mixed = WasteStream(f'{ID}_mixed') header = self._state_header self._state_header = list(header) + [f'TSS{i+1} [mg/L]' for i in range(N_layer)] for attr, value in kwargs.items(): setattr(self, attr, value) + + self._inf = self.ins[0].copy(f'{ID}_inf') + self._ras = self.outs[1].copy(f'{ID}_ras') + self._was = self.outs[2].copy(f'{ID}_was') + + @property + def height(self): + '''[float] Height of the clarifier in m.''' + return self._h + @height.setter + def height(self, h): + self._h = h @property def underflow(self): @@ -241,7 +317,19 @@ def fns(self): def fns(self, fns): if fns < 0 or fns > 1: raise ValueError('fns must be within [0,1].') self._fns = fns - + + @property + def solids_loading_rate(self): + '''solids_loading_rate is the loading in the clarifier''' + return self._slr + + @solids_loading_rate.setter + def solids_loading_rate(self, slr): + if slr is not None: + self._slr = slr + else: + raise ValueError('solids_loading_rate of the clarifier expected from user') + def set_init_solubles(self, **kwargs): '''set the initial concentrations [mg/L] of solubles in the clarifier.''' Cs = np.zeros(len(self.components)) @@ -295,20 +383,20 @@ def _update_state(self): X_e = arr[-n] * X_composition C_s = Z + arr[-1] * X_composition eff, ras, was = self._outs - if eff.isproduct() and eff.state is None: + if eff.isproduct() and eff.state is None: eff.state = np.append(Z+X_e, Q_e) else: eff.state[:-1] = Z+X_e # not sure if this works for a setter eff.state[-1] = Q_e #!!! might need to enable dynamic sludge volume flows - if ras.isproduct() and ras.state is None: + if ras.isproduct() and ras.state is None: ras.state = np.append(C_s, self._Qras) - else: + else: ras.state[:-1] = C_s ras.state[-1] = self._Qras - if was.isproduct() and was.state is None: + if was.isproduct() and was.state is None: was.state = np.append(C_s, self._Qwas) - else: + else: was.state[:-1] = C_s was.state[-1] = self._Qwas @@ -324,19 +412,19 @@ def _update_dstate(self): dC_e = dZ + arr[-n] * X_composition + dX_composition * TSS_e dC_s = dZ + arr[-1] * X_composition + dX_composition * TSS_s eff, ras, was = self._outs - if eff.isproduct() and eff.dstate is None: + if eff.isproduct() and eff.dstate is None: eff.dstate = np.append(dC_e, dQ) else: eff.dstate[:-1] = dC_e # not sure if this works for a setter eff.dstate[-1] = dQ #!!! might need to enable dynamic sludge volume flows - if ras.isproduct() and ras.dstate is None: + if ras.isproduct() and ras.dstate is None: ras.dstate = np.append(dC_s, 0.) - else: + else: ras.dstate[:-1] = dC_s - if was.isproduct() and was.dstate is None: + if was.isproduct() and was.dstate is None: was.dstate = np.append(dC_s, 0.) - else: + else: was.dstate[:-1] = dC_s def _run(self): @@ -389,7 +477,7 @@ def _compile_ODE(self): rh_arr = np.full_like(nzeros, self._rh) rp_arr = np.full_like(nzeros, self._rp) func_vx = lambda x_arr, xmin_arr : _settling_flux(x_arr, vmax_arr, vmaxp_arr, xmin_arr, rh_arr, rp_arr, nzeros) - + A, hj, V = self._A, self._hj, self._V A_arr = np.full_like(nzeros, A) hj_arr = np.full_like(nzeros, hj) @@ -434,11 +522,251 @@ def dy_dt(t, QC_ins, QC, dQC_ins): _update_dstate() self._ODE = dy_dt - + + _units = { + 'Number of clarifiers': 'ea', + 'Volumetric flow': 'm3/day', + 'Clarifier depth': 'm', + 'Surface area': 'm2', + 'Clarifier diameter': 'm', + 'Clarifier volume': 'm3', + 'Design solids loading rate': 'kg/m2/hr', + 'Surface overflow rate': 'm3/day/m2', + 'Hydraulic Retention Time': 'hr', + 'Center feed depth': 'm', + 'Downward flow velocity': 'm/hr', + 'Center feed diameter': 'm', + 'Volume of concrete wall': 'm3', + 'Stainless steel': 'kg', + 'Pump pipe stainless steel' : 'kg', + 'Pump stainless steel': 'kg', + 'Number of pumps': 'ea' + } + + def _design_pump(self): + ID, pumps = self.ID, self.pumps + + self._ras.copy_like(self.outs[1]) + self._was.copy_like(self.outs[2]) + + ins_dct = { + 'ras': self._ras, + 'was': self._was, + } + + D = self.design_results + + ras_flow = self._ras.get_total_flow('m3/hr') + was_flow = self._was.get_total_flow('m3/hr') + + ras_flow_u = ras_flow/D['Number of clarifiers']*0.00634 + was_flow_u = was_flow/D['Number of clarifiers']*0.00634 + + Q_mgd = { + 'ras': ras_flow_u, + 'was': was_flow_u, + } + + type_dct = dict.fromkeys(pumps, 'sludge') + inputs_dct = dict.fromkeys(pumps, (1,)) + + for i in pumps: + if hasattr(self, f'{i}_pump'): + p = getattr(self, f'{i}_pump') + setattr(p, 'add_inputs', inputs_dct[i]) + else: + ID = f'{ID}_{i}' + capacity_factor=1 + pump = WWTpump( + ID=ID, ins=ins_dct[i], thermo = self.thermo, pump_type=type_dct[i], + Q_mgd=Q_mgd[i], add_inputs=inputs_dct[i], + capacity_factor=capacity_factor, + include_pump_cost=True, + include_building_cost=False, + include_OM_cost=True, + ) + setattr(self, f'{i}_pump', pump) + + pipe_ss, pump_ss = 0., 0. + for i in pumps: + p = getattr(self, f'{i}_pump') + p.simulate() + p_design = p.design_results + pipe_ss += p_design['Pump pipe stainless steel'] + pump_ss += p_design['Pump stainless steel'] + return pipe_ss, pump_ss + def _design(self): - pass - - + + self._mixed.mix_from(self.ins) + mixed = self._mixed + D = self.design_results + + # Number of clarifiers based on tentative suggestions by Jeremy + # (would be verified through collaboration with industry) + total_flow = (mixed.get_total_flow('m3/hr')*24)/3785 # in MGD + if total_flow <= 3: + D['Number of clarifiers'] = 2 + elif total_flow > 3 and total_flow <= 8: + D['Number of clarifiers'] = 3 + elif total_flow > 8 and total_flow <=20: + D['Number of clarifiers'] = 4 + else: + D['Number of clarifiers'] = 4 + total_flow -= 20 + D['Number of clarifiers'] += np.ceil(total_flow/20) + + D['Volumetric flow'] = (mixed.get_total_flow('m3/hr')*24)/D['Number of clarifiers'] #m3/day + + # Sidewater depth of a cylindrical clarifier lies between 4-5 m (MOP 8) + D['Clarifier depth'] = self._h # in m + + # Area of clarifier + # D['Surface area'] = solids_clarifier/D['Solids loading rate'] #m2 + D['Surface area'] = self._A/D['Number of clarifiers'] + D['Clarifier diameter'] = np.sqrt(4*D['Surface area']/np.pi) # in m + D['Clarifier volume'] = D['Surface area']*D['Clarifier depth'] # in m3 + + # Checks on SLR,, SOR, and HRT + + D['Design solids loading rate'] = self._slr # kg/(m2*hr) + + total_solids = mixed.get_TSS()*mixed.get_total_flow('m3/hr')/1000 # in kg/hr (mg/l * m3/hr) + solids_clarifier = total_solids/D['Number of clarifiers'] # in kg/hr + simulated_slr = solids_clarifier/D['Surface area'] # in kg/(m2*hr) + + # Consult Joy on the margin or error + if simulated_slr < 0.8*D['Design solids loading rate'] or simulated_slr > 1.2*D['Design solids loading rate']: + design_slr = D['Design solids loading rate'] + warn(f'Solids loading rate = {simulated_slr} is not within 20% of the recommended design level of {design_slr} kg/hr/m2') + + # Check on SLR [3, 4, 5] + if simulated_slr > 14: + warn(f'Solids loading rate = {simulated_slr} is above recommended level of 14 kg/hr/m2') + + # Check on SOR [3, 4, 5] + D['Surface overflow rate'] = D['Volumetric flow']/D['Surface area'] # in m3/m2/hr + if D['Surface overflow rate'] > 49: + sor = D['Surface overflow rate'] + warn(f'Surface overflow rate = {sor} is above recommended level of 49 m3/day/m2') + + # HRT + D['Hydraulic Retention Time'] = D['Clarifier volume']*24/D['Volumetric flow'] # in hr + + # Clarifiers can be center feed or peripheral feed. The design here is for the more commonly deployed center feed. + # Depth of the center feed lies between 30-75% of sidewater depth. [2] + D['Center feed depth'] = 0.5*D['Clarifier depth'] + # Criteria for downward velocity of flow determine + D['Downward flow velocity'] = self._downward_flow_velocity # in m/hr + Center_feed_area = (D['Volumetric flow']/24)/D['Downward flow velocity'] # in m2 + D['Center feed diameter'] = np.sqrt(4*Center_feed_area/np.pi) + + #Sanity check: Diameter of the center feed lies between 20-25% of tank diameter [2] + if D['Center feed diameter'] < 0.20*D['Clarifier diameter'] or D['Center feed diameter'] > 0.25*D['Clarifier diameter']: + cf_dia = D['Center feed diameter'] + tank_dia = D['Clarifier diameter'] + warn(f'Diameter of the center feed does not lie between 20-25% of tank diameter. It is {cf_dia*100/tank_dia} % of tank diameter') + + # Amount of concrete required + D_tank = D['Clarifier depth']*39.37 # m to inches + # Thickness of the wall concrete, [m]. Default to be minimum of 1 ft with 1 in added for every ft of depth over 12 ft. (Brian's code) + thickness_concrete_wall = (1 + max(D_tank-12, 0)/12)*0.3048 # from feet to m + inner_diameter = D['Clarifier diameter'] + outer_diameter = inner_diameter + 2*thickness_concrete_wall + D['Volume of concrete wall'] = (np.pi*D['Clarifier depth']/4)*(outer_diameter**2 - inner_diameter**2) + + # Concrete slab thickness, [ft], default to be 2 in thicker than the wall thickness. (Brian's code) + thickness_concrete_slab = thickness_concrete_wall + (2/12)*0.3048 # from inch to m + # From Brian's code + D['Volume of concrete slab'] = (thickness_concrete_slab + thickness_concrete_wall)*D['Surface area'] + + # Amount of metal required for center feed + thickness_metal_wall = 0.3048 # equal to 1 feet, in m (!! NEED A RELIABLE SOURCE !!) + inner_diameter_center_feed = D['Center feed diameter'] + outer_diameter_center_feed = inner_diameter_center_feed + 2*thickness_metal_wall + volume_center_feed = (np.pi*D['Center feed depth']/4)*(outer_diameter_center_feed**2 - inner_diameter_center_feed **2) + density_ss = 7930 # kg/m3, 18/8 Chromium + D['Stainless steel'] = volume_center_feed*density_ss # in kg + + # Pumps + pipe, pumps = self._design_pump() + D['Pump pipe stainless steel'] = pipe + D['Pump stainless steel'] = pumps + + # For secondary clarifier + D['Number of pumps'] = 2*D['Number of clarifiers'] + + def _cost(self): + + D = self.design_results + C = self.baseline_purchase_costs + + # Construction of concrete and stainless steel walls + C['Wall concrete'] = D['Number of clarifiers']*D['Volume of concrete wall']*self.wall_concrete_unit_cost + + C['Slab concrete'] = D['Number of clarifiers']*D['Volume of concrete slab']*self.slab_concrete_unit_cost + + C['Wall stainless steel'] = D['Number of clarifiers']*D['Stainless steel']*self.stainless_steel_unit_cost + + # Cost of equipment + + # Source of scaling exponents: Process Design and Economics for Biochemical Conversion of Lignocellulosic Biomass to Ethanol by NREL. + + # Scraper + # Source: https://www.alibaba.com/product-detail/Peripheral-driving-clarifier-mud-scraper-waste_1600891102019.html?spm=a2700.details.0.0.47ab45a4TP0DLb + # base_cost_scraper = 2500 + # base_flow_scraper = 1 # in m3/hr (!!! Need to know whether this is for solids or influent !!!) + clarifier_flow = D['Volumetric flow']/24 # in m3/hr + # C['Scraper'] = D['Number of clarifiers']*base_cost_scraper*(clarifier_flow/base_flow_scraper)**0.6 + # base_power_scraper = 2.75 # in kW + # THE EQUATION BELOW IS NOT CORRECT TO SCALE SCRAPER POWER REQUIREMENTS + # scraper_power = D['Number of clarifiers']*base_power_scraper*(clarifier_flow/base_flow_scraper)**0.6 + + # v notch weir + # Source: https://www.alibaba.com/product-detail/50mm-Tube-Settler-Media-Modules-Inclined_1600835845218.html?spm=a2700.galleryofferlist.normal_offer.d_title.69135ff6o4kFPb + base_cost_v_notch_weir = 6888 + base_flow_v_notch_weir = 10 # in m3/hr + C['v notch weir'] = D['Number of clarifiers']*base_cost_v_notch_weir*(clarifier_flow/base_flow_v_notch_weir)**0.6 + + # Pump (construction and maintainance) + pumps = self.pumps + add_OPEX = self.add_OPEX + pump_cost = 0. + building_cost = 0. + opex_o = 0. + opex_m = 0. + + # i would be 0 and 1 for RAS and WAS respectively + for i in pumps: + p = getattr(self, f'{i}_pump') + p_cost = p.baseline_purchase_costs + p_add_opex = p.add_OPEX + pump_cost += p_cost['Pump'] + building_cost += p_cost['Pump building'] + opex_o += p_add_opex['Pump operating'] + opex_m += p_add_opex['Pump maintenance'] + + # All costs associated with pumping need to be multiplied by number of clarifiers + C['Pumps'] = pump_cost*D['Number of clarifiers'] + C['Pump building'] = building_cost*D['Number of clarifiers'] + add_OPEX['Pump operating'] = opex_o*D['Number of clarifiers'] + add_OPEX['Pump maintenance'] = opex_m*D['Number of clarifiers'] + + # Power + pumping = 0. + for ID in self.pumps: + p = getattr(self, f'{ID}_pump') + if p is None: + continue + pumping += p.power_utility.rate + + pumping = pumping*D['Number of clarifiers'] + + self.power_utility.rate += pumping + # self.power_utility.consumption += scraper_power + +# %% + class IdealClarifier(SanUnit): _N_ins = 1 @@ -536,4 +864,891 @@ def _run(self): sludge.set_flow_by_concentration(Qs, Cs, units=('m3/d', 'mg/L')) def _design(self): - pass \ No newline at end of file + pass + + +# %% +# Total COD removal efficiency +nCOD = lambda f_corr, fx, HRT: f_corr*(2.88*fx - 0.118)*(1.45 + 6.15*np.log(HRT*24*60)) + +def calc_f_i(fx, f_corr, HRT): + '''calculates the effluent-to-influent ratio of solid concentrations''' + nX = nCOD(f_corr, fx, HRT)/fx + if nX > 100: nX = 100 + if nX < 0: nX = 0 + return 1-(nX/100) + +class PrimaryClarifierBSM2(SanUnit): + + """ + A Primary clarifier based on BSM2 Layout. [1] + + Parameters + ---------- + ID : str + ID for the clarifier. + ins : class:`WasteStream` + Influent to the clarifier. Expected number of influent is 3. + outs : class:`WasteStream` + Sludge (uf) and treated effluent (of). + HRT : float + Hydraulic retention time in days. The default is 0.04268 days, based on IWA report.[1] + ratio_uf : float + The ratio of sludge to primary influent. The default is 0.007, based on IWA report.[1] + f_corr : float + Dimensionless correction factor for removal efficiency in the primary clarifier.[1] + cylindrical_depth : float, optional + The depth of the cylindrical portion of clarifier [in m]. + upflow_velocity : float, optional + Speed with which influent enters the center feed of the clarifier [m/hr]. The default is 43.2. + F_BM : dict + Equipment bare modules. + + Examples + -------- + >>> from qsdsan import set_thermo, Components, WasteStream + >>> cmps = Components.load_default() + >>> cmps_test = cmps.subgroup(['S_F', 'S_NH4', 'X_OHO', 'H2O']) + >>> set_thermo(cmps_test) + >>> ws = WasteStream('ws', S_F = 10, S_NH4 = 20, X_OHO = 15, H2O=1000) + >>> from qsdsan.sanunits import PrimaryClarifierBSM2 + >>> PC = PrimaryClarifierBSM2(ID='PC', ins= (ws,), outs=('eff', 'sludge')) + >>> PC.simulate() + >>> uf, of = PC.outs + >>> uf.imass['X_OHO']/ws.imass['X_OHO'] # doctest: +ELLIPSIS + 0.280... + >>> # PC.show() + + References + ---------- + [1] Gernaey, Krist V., Ulf Jeppsson, Peter A. Vanrolleghem, and John B. Copp. + Benchmarking of control strategies for wastewater treatment plants. IWA publishing, 2014. + [2] Metcalf, Leonard, Harrison P. Eddy, and Georg Tchobanoglous. Wastewater + engineering: treatment, disposal, and reuse. Vol. 4. New York: McGraw-Hill, 1991. + [3] Otterpohl R. and Freund M. (1992). Dynamic Models for clarifiers of activated sludge + plants with dry and wet weather flows. Water Sci. Technol., 26(5-6), 1391-1400. + """ + + _N_ins = 3 + _N_outs = 2 # [0] effluent; [1] underflow + _ins_size_is_fixed = False + + # Costs + wall_concrete_unit_cost = 650 / 0.765 # $/m3, 0.765 is to convert from $/yd3 + stainless_steel_unit_cost=1.8 # $/kg (Taken from Joy's METAB code) https://www.alibaba.com/product-detail/brushed-stainless-steel-plate-304l-stainless_1600391656401.html?spm=a2700.details.0.0.230e67e6IKwwFd + + pumps = ('sludge',) + t_m = 0.125 # Smoothing time constant for qm calculation + + def __init__(self, ID='', ins=None, outs=(), thermo=None, + isdynamic=True, init_with='WasteStream', + volume=900, ratio_uf=0.007, mean_f_x=0.85, + f_corr=0.65, cylindrical_depth = 5, upflow_velocity=43.2, + F_BM=default_F_BM, **kwargs): + SanUnit.__init__(self, ID, ins, outs, thermo, isdynamic=isdynamic, + init_with=init_with) + # self.HRT = HRT # in days + self._mixed = self.ins[0].copy(f'{ID}_mixed') + self._sludge = np.ones(len(self.components)+1) + self._effluent = np.ones(len(self.components)+1) + self.V = volume + self.ratio_uf = ratio_uf + self.f_x = mean_f_x + self.f_corr = f_corr + self.cylindrical_depth = cylindrical_depth # in m + self.upflow_velocity = upflow_velocity # in m/hr (converted from 12 mm/sec) + self.F_BM.update(default_F_BM) + self._concs = None + + @property + def ratio_uf(self): + return self._r + @ratio_uf.setter + def ratio_uf(self, r): + if r > 1 or r < 0: + raise ValueError(f'Sludge to influent ratio must be within [0, 1], not {r}') + self._r = r + self._sludge[-1] = r + self._effluent[-1] = 1-r + + # def _f_i(self): + # xcod = self._mixed.composite('COD', particle_size='x') + # fx = xcod/self._mixed.COD + # n_COD = self.f_corr*(2.88*fx - 0.118)*(1.45 + 6.15*np.log(self.HRT*24*60)) + # f_i = 1 - (n_COD/100) + # return f_i + + # def _run(self): + # uf, of = self.outs + # cmps = self.components + # mixed = self._mixed + # mixed.mix_from(self.ins) + + # r = self._r + # f_i = self._f_i() + + # Xs = (1 - f_i)*mixed.mass*cmps.x + # Xe = (f_i)*mixed.mass*cmps.x + + # Zs = r*mixed.mass*cmps.s + # Ze = (1-r)*mixed.mass*cmps.s + + # Ce = Ze + Xe + # Cs = Zs + Xs + # of.set_flow(Ce,'kg/hr') + # uf.set_flow(Cs,'kg/hr') + + @property + def f_x(self): + '''[float] Fraction of particulate COD [-].''' + if self._f_x: return self._f_x + else: + concs = self._mixed.conc + cmps = self._mixed.components + cod_concs = concs*cmps.i_COD + if sum(cod_concs) == 0: return + return sum(cod_concs*cmps.x)/sum(cod_concs) + @f_x.setter + def f_x(self, f): + if isinstance(f, (float, int)) and (f < 0 or f > 1): + raise ValueError('f_x must be within [0,1]') + self._f_x = f + + def _run(self): + of, uf = self.outs + mixed = self._mixed + mixed.mix_from(self.ins) + x = self.components.x + r = self._r + f_i = calc_f_i(self.f_x, self.f_corr, self.t_m) + split_to_uf = (1-x)*r + x*(1-(1-r)*f_i) + mixed.split_to(uf, of, split_to_uf) + + def set_init_conc(self, **kwargs): + '''set the initial concentrations [mg/L].''' + self._concs = self.components.kwarray(kwargs) + + def _init_state(self): + mixed = self._mixed + Q = mixed.get_total_flow('m3/d') + if self._concs is not None: Cs = self._concs + else: Cs = mixed.conc + self._state = np.append(Cs, Q).astype('float64') + self._dstate = self._state * 0. + + def _update_parameters(self): + x = self.components.x + r = self._r + Q = self._state[-1] + f_i = calc_f_i(self.f_x, self.f_corr, self.V/Q) + self._sludge[:-1] = x * ((1-f_i)/r+f_i) + (1-x) + self._effluent[:-1] = x * f_i + (1-x) + + def _update_state(self): + '''updates conditions of output stream based on conditions of the Primary Clarifier''' + of, uf = self.outs + uf.state = self._sludge * self._state + of.state = self._effluent * self._state + + def _update_dstate(self): + '''updates rates of change of output stream from rates of change of the Primary Clarifier''' + of, uf = self.outs + uf.dstate = self._sludge * self._dstate + of.dstate = self._effluent * self._dstate + + # def _init_state(self): + # # if multiple wastestreams exist then concentration and total flow + # # would be calculated assuming perfect mixing + # Qs = self._ins_QC[:,-1] + # Cs = self._ins_QC[:,:-1] + # self._state = np.append(Qs @ Cs / Qs.sum(), Qs.sum()) + # self._dstate = self._state * 0. + + # uf, of = self.outs + # s_flow = uf.F_vol/(uf.F_vol+of.F_vol) + # denominator = uf.mass + of.mass + # denominator += (denominator == 0) + # s = uf.mass/denominator + # self._sludge = np.append(s/s_flow, s_flow) + # self._effluent = np.append((1-s)/(1-s_flow), 1-s_flow) + + # def _update_state(self): + # '''updates conditions of output stream based on conditions of the Primary Clarifier''' + # self._outs[0].state = self._sludge * self._state + # self._outs[1].state = self._effluent * self._state + + # def _update_dstate(self): + # '''updates rates of change of output stream from rates of change of the Primary Clarifier''' + # self._outs[0].dstate = self._sludge * self._dstate + # self._outs[1].dstate = self._effluent * self._dstate + + # @property + # def AE(self): + # if self._AE is None: + # self._compile_AE() + # return self._AE + + # def _compile_AE(self): + # _state = self._state + # _dstate = self._dstate + # _update_state = self._update_state + # _update_dstate = self._update_dstate + # def yt(t, QC_ins, dQC_ins): + # #Because there are multiple inlets + # Q_ins = QC_ins[:, -1] + # C_ins = QC_ins[:, :-1] + # dQ_ins = dQC_ins[:, -1] + # dC_ins = dQC_ins[:, :-1] + # Q = Q_ins.sum() + # C = Q_ins @ C_ins / Q + # _state[-1] = Q + # _state[:-1] = C + # Q_dot = dQ_ins.sum() + # C_dot = (dQ_ins @ C_ins + Q_ins @ dC_ins - Q_dot * C)/Q + # _dstate[-1] = Q_dot + # _dstate[:-1] = C_dot + # _update_state() + # _update_dstate() + # self._AE = yt + @property + def ODE(self): + if self._ODE is None: + self._compile_ODE() + return self._ODE + + def _compile_ODE(self): + _dstate = self._dstate + _update_parameters = self._update_parameters + _update_dstate = self._update_dstate + V = self.V + t_m = self.t_m + def dy_dt(t, QC_ins, QC, dQC_ins): + Q_ins = QC_ins[:, -1] + C_ins = QC_ins[:, :-1] + # dQ_ins = dQC_ins[:, -1] + # dC_ins = dQC_ins[:, :-1] + Q_in = Q_ins.sum() + C_in = Q_ins @ C_ins / Q_in + C = QC[:-1] + Q = QC[-1] + _dstate[:-1] = Q_in*(C_in - C)/V + _dstate[-1] = (Q_in-Q)/t_m + _update_parameters() + _update_dstate() + self._ODE = dy_dt + +#%% +# Assign a bare module of 1 to all +default_F_BM = { + 'Wall concrete': 1., + 'Slab concrete': 1., + 'Wall stainless steel': 1., + 'Scraper': 1, + 'v notch weir': 1, + 'Pumps': 1 + } +default_F_BM.update(default_WWTpump_F_BM) + +class PrimaryClarifier(SanUnit): + + """ + Primary clarifier adapted from the design of thickener as defined in BSM-2. [1] + ---------- + ID : str + ID for the Primary Clarifier. The default is ''. + ins : class:`WasteStream` + Influent to the clarifier. Expected number of influent is 1. + outs : class:`WasteStream` + Sludge and treated effluent. + thickener_perc : float + The percentage of solids in the underflow of the clarifier.[1] + TSS_removal_perc : float + The percentage of suspended solids removed in the clarifier.[1] + surface_overflow_rate : float + Surface overflow rate in the clarifier in [(m3/day)/m2]. [3] + Design SOR value for clarifier is 41 (m3/day)/m2 if it does not receive WAS. + Design SOR value for clarifier is 29 (m3/day)/m2 if it receives WAS. + Typically SOR lies between 30-50 (m3/day)/m2. + Here default value of 41 (m3/day)/m2 is used. + depth_clarifier : float + Depth of clarifier. Typical depths range from 3 m to 4.9 m [2, 3]. + Default value of 4.5 m would be used here. + downward_flow_velocity : float, optional + Speed on the basis of which center feed diameter is designed [m/hr]. [4] + The default is 36 m/hr. (10 mm/sec) + F_BM : dict + Equipment bare modules. + + Examples + -------- + >>> from qsdsan import set_thermo, Components, WasteStream + >>> cmps = Components.load_default() + >>> cmps_test = cmps.subgroup(['S_F', 'S_NH4', 'X_OHO', 'H2O']) + >>> set_thermo(cmps_test) + >>> ws = WasteStream('ws', S_F = 10, S_NH4 = 20, X_OHO = 15, H2O=1000) + >>> from qsdsan.sanunits import Thickener + >>> TC = Thickener(ID='TC', ins= (ws), outs=('sludge', 'effluent')) + >>> TC.simulate() + >>> sludge, effluent = TC.outs + >>> sludge.imass['X_OHO']/ws.imass['X_OHO'] + 0.98 + >>> TC.show() # doctest: +ELLIPSIS + Thickener: TC + ins... + [0] ws + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 1e+04 + S_NH4 2e+04 + X_OHO 1.5e+04 + H2O 1e+06 + WasteStream-specific properties: + pH : 7.0 + COD : 23873.0 mg/L + BOD : 14963.2 mg/L + TC : 8298.3 mg/L + TOC : 8298.3 mg/L + TN : 20363.2 mg/L + TP : 367.6 mg/L + TK : 68.3 mg/L + outs... + [0] sludge + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 1.56e+03 + S_NH4 3.11e+03 + X_OHO 1.47e+04 + H2O 1.56e+05 + WasteStream-specific properties: + pH : 7.0 + COD : 95050.4 mg/L + BOD : 55228.4 mg/L + TC : 34369.6 mg/L + TOC : 34369.6 mg/L + TN : 24354.4 mg/L + TP : 1724.0 mg/L + TK : 409.8 mg/L + [1] effluent + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 8.44e+03 + S_NH4 1.69e+04 + X_OHO 300 + H2O 8.44e+05 + WasteStream-specific properties: + pH : 7.0 + COD : 9978.2 mg/L + BOD : 7102.9 mg/L + TC : 3208.8 mg/L + TOC : 3208.8 mg/L + TN : 19584.1 mg/L + TP : 102.9 mg/L + TK : 1.6 mg/L + + References + ---------- + .. [1] Gernaey, Krist V., Ulf Jeppsson, Peter A. Vanrolleghem, and John B. Copp. + Benchmarking of control strategies for wastewater treatment plants. IWA publishing, 2014. + [2] Metcalf, Leonard, Harrison P. Eddy, and Georg Tchobanoglous. Wastewater + engineering: treatment, disposal, and reuse. Vol. 4. New York: McGraw-Hill, 1991. + [3] Chapter-10: Primary Treatment. Design of water resource recovery facilities. + WEF Manual of Practice No. 8. 6th Edition. Virginia: McGraw-Hill, 2018. + [4] Introduction to Wastewater Clarifier Design by Nikolay Voutchkov, PE, BCEE. + """ + + _N_ins = 1 + _N_outs = 2 + _ins_size_is_fixed = False + _outs_size_is_fixed = False + + # Costs + wall_concrete_unit_cost = 1081.73 # $/m3 (Hydromantis. CapdetWorks 4.0. https://www.hydromantis.com/CapdetWorks.html) + slab_concrete_unit_cost = 582.48 # $/m3 (Hydromantis. CapdetWorks 4.0. https://www.hydromantis.com/CapdetWorks.html) + stainless_steel_unit_cost=1.8 # Alibaba. Brushed Stainless Steel Plate 304. https://www.alibaba.com/product-detail/brushed-stainless-steel-plate-304l-stainless_1600391656401.html?spm=a2700.details.0.0.230e67e6IKwwFd + + pumps = ('sludge',) + + def __init__(self, ID='', ins=None, outs=(), thermo=None, isdynamic=False, + init_with='WasteStream', thickener_perc=7, + TSS_removal_perc=98, surface_overflow_rate = 41, depth_clarifier=4.5, + downward_flow_velocity=36, F_BM=default_F_BM, **kwargs): + SanUnit.__init__(self, ID, ins, outs, thermo, isdynamic=isdynamic, + init_with=init_with) + self.thickener_perc = thickener_perc + self.TSS_removal_perc = TSS_removal_perc + self.surface_overflow_rate = surface_overflow_rate + self.depth_clarifier = depth_clarifier + self.downward_flow_velocity = downward_flow_velocity + self.F_BM.update(F_BM) + self._mixed = WasteStream(f'{ID}_mixed') + self._sludge = self.outs[0].copy(f'{ID}_sludge') + + @property + def thickener_perc(self): + '''tp is the percentage of Suspended Sludge in the underflow of the clarifier''' + return self._tp + + @thickener_perc.setter + def thickener_perc(self, tp): + if tp is not None: + if tp>=100 or tp<=0: + raise ValueError(f'should be between 0 and 100 not {tp}') + self._tp = tp + else: + raise ValueError('percentage of SS in the underflow of the thickener expected from user') + + # @property + # def solids_loading_rate(self): + # '''solids_loading_rate is the loading in the clarifier''' + # return self._slr + + # @solids_loading_rate.setter + # def solids_loading_rate(self, slr): + # if slr is not None: + # self._slr = slr + # else: + # raise ValueError('solids_loading_rate of the clarifier expected from user') + + @property + def TSS_removal_perc(self): + '''The percentage of suspended solids removed in the clarifier''' + return self._TSS_rmv + + @TSS_removal_perc.setter + def TSS_removal_perc(self, TSS_rmv): + if TSS_rmv is not None: + if TSS_rmv>=100 or TSS_rmv<=0: + raise ValueError(f'should be between 0 and 100 not {TSS_rmv}') + self._TSS_rmv = TSS_rmv + else: + raise ValueError('percentage of suspended solids removed in the clarifier expected from user') + + @property + def thickener_factor(self): + self._mixed.mix_from(self.ins) + inf = self._mixed + _cal_thickener_factor = self._cal_thickener_factor + if not self.ins: return + elif inf.isempty(): return + else: + TSS_in = inf.get_TSS() + thickener_factor = _cal_thickener_factor(TSS_in) + return thickener_factor + + @property + def thinning_factor(self): + self._mixed.mix_from(self.ins) + inf = self._mixed + TSS_in = inf.get_TSS() + _cal_thickener_factor = self._cal_thickener_factor + thickener_factor = _cal_thickener_factor(TSS_in) + _cal_parameters = self._cal_parameters + Qu_factor, thinning_factor = _cal_parameters(thickener_factor) + return thinning_factor + + def _cal_thickener_factor(self, TSS_in): + if TSS_in > 0: + thickener_factor = self._tp*10000/TSS_in + if thickener_factor<1: + thickener_factor=1 + return thickener_factor + else: return None + + def _cal_parameters(self, thickener_factor): + if thickener_factor<1: + Qu_factor = 1 + thinning_factor=0 + else: + Qu_factor = self._TSS_rmv/(100*thickener_factor) + thinning_factor = (1 - (self._TSS_rmv/100))/(1 - Qu_factor) + return Qu_factor, thinning_factor + + def _update_parameters(self): + + # Thickener_factor, Thinning_factor, and Qu_factor need to be + # updated again and again. while dynamic simulations + + cmps = self.components + + TSS_in = np.sum(self._state[:-1]*cmps.i_mass*cmps.x) + _cal_thickener_factor = self._cal_thickener_factor + self.updated_thickener_factor = _cal_thickener_factor(TSS_in) + _cal_parameters = self._cal_parameters + + updated_thickener_factor = self.updated_thickener_factor + self.updated_Qu_factor, self.updated_thinning_factor = _cal_parameters(updated_thickener_factor) + + def _run(self): + self._mixed.mix_from(self.ins) + inf = self._mixed + sludge, eff = self.outs + cmps = self.components + + TSS_rmv = self._TSS_rmv + thinning_factor = self.thinning_factor + thickener_factor = self.thickener_factor + + # The following are splits by mass of particulates and solubles + + # Note: (1 - thinning_factor)/(thickener_factor - thinning_factor) = Qu_factor + Zs = (1 - thinning_factor)/(thickener_factor - thinning_factor)*inf.mass*cmps.s + Ze = (thickener_factor - 1)/(thickener_factor - thinning_factor)*inf.mass*cmps.s + + Xe = (1 - TSS_rmv/100)*inf.mass*cmps.x + Xs = (TSS_rmv/100)*inf.mass*cmps.x + + # e stands for effluent, s stands for sludge + Ce = Ze + Xe + Cs = Zs + Xs + + eff.set_flow(Ce,'kg/hr') + sludge.set_flow(Cs,'kg/hr') + + def _init_state(self): + + # This function is run only once during dynamic simulations + + # Since there could be multiple influents, the state of the unit is + # obtained assuming perfect mixing + Qs = self._ins_QC[:,-1] + Cs = self._ins_QC[:,:-1] + self._state = np.append(Qs @ Cs / Qs.sum(), Qs.sum()) + self._dstate = self._state * 0. + + # To initialize the updated_thickener_factor, updated_thinning_factor + # and updated_Qu_factor for dynamic simulation + self._update_parameters() + + def _update_state(self): + '''updates conditions of output stream based on conditions of the Thickener''' + + # This function is run multiple times during dynamic simulation + + # Remember that here we are updating the state array of size n, which is made up + # of component concentrations in the first (n-1) cells and the last cell is flowrate. + + # So, while in the run function the effluent and sludge are split by mass, + # here they are split by concentration. Therefore, the split factors are different. + + # Updated intrinsic modelling parameters are used for dynamic simulation + thickener_factor = self.updated_thickener_factor + thinning_factor = self.updated_thinning_factor + Qu_factor = self.updated_Qu_factor + cmps = self.components + + # For sludge, the particulate concentrations are multiplied by thickener factor, and + # flowrate is multiplied by Qu_factor. The soluble concentrations remains same. + uf, of = self.outs + if uf.state is None: uf.state = np.zeros(len(cmps)+1) + uf.state[:-1] = self._state[:-1]*cmps.s*1 + self._state[:-1]*cmps.x*thickener_factor + uf.state[-1] = self._state[-1]*Qu_factor + + # For effluent, the particulate concentrations are multiplied by thinning factor, and + # flowrate is multiplied by Qu_factor. The soluble concentrations remains same. + if of.state is None: of.state = np.zeros(len(cmps)+1) + of.state[:-1] = self._state[:-1]*cmps.s*1 + self._state[:-1]*cmps.x*thinning_factor + of.state[-1] = self._state[-1]*(1 - Qu_factor) + + def _update_dstate(self): + '''updates rates of change of output stream from rates of change of the Thickener''' + + # This function is run multiple times during dynamic simulation + + # Remember that here we are updating the state array of size n, which is made up + # of component concentrations in the first (n-1) cells and the last cell is flowrate. + + # So, while in the run function the effluent and sludge are split by mass, + # here they are split by concentration. Therefore, the split factors are different. + + # Updated intrinsic modelling parameters are used for dynamic simulation + thickener_factor = self.updated_thickener_factor + thinning_factor = self.updated_thinning_factor + Qu_factor = self.updated_Qu_factor + cmps = self.components + + # For sludge, the particulate concentrations are multiplied by thickener factor, and + # flowrate is multiplied by Qu_factor. The soluble concentrations remains same. + uf, of = self.outs + if uf.dstate is None: uf.dstate = np.zeros(len(cmps)+1) + uf.dstate[:-1] = self._dstate[:-1]*cmps.s*1 + self._dstate[:-1]*cmps.x*thickener_factor + uf.dstate[-1] = self._dstate[-1]*Qu_factor + + # For effluent, the particulate concentrations are multiplied by thinning factor, and + # flowrate is multiplied by Qu_factor. The soluble concentrations remains same. + if of.dstate is None: of.dstate = np.zeros(len(cmps)+1) + of.dstate[:-1] = self._dstate[:-1]*cmps.s*1 + self._dstate[:-1]*cmps.x*thinning_factor + of.dstate[-1] = self._dstate[-1]*(1 - Qu_factor) + + @property + def AE(self): + if self._AE is None: + self._compile_AE() + return self._AE + + def _compile_AE(self): + + # This function is run multiple times during dynamic simulation + + _state = self._state + _dstate = self._dstate + _update_state = self._update_state + _update_dstate = self._update_dstate + _update_parameters = self._update_parameters + def yt(t, QC_ins, dQC_ins): + Q_ins = QC_ins[:, -1] + C_ins = QC_ins[:, :-1] + dQ_ins = dQC_ins[:, -1] + dC_ins = dQC_ins[:, :-1] + Q = Q_ins.sum() + C = Q_ins @ C_ins / Q + _state[-1] = Q + _state[:-1] = C + Q_dot = dQ_ins.sum() + C_dot = (dQ_ins @ C_ins + Q_ins @ dC_ins - Q_dot * C)/Q + _dstate[-1] = Q_dot + _dstate[:-1] = C_dot + + _update_parameters() + _update_state() + _update_dstate() + self._AE = yt + + def _design_pump(self): + ID, pumps = self.ID, self.pumps + self._sludge.copy_like(self.outs[0]) + sludge = self._sludge + + ins_dct = { + 'sludge': sludge, + } + + type_dct = dict.fromkeys(pumps, 'sludge') + inputs_dct = dict.fromkeys(pumps, (1,)) + + D = self.design_results + influent_Q = sludge.get_total_flow('m3/hr')/D['Number of clarifiers'] + influent_Q_mgd = influent_Q*0.00634 # m3/hr to MGD + + for i in pumps: + if hasattr(self, f'{i}_pump'): + p = getattr(self, f'{i}_pump') + setattr(p, 'add_inputs', inputs_dct[i]) + else: + ID = f'{ID}_{i}' + capacity_factor=1 + pump = WWTpump( + ID=ID, ins= ins_dct[i], thermo = self.thermo, pump_type=type_dct[i], + Q_mgd=influent_Q_mgd, add_inputs=inputs_dct[i], + capacity_factor=capacity_factor, + include_pump_cost=True, + include_building_cost=False, + include_OM_cost=True, + ) + + setattr(self, f'{i}_pump', pump) + + pipe_ss, pump_ss = 0., 0. + for i in pumps: + p = getattr(self, f'{i}_pump') + p.simulate() + p_design = p.design_results + pipe_ss += p_design['Pump pipe stainless steel'] + pump_ss += p_design['Pump stainless steel'] + return pipe_ss, pump_ss + + _units = { + 'Number of clarifiers': 'ea', + 'SOR': 'm3/day/m2', + 'Volumetric flow': 'm3/day', + 'Surface area': 'm2', + 'Cylindrical diameter': 'm', + 'Conical radius': 'm', + 'Conical depth': 'm', + 'Clarifier depth': 'm', + 'Cylindrical depth': 'm', + 'Cylindrical volume': 'm3', + 'Conical volume': 'm3', + 'Volume': 'm3', + 'Hydraulic Retention Time': 'hr', + 'Center feed depth': 'm', + 'Downward flow velocity': 'm/hr', + 'Center feed diameter': 'm', + 'Volume of concrete wall': 'm3', + 'Volume of concrete slab': 'm3', + 'Stainless steel': 'kg', + 'Pump pipe stainless steel' : 'kg', + 'Pump stainless steel': 'kg', + 'Number of pumps': 'ea' + } + + def _design(self): + + self._mixed.mix_from(self.ins) + mixed = self._mixed + D = self.design_results + + # Number of clarifiers based on tentative suggestions by Jeremy + # (would be verified through collaboration with industry) + total_flow = (mixed.get_total_flow('m3/hr')*24)/3785 # in MGD + if total_flow <= 3: + D['Number of clarifiers'] = 2 + elif total_flow > 3 and total_flow <= 8: + D['Number of clarifiers'] = 3 + elif total_flow > 8 and total_flow <=20: + D['Number of clarifiers'] = 4 + else: + D['Number of clarifiers'] = 4 + total_flow -= 20 + D['Number of clarifiers'] += np.ceil(total_flow/20) + + D['SOR'] = self.surface_overflow_rate # in (m3/day)/m2 + D['Volumetric flow'] = (mixed.get_total_flow('m3/hr')*24)/D['Number of clarifiers'] # m3/day + D['Surface area'] = D['Volumetric flow']/D['SOR'] # in m2 + D['Cylindrical diameter'] = np.sqrt(4*D['Surface area']/np.pi) #in m + + #Check on cylindrical diameter [2, 3] + if D['Cylindrical diameter'] < 3 or D['Cylindrical diameter'] > 60: + Cylindrical_dia = D['Cylindrical diameter'] + warn(f'Cylindrical diameter = {Cylindrical_dia} is not between 3 m and 60 m') + + D['Conical radius'] = D['Cylindrical diameter']/2 + # The slope of the bottom conical floor lies between 1:10 to 1:12 [3, 4] + D['Conical depth'] = D['Conical radius']/12 + D['Clarifier depth'] = self.depth_clarifier #in m + D['Cylindrical depth'] = D['Clarifier depth'] - D['Conical depth'] + + # Check on cylindrical and conical depths + if D['Cylindrical depth'] < D['Conical depth']: + Cylindrical_depth = D['Cylindrical depth'] + Conical_depth = D['Conical depth'] + warn(f'Cylindrical depth = {Cylindrical_depth} is lower than Conical depth = {Conical_depth}') + + D['Cylindrical volume'] = np.pi*np.square(D['Cylindrical diameter']/2)*D['Cylindrical depth'] #in m3 + D['Conical volume'] = (3.14/3)*(D['Conical radius']**2)*D['Conical depth'] #in m3 + D['Volume'] = D['Cylindrical volume'] + D['Conical volume'] #in m3 + + D['Hydraulic Retention Time'] = D['Volume']/(D['Volumetric flow']/24) #in hrs + + # Check on cylinderical HRT [3] + if D['Hydraulic Retention Time'] < 1.5 or D['Hydraulic Retention Time'] > 2.5: + HRT = D['Hydraulic Retention Time'] + warn(f'HRT = {HRT} is not between 1.5 and 2.5 hrs') + + # The design here is for center feed of clarifier. + + # Depth of the center feed lies between 30-75% of sidewater depth. [3, 4] + D['Center feed depth'] = 0.5*D['Cylindrical depth'] + # Typical conventional feed wells are designed for an average downflow velocity + # of 10-13 mm/s and maximum velocity of 25-30 mm/s. [4] + peak_flow_safety_factor = 2.5 # assumed based on average and maximum velocities + D['Downward flow velocity'] = self.downward_flow_velocity*peak_flow_safety_factor # in m/hr + + Center_feed_area = (D['Volumetric flow']/24)/D['Downward flow velocity'] # in m2 + + D['Center feed diameter'] = np.sqrt(4*Center_feed_area/np.pi) + + #Sanity check: Diameter of the center feed lies between 15-25% of tank diameter [4] + #The lower limit of this check has been modified to 10% based on advised range of down flow velocity in [4]. + if D['Center feed diameter'] < 0.10*D['Cylindrical diameter'] or D['Center feed diameter'] > 0.25*D['Cylindrical diameter']: + cf_dia = D['Center feed diameter'] + tank_dia = D['Cylindrical diameter'] + warn(f'Diameter of the center feed does not lie between 15-25% of tank diameter. It is {cf_dia*100/tank_dia}% of tank diameter') + + # Amount of concrete required + D_tank = D['Cylindrical depth']*39.37 # m to inches + # Thickness of the wall concrete [m]. Default to be minimum of 1 feet with 1 inch added for every feet of depth over 12 feet. + thickness_concrete_wall = (1 + max(D_tank-12, 0)/12)*0.3048 # from feet to m + inner_diameter = D['Cylindrical diameter'] + outer_diameter = inner_diameter + 2*thickness_concrete_wall + volume_cylindercal_wall = (np.pi*D['Cylindrical depth']/4)*(outer_diameter**2 - inner_diameter**2) + D['Volume of concrete wall'] = volume_cylindercal_wall # in m3 + + # Concrete slab thickness, [ft], default to be 2 in thicker than the wall thickness. (Brian's code) + thickness_concrete_slab = thickness_concrete_wall + (2/12)*0.3048 # from inch to m + outer_diameter_cone = inner_diameter + 2*(thickness_concrete_wall + thickness_concrete_slab) + volume_conical_wall = (np.pi/(3*4))*(((D['Conical depth'] + thickness_concrete_wall + thickness_concrete_slab)*(outer_diameter_cone**2)) - (D['Conical depth']*(inner_diameter)**2)) + D['Volume of concrete slab'] = volume_conical_wall + + # Amount of metal required for center feed + thickness_metal_wall = 0.3048 # equal to 1 feet, in m (!! NEED A RELIABLE SOURCE !!) + inner_diameter_center_feed = D['Center feed diameter'] + outer_diameter_center_feed = inner_diameter_center_feed + 2*thickness_metal_wall + volume_center_feed = (3.14*D['Center feed depth']/4)*(outer_diameter_center_feed**2 - inner_diameter_center_feed**2) + density_ss = 7930 # kg/m3, 18/8 Chromium + D['Stainless steel'] = volume_center_feed*density_ss # in kg + + # Pumps + pipe, pumps = self._design_pump() + D['Pump pipe stainless steel'] = pipe + D['Pump stainless steel'] = pumps + + #For primary clarifier + D['Number of pumps'] = D['Number of clarifiers'] + + def _cost(self): + + self._mixed.mix_from(self.ins) + + D = self.design_results + C = self.baseline_purchase_costs + + # Construction of concrete and stainless steel walls + C['Wall concrete'] = D['Number of clarifiers']*D['Volume of concrete wall']*self.wall_concrete_unit_cost + + C['Slab concrete'] = D['Number of clarifiers']*D['Volume of concrete slab']*self.slab_concrete_unit_cost + + C['Wall stainless steel'] = D['Number of clarifiers']*D['Stainless steel']*self.stainless_steel_unit_cost + + # Cost of equipment + + # Source of scaling exponents: Process Design and Economics for Biochemical Conversion of Lignocellulosic Biomass to Ethanol by NREL. + + # Scraper + # Source: https://www.alibaba.com/product-detail/Peripheral-driving-clarifier-mud-scraper-waste_1600891102019.html?spm=a2700.details.0.0.47ab45a4TP0DLb + # base_cost_scraper = 2500 + # base_flow_scraper = 1 # in m3/hr (!!! Need to know whether this is for solids or influent !!!) + clarifier_flow = D['Volumetric flow']/24 + + # C['Scraper'] = D['Number of clarifiers']*base_cost_scraper*(clarifier_flow/base_flow_scraper)**0.6 + + # base_power_scraper = 2.75 # in kW + # THE EQUATION BELOW IS NOT CORRECT TO SCALE SCRAPER POWER REQUIREMENTS + # scraper_power = D['Number of clarifiers']*base_power_scraper*(clarifier_flow/base_flow_scraper)**0.6 + + # v notch weir + # Source: https://www.alibaba.com/product-detail/50mm-Tube-Settler-Media-Modules-Inclined_1600835845218.html?spm=a2700.galleryofferlist.normal_offer.d_title.69135ff6o4kFPb + base_cost_v_notch_weir = 6888 + base_flow_v_notch_weir = 10 # in m3/hr + C['v notch weir'] = D['Number of clarifiers']*base_cost_v_notch_weir*(clarifier_flow/base_flow_v_notch_weir)**0.6 + + # Pump (construction and maintainance) + pumps = self.pumps + add_OPEX = self.add_OPEX + pump_cost = 0. + building_cost = 0. + opex_o = 0. + opex_m = 0. + + for i in pumps: + p = getattr(self, f'{i}_pump') + p_cost = p.baseline_purchase_costs + p_add_opex = p.add_OPEX + pump_cost += p_cost['Pump'] + building_cost += p_cost['Pump building'] + opex_o += p_add_opex['Pump operating'] + opex_m += p_add_opex['Pump maintenance'] + + C['Pumps'] = pump_cost*D['Number of clarifiers'] + C['Pump building'] = building_cost*D['Number of clarifiers'] + add_OPEX['Pump operating'] = opex_o*D['Number of clarifiers'] + add_OPEX['Pump maintenance'] = opex_m*D['Number of clarifiers'] + + # Power + pumping = 0. + for ID in self.pumps: + p = getattr(self, f'{ID}_pump') + if p is None: + continue + pumping += p.power_utility.rate + + pumping = pumping*D['Number of clarifiers'] + + self.power_utility.rate += pumping + # self.power_utility.rate += scraper_power diff --git a/qsdsan/sanunits/_junction.py b/qsdsan/sanunits/_junction.py index 66eda081..d586ea9a 100644 --- a/qsdsan/sanunits/_junction.py +++ b/qsdsan/sanunits/_junction.py @@ -7,6 +7,8 @@ Joy Zhang Yalin Li + + Saumitra Rai This module is under the University of Illinois/NCSA Open Source License. Please refer to https://github.com/QSD-Group/QSDsan/blob/main/LICENSE.txt @@ -21,8 +23,15 @@ __all__ = ( 'Junction', - 'ADMjunction', 'ADMtoASM', 'ASMtoADM', - ) + 'ADMjunction', + 'mADMjunction', + 'ADMtoASM', + 'ASMtoADM', + 'ASM2dtoADM1', + 'ADM1toASM2d', + 'ASM2dtomADM1', + 'mADM1toASM2d', + ) #%% class Junction(SanUnit): @@ -287,7 +296,7 @@ def adm1_model(self): def adm1_model(self, model): if not isinstance(model, pc.ADM1): raise ValueError('`adm1_model` must be an `AMD1` object, ' - f'the given object is {type(model).__name__}.') + f'the given object is {type(model).__name__}.') self._adm1_model = model @property @@ -329,6 +338,128 @@ def alpha_IN(self): pKa_IN = self.pKa[1] return 10**(pKa_IN-pH)/(1+10**(pKa_IN-pH))/14 + def _compile_AE(self): + _state = self._state + _dstate = self._dstate + _cached_state = self._cached_state + _update_state = self._update_state + _update_dstate = self._update_dstate + rxn = self.reactions + + def yt(t, QC_ins, dQC_ins): + before_vals = QC_ins[0,:-1] + _state[:-1] = rxn(before_vals) + _state[-1] = QC_ins[0,-1] + if t > self._cached_t: + _dstate[:] = (_state - _cached_state)/(t-self._cached_t) + _cached_state[:] = _state + self._cached_t = t + _update_state() + _update_dstate() + + self._AE = yt +#%% + +class mADMjunction(Junction): + ''' + An abstract superclass holding common properties of ADM interface classes. + Users should use its subclasses (e.g., ``ASMtoADM``, ``ADMtoASM``) instead. + + See Also + -------- + :class:`qsdsan.sanunits.Junction` + + :class:`qsdsan.sanunits.ADMtoASM` + + :class:`qsdsan.sanunits.ASMtoADM` + ''' + _parse_reactions = Junction._no_parse_reactions + rtol = 1e-2 + atol = 1e-6 + + def __init__(self, ID='', upstream=None, downstream=(), thermo=None, + init_with='WasteStream', F_BM_default=None, isdynamic=False, + adm1_model=None): + self.adm1_model = adm1_model # otherwise there won't be adm1_model when `_compile_reactions` is called + + if thermo is None: + warn('No `thermo` object is provided and is prone to raise error. ' + 'If you are not sure how to get the `thermo` object, ' + 'use `thermo = qsdsan.set_thermo` after setting thermo with the `Components` object.') + super().__init__(ID=ID, upstream=upstream, downstream=downstream, + thermo=thermo, init_with=init_with, + F_BM_default=F_BM_default, isdynamic=isdynamic) + + + @property + def T(self): + '''[float] Temperature of the upstream/downstream [K].''' + return self.ins[0].T + @T.setter + def T(self, T): + self.ins[0].T = self.outs[0].T = T + + @property + def pH(self): + '''[float] pH of the upstream/downstream.''' + return self.ins[0].pH + + @property + def adm1_model(self): + '''[qsdsan.Process] ADM process model.''' + return self._adm1_model + @adm1_model.setter + def adm1_model(self, model): + if not isinstance(model, pc.ADM1_p_extension): + raise ValueError('`adm1_model` must be an `AMD1` object, ' + f'the given object is {type(model).__name__}.') + self._adm1_model = model + + @property + def T_base(self): + '''[float] Base temperature in the ADM1 model.''' + return self.adm1_model.rate_function.params['T_base'] + + @property + def pKa_base(self): + '''[float] pKa of the acid-base pairs at the base temperature in the ADM1 model.''' + Ka_base = self.adm1_model.rate_function.params['Ka_base'] + return -np.log10(Ka_base) + + @property + def Ka_dH(self): + '''[float] Heat of reaction for Ka.''' + return self.adm1_model.rate_function.params['Ka_dH'] + + @property + def pKa(self): + ''' + [numpy.array] pKa array of the following acid-base pairs: + ('H+', 'OH-'), ('NH4+', 'NH3'), ('H3PO4', 'H2PO4 2-'), ('CO2', 'HCO3-'), + ('HAc', 'Ac-'), ('HPr', 'Pr-'), ('HBu', 'Bu-'), ('HVa', 'Va-') + ''' + return self.pKa_base-np.log10(np.exp(pc.T_correction_factor(self.T_base, self.T, self.Ka_dH))) + + @property + def alpha_IN(self): + '''[float] Charge per g of N.''' + pH = self.pH + pKa_IN = self.pKa[1] + return 10**(pKa_IN-pH)/(1+10**(pKa_IN-pH))/14 + + @property + def alpha_IP(self): + '''[float] Charge per g of P.''' + pH = self.pH + pKa_IP = self.pKa[2] + return 10**(pKa_IP-pH)/(1+10**(pKa_IP-pH))/31 + + @property + def alpha_IC(self): + '''[float] Charge per g of C.''' + pH = self.pH + pKa_IC = self.pKa[3] + return -1/(1+10**(pKa_IC-pH))/12 def _compile_AE(self): _state = self._state @@ -394,6 +525,10 @@ class ADMtoASM(ADMjunction): # Should be constants cod_vfa = np.array([64, 112, 160, 208]) + # whether to conserve the nitrogen split between soluble and particulate components + conserve_particulate_N = False + + def isbalanced(self, lhs, rhs_vals, rhs_i): rhs = sum(rhs_vals*rhs_i) error = rhs - lhs @@ -473,6 +608,7 @@ def _compile_reactions(self): alpha_IC = self.alpha_IC alpha_vfa = self.alpha_vfa f_corr = self.balance_cod_tkn + conserve_particulate_N = self.conserve_particulate_N def adm2asm(adm_vals): S_su, S_aa, S_fa, S_va, S_bu, S_pro, S_ac, S_h2, S_ch4, S_IC, S_IN, S_I, \ @@ -497,20 +633,33 @@ def adm2asm(adm_vals): X_P = xp_cod bio_n -= xp_ndm X_S = bio_cod - X_P - xs_ndm = X_S*X_S_i_N - if xs_ndm <= bio_n: - X_ND = bio_n - xs_ndm - bio_n = 0 - elif xs_ndm <= bio_n + S_IN: - X_ND = 0 - S_IN -= (xs_ndm - bio_n) - bio_n = 0 + if conserve_particulate_N: + xs_ndm = X_S*X_S_i_N + if xs_ndm <= bio_n: + X_ND = bio_n - xs_ndm + bio_n = 0 + elif xs_ndm <= bio_n + S_IN: + X_ND = 0 + S_IN -= (xs_ndm - bio_n) + bio_n = 0 + else: + if isclose(xs_ndm, bio_n + S_IN, rel_tol=rtol, abs_tol=atol): + X_ND = S_IN = bio_n = 0 + else: + raise RuntimeError('Not enough nitrogen (S_IN + biomass) to map ' + 'all biomass COD into X_P and X_S') else: - if isclose(xs_ndm, bio_n + S_IN, rel_tol=rtol, abs_tol=atol): - X_ND = S_IN = bio_n = 0 + xs_ndm = X_S*X_c_i_N # requires X_S.i_N = 0 + if xs_ndm <= bio_n + S_IN: + X_ND = xs_ndm + S_IN += bio_n - X_ND + bio_n = 0 else: - raise RuntimeError('Not enough nitrogen (S_IN + biomass) to map ' - 'all biomass COD into X_P and X_S') + if isclose(xs_ndm, bio_n + S_IN, rel_tol=rtol, abs_tol=atol): + X_ND = S_IN = bio_n = 0 + else: + raise RuntimeError('Not enough nitrogen (S_IN + biomass) to map ' + 'all biomass COD into X_P and X_S') # Step 1b: convert particulate substrates into X_S + X_ND xsub_cod = X_c + X_ch + X_pr + X_li @@ -865,4 +1014,2023 @@ def asm2adm(asm_vals): return adm_vals - self._reactions = asm2adm \ No newline at end of file + self._reactions = asm2adm + +#%% + +class ASM2dtoADM1(ADMjunction): + ''' + Interface unit to convert activated sludge model No. (ASM2d) components + to original anaerobic digestion model (ADM1) components. + + Parameters + ---------- + upstream : stream or str + Influent stream with ASM components. + downstream : stream or str + Effluent stream with ADM components. + adm1_model : obj + The anaerobic digestion process model (:class:`qsdsan.processes.ADM1`). + xs_to_li : float + Split of slowly biodegradable substrate COD to lipid, + after all N is mapped into protein. + bio_to_li : float + Split of biomass COD to lipid, after all biomass N is + mapped into protein. + frac_deg : float + Biodegradable fraction of biomass COD. + rtol : float + Relative tolerance for COD and TKN balance. + atol : float + Absolute tolerance for COD and TKN balance. + + References + ---------- + [1] Nopens, I.; Batstone, D. J.; Copp, J. B.; Jeppsson, U.; Volcke, E.; + Alex, J.; Vanrolleghem, P. A. An ASM/ADM Model Interface for Dynamic + Plant-Wide Simulation. Water Res. 2009, 43, 1913–1923. + + See Also + -------- + :class:`qsdsan.sanunits.ADMjunction` + + :class:`qsdsan.sanunits.ADMtoASM` + + `math.isclose ` + ''' + # User defined values + xs_to_li = 0.7 + bio_to_li = 0.4 + frac_deg = 0.68 + asm_X_I_i_N = 0.06 + + def isbalanced(self, lhs, rhs_vals, rhs_i): + rhs = sum(rhs_vals*rhs_i) + error = rhs - lhs + tol = max(self.rtol*lhs, self.rtol*rhs, self.atol) + return abs(error) <= tol, error, tol, rhs + + def balance_cod_tkn(self, asm_vals, adm_vals): + cmps_asm = self.ins[0].components + cmps_adm = self.outs[0].components + asm_i_COD = cmps_asm.i_COD + adm_i_COD = cmps_adm.i_COD + non_tkn_idx = cmps_asm.indices(('S_N2', 'S_NO3')) + asm_i_N = cmps_asm.i_N + adm_i_N = cmps_adm.i_N + asm_cod = sum(asm_vals*asm_i_COD) + asm_tkn = sum(asm_vals*asm_i_N) - sum(asm_vals[non_tkn_idx]) + cod_bl, cod_err, cod_tol, adm_cod = self.isbalanced(asm_cod, adm_vals, adm_i_COD) + tkn_bl, tkn_err, tkn_tol, adm_tkn = self.isbalanced(asm_tkn, adm_vals, adm_i_N) + + if cod_bl: + if tkn_bl: return adm_vals + else: + if tkn_err > 0: dtkn = -(tkn_err - tkn_tol)/adm_tkn + else: dtkn = -(tkn_err + tkn_tol)/adm_tkn + _adm_vals = adm_vals * (1 + (adm_i_N>0)*dtkn) + _cod_bl, _cod_err, _cod_tol, _adm_cod = self.isbalanced(asm_cod, _adm_vals, adm_i_COD) + if _cod_bl: return _adm_vals + else: + warn('cannot balance COD and TKN at the same ' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ASM) COD is {asm_cod}\n ' + f'effluent (ADM) COD is {adm_cod} or {_adm_cod}\n ' + f'influent TKN is {asm_tkn}\n ' + f'effluent TKN is {adm_tkn} or {adm_tkn*(1+dtkn)}. ') + return adm_vals + else: + if cod_err > 0: dcod = -(cod_err - cod_tol)/adm_cod + else: dcod = -(cod_err + cod_tol)/adm_cod + _adm_vals = adm_vals * (1 + (adm_i_COD>0)*dcod) + _tkn_bl, _tkn_err, _tkn_tol, _adm_tkn = self.isbalanced(asm_tkn, _adm_vals, adm_i_N) + if _tkn_bl: return _adm_vals + else: + warn('cannot balance COD and TKN at the same ' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ASM) COD is {asm_cod}\n ' + f'effluent (ADM) COD is {adm_cod} or {adm_cod*(1+dcod)}\n ' + f'influent TKN is {asm_tkn}\n ' + f'effluent TKN is {adm_tkn} or {_adm_tkn}. ') + return adm_vals + + def _compile_reactions(self): + # Retrieve constants + ins = self.ins[0] + outs = self.outs[0] + rtol = self.rtol + atol = self.atol + + cmps_asm = ins.components + + S_NO3_i_COD = cmps_asm.S_NO3.i_COD + X_H_i_N = cmps_asm.X_H.i_N + X_AUT_i_N = cmps_asm.X_AUT.i_N + X_PAO_i_N = cmps_asm.X_PAO.i_N + S_F_i_N = cmps_asm.S_F.i_N + X_S_i_N = cmps_asm.X_S.i_N + asm_S_I_i_N = cmps_asm.S_I.i_N + + if self.asm_X_I_i_N == None: + asm_X_I_i_N = cmps_asm.X_I.i_N + else: + asm_X_I_i_N = self.asm_X_I_i_N + + if cmps_asm.S_A.i_N > 0: + warn(f'S_A in ASM2d has positive nitrogen content: {cmps_asm.S_S.i_N} gN/gCOD. ' + 'These nitrogen will be ignored by the interface model ' + 'and could lead to imbalance of TKN after conversion.') + + cmps_adm = outs.components + S_aa_i_N = cmps_adm.S_aa.i_N + X_pr_i_N = cmps_adm.X_pr.i_N + adm_S_I_i_N = cmps_adm.S_I.i_N + adm_X_I_i_N = cmps_adm.X_I.i_N + + + adm_ions_idx = cmps_adm.indices(['S_IN', 'S_IC', 'S_cat', 'S_an']) + + frac_deg = self.frac_deg + alpha_IN = self.alpha_IN + alpha_IC = self.alpha_IC + proton_charge = 10**(-self.pKa[0]+self.pH) - 10**(-self.pH) # self.pKa[0] is pKw + f_corr = self.balance_cod_tkn + + def asm2adm(asm_vals): + + S_O2, S_N2, S_NH4, S_NO3, S_PO4, S_F, S_A, S_I, S_ALK, X_I, X_S, X_H, \ + X_PAO, X_PP, X_PHA, X_AUT, X_MeOH, X_MeP, H2O = asm_vals + + # Step 0: charged component snapshot + _sa = S_A + _snh4 = S_NH4 + _sno3 = S_NO3 + _spo4 = S_PO4 + _salk = S_ALK + _xpp = X_PP + + # Step 1: remove any remaining COD demand + O2_coddm = S_O2 + NO3_coddm = -S_NO3*S_NO3_i_COD + + cod_spl = (S_A + S_F) + (X_S + X_PHA) + (X_H + X_AUT + X_PAO) + + + bioN = X_H*X_H_i_N + X_AUT*X_AUT_i_N + X_PAO*X_PAO_i_N + # To be used in Step 2 + S_F_N = S_F*S_F_i_N #S_ND (in asm1) equals the N content in S_F + # To be used in Step 3 + X_S_N = X_S*X_S_i_N #X_ND (in asm1) equals the N content in X_S + + + if cod_spl <= O2_coddm: + S_O2 = O2_coddm - cod_spl + S_F = S_A = X_S = X_H = X_AUT = 0 + elif cod_spl <= O2_coddm + NO3_coddm: + S_O2 = 0 + S_NO3 = -(O2_coddm + NO3_coddm - cod_spl)/S_NO3_i_COD + S_A = S_F = X_S = X_H = X_AUT = 0 + else: + S_A -= O2_coddm + NO3_coddm + if S_A < 0: + S_F += S_A + S_A = 0 + if S_F < 0: + X_S += S_F + S_F = 0 + if X_S < 0: + X_PHA += X_S + X_S = 0 + if X_PHA < 0: + X_H += X_PHA + X_PHA = 0 + if X_H < 0: + X_AUT += X_H + X_H = 0 + if X_AUT < 0: + X_PAO += X_AUT + X_AUT = 0 + + S_O2 = S_NO3 = 0 + + # Step 2: convert any readily biodegradable + # COD and TKN into amino acids and sugars + + # S_S (in asm1) equals to the sum of S_F and S_A (pg. 82 IWA ASM models handbook) + S_S_asm1 = S_F + S_A + + # First we calculate the amount of amino acid required in ADM1 + # if all available soluble organic N can be mapped to amino acid + req_scod = S_F_N / S_aa_i_N + + # if available S_S is not enough to fulfill that amino acid requirement + if S_S_asm1 < req_scod: + # then all available S_S is mapped to amino acids + S_aa = S_S_asm1 + # and no S_S would be available for conversion to sugars + S_su = 0 + # This needs to be followed by a corresponding loss in soluble organic N + S_F_N -= S_aa * S_aa_i_N + # if available S_S is more than enough to fulfill that amino acid requirement + else: + # All soluble organic N will be mapped to amino acid + S_aa = req_scod + # The line above implies that a certain portion of S_S would also be consumed to form amino acid + # The S_S which is left would form sugar + # In simpler terms; S_S = S_S - S_aa; S_su = S_S + S_su = S_S_asm1 - S_aa + # All soluble organic N would thus be consumed in amino acid formation + S_F_N = 0 + + + # Step 3: convert slowly biodegradable COD and TKN + # into proteins, lipids, and carbohydrates + + # First we calculate the amount of protein required in ADM1 + # if all available particulate organic N can be mapped to protein + req_xcod = X_S_N / X_pr_i_N + + # if available X_S is not enough to fulfill that protein requirement + if X_S < req_xcod: + # then all available X_S is mapped to amino acids + X_pr = X_S + # and no X_S would be available for conversion to lipid or carbohydrates + + X_li = self.xs_to_li * X_PHA + X_ch = (1 - self.xs_to_li)*X_PHA + + # This needs to be followed by a corresponding loss in particulate organic N + X_S_N -= X_pr * X_pr_i_N + + # if available X_S is more than enough to fulfill that protein requirement + else: + # All particulate organic N will be mapped to amino acid + X_pr = req_xcod + # The line above implies that a certain portion of X_S would also be consumed to form protein + # The X_S which is left would form lipid and carbohydrates in a percentage define by the user + X_li = self.xs_to_li * (X_S + X_PHA - X_pr) + X_ch = (X_S + X_PHA - X_pr) - X_li + # All particulate organic N would thus be consumed in amino acid formation + X_S_N = 0 + + # Step 4: convert active biomass into protein, lipids, + # carbohydrates and potentially particulate TKN + + # First the amount of biomass N available for protein, lipid etc is determined + # For this calculation, from total biomass N available the amount + # of particulate inert N expected in ADM1 is subtracted + + available_bioN = bioN - (X_H + X_AUT + X_PAO) * (1-frac_deg) * adm_X_I_i_N + + if available_bioN < 0: + raise RuntimeError('Not enough N in X_H, X_AUT and X_PAO to fully convert ' + 'the non-biodegradable portion into X_I in ADM1.') + + # Then the amount of biomass N required for biomass conversion to protein is determined + req_bioN = (X_H + X_AUT + X_PAO) * frac_deg * X_pr_i_N + # req_bioP = (X_H + X_AUT) * frac_deg * X_pr_i_P + + # If available biomass N and particulate organic N is greater than + # required biomass N for conversion to protein + if available_bioN + X_S_N >= req_bioN: + # then all biodegradable biomass N (corrsponding to protein demand) is converted to protein + X_pr += (X_H + X_AUT + X_PAO) * frac_deg + # the remaining biomass N is transfered as organic N + X_S_N += available_bioN - req_bioN + else: + # all available N and particulate organic N is converted to protein + bio2pr = (available_bioN + X_S_N)/X_pr_i_N + X_pr += bio2pr + # Biodegradable biomass available after conversion to protein is calculated + bio_to_split = (X_H + X_AUT + X_PAO) * frac_deg - bio2pr + # Part of the remaining biomass is mapped to lipid based on user defined value + bio_split_to_li = bio_to_split * self.bio_to_li + X_li += bio_split_to_li + # The other portion of the remanining biomass is mapped to carbohydrates + X_ch += (bio_to_split - bio_split_to_li) + # Since all organic N has been mapped to protein, none is left + X_S_N = 0 + + # Step 5: map particulate inerts + + # 5 (a) + # First determine the amount of particulate inert N available from ASM2d + xi_nsp_asm2d = X_I * asm_X_I_i_N + + # Then determine the amount of particulate inert N that could be produced + # in ADM1 given the ASM1 X_I + xi_ndm = X_I * adm_X_I_i_N + + # if particulate inert N available in ASM1 is greater than ADM1 demand + if xi_nsp_asm2d + X_S_N >= xi_ndm: + deficit = xi_ndm - xi_nsp_asm2d + # COD balance + X_I += (X_H + X_AUT + X_PAO) * (1-frac_deg) + # N balance + X_S_N -= deficit + elif isclose(xi_nsp_asm2d+X_S_N, xi_ndm, rel_tol=rtol, abs_tol=atol): + # COD balance + X_I += (X_H + X_AUT + X_PAO) * (1-frac_deg) + # N balance + X_S_N = 0 + else: + raise RuntimeError('Not enough N in X_I, X_S to fully ' + 'convert X_I in ASM2d into X_I in ADM1.') + + # 5(b) + + # Then determine the amount of soluble inert N that could be produced + # in ADM1 given the ASM1 X_I + req_sn = S_I * adm_S_I_i_N + supply_inert_n_asm2d = S_I * asm_S_I_i_N + + # N balance + if req_sn <= S_F_N + supply_inert_n_asm2d: + S_F_N -= (req_sn - supply_inert_n_asm2d) + supply_inert_n_asm2d = 0 + # N balance + elif req_sn <= S_F_N + X_S_N + supply_inert_n_asm2d: + X_S_N -= (req_sn - S_F_N - supply_inert_n_asm2d) + S_F_N = supply_inert_n_asm2d = 0 + # N balance + elif req_sn <= S_F_N + X_S_N + S_NH4 + supply_inert_n_asm2d: + S_NH4 -= (req_sn - S_F_N - X_S_N - supply_inert_n_asm2d) + S_F_N = X_S_N = supply_inert_n_asm2d = 0 + else: + warn('Additional soluble inert COD is mapped to S_su.') + SI_cod = (S_F_N + X_S_N + S_NH4 + supply_inert_n_asm2d)/adm_S_I_i_N + S_su += S_I - SI_cod + S_I = SI_cod + S_F_N = X_S_N = S_NH4 = supply_inert_n_asm2d = 0 + + # Step 6: Step map any remaining TKN/P + S_IN = S_F_N + X_S_N + S_NH4 + supply_inert_n_asm2d + + # Step 8: check COD and TKN balance + # has TKN: S_aa, S_IN, S_I, X_pr, X_I + S_IC = S_cat = S_an = 0 + + adm_vals = np.array([ + S_su, S_aa, + 0, 0, 0, 0, 0, # S_fa, S_va, S_bu, S_pro, S_ac, + 0, 0, # S_h2, S_ch4, + S_IC, S_IN, S_I, + 0, # X_c, + X_ch, X_pr, X_li, + 0, 0, 0, 0, 0, 0, 0, # X_su, X_aa, X_fa, X_c4, X_pro, X_ac, X_h2, + X_I, S_cat, S_an, H2O]) + + adm_vals = f_corr(asm_vals, adm_vals) + + # Step 7: charge balance + asm_charge_tot = - _sa/64 + _snh4/14 - _sno3/14 - 1.5*_spo4/31 - _salk - _xpp/31 #Based on page 84 of IWA ASM handbook + #!!! charge balance should technically include VFAs, + # but VFAs concentrations are assumed zero per previous steps?? + S_IN = adm_vals[adm_ions_idx[0]] + + S_IC = (asm_charge_tot -S_IN*alpha_IN)/alpha_IC + + net_Scat = asm_charge_tot + proton_charge + if net_Scat > 0: + S_cat = net_Scat + S_an = 0 + else: + S_cat = 0 + S_an = -net_Scat + + adm_vals[adm_ions_idx[1:]] = [S_IC, S_cat, S_an] + + return adm_vals + + self._reactions = asm2adm + +#%% + +class ADM1toASM2d(ADMjunction): + ''' + Interface unit to convert anaerobic digestion model no. 1 (ADM1) components + to activated sludge model no. 2 (ASM2d) components. + + Parameters + ---------- + upstream : stream or str + Influent stream with ADM components. + downstream : stream or str + Effluent stream with ASM components. + adm1_model : obj + The anaerobic digestion process model (:class:`qsdsan.processes.ADM1`). + bio_to_xs : float + Split of the total biomass COD to slowly biodegradable substrate (X_S), + the rest is assumed to be mapped into X_P. + rtol : float + Relative tolerance for COD and TKN balance. + atol : float + Absolute tolerance for COD and TKN balance. + + References + ---------- + [1] Nopens, I.; Batstone, D. J.; Copp, J. B.; Jeppsson, U.; Volcke, E.; + Alex, J.; Vanrolleghem, P. A. An ASM/ADM Model Interface for Dynamic + Plant-Wide Simulation. Water Res. 2009, 43, 1913–1923. + + See Also + -------- + :class:`qsdsan.sanunits.ADMjunction` + + :class:`qsdsan.sanunits.ASMtoADM` + + `math.isclose ` + ''' + # User defined values + bio_to_xs = 0.9 + + # Should be constants + cod_vfa = np.array([64, 112, 160, 208]) + + # whether to conserve the nitrogen split between soluble and particulate components + conserve_particulate_N = False + + + def isbalanced(self, lhs, rhs_vals, rhs_i): + rhs = sum(rhs_vals*rhs_i) + error = rhs - lhs + tol = max(self.rtol*lhs, self.rtol*rhs, self.atol) + return abs(error) <= tol, error, tol, rhs + + def balance_cod_tkn(self, adm_vals, asm_vals): + cmps_adm = self.ins[0].components + cmps_asm = self.outs[0].components + asm_i_COD = cmps_asm.i_COD + adm_i_COD = cmps_adm.i_COD + gas_idx = cmps_adm.indices(('S_h2', 'S_ch4')) + asm_i_N = cmps_asm.i_N + adm_i_N = cmps_adm.i_N + adm_cod = sum(adm_vals*adm_i_COD) - sum(adm_vals[gas_idx]) + adm_tkn = sum(adm_vals*adm_i_N) + cod_bl, cod_err, cod_tol, asm_cod = self.isbalanced(adm_cod, asm_vals, asm_i_COD) + tkn_bl, tkn_err, tkn_tol, asm_tkn = self.isbalanced(adm_tkn, asm_vals, asm_i_N) + if cod_bl: + if tkn_bl: return asm_vals + else: + if tkn_err > 0: dtkn = -(tkn_err - tkn_tol)/asm_tkn + else: dtkn = -(tkn_err + tkn_tol)/asm_tkn + _asm_vals = asm_vals * (1 + (asm_i_N>0)*dtkn) + _cod_bl, _cod_err, _cod_tol, _asm_cod = self.isbalanced(adm_cod, _asm_vals, asm_i_COD) + if _cod_bl: return _asm_vals + else: + warn('cannot balance COD and TKN at the same ' + f'time with rtol={self.rtol} and atol={self.atol}. ' + f'influent (ADM) COD is {adm_cod}, ' + f'effluent (ASM) COD is {asm_cod} or {_asm_cod}. ' + f'influent TKN is {adm_tkn}, ' + f'effluent TKN is {asm_tkn} or {asm_tkn*(1+dtkn)}. ') + return asm_vals + else: + if cod_err > 0: dcod = -(cod_err - cod_tol)/asm_cod + else: dcod = -(cod_err + cod_tol)/asm_cod + _asm_vals = asm_vals * (1 + (asm_i_COD>0)*dcod) + _tkn_bl, _tkn_err, _tkn_tol, _asm_tkn = self.isbalanced(adm_tkn, _asm_vals, asm_i_N) + if _tkn_bl: return _asm_vals + else: + warn('cannot balance COD and TKN at the same ' + f'time with rtol={self.rtol} and atol={self.atol}. ' + f'influent (ADM) COD is {adm_cod}, ' + f'effluent (ASM) COD is {asm_cod} or {asm_cod*(1+dcod)}. ' + f'influent TKN is {adm_tkn}, ' + f'effluent TKN is {asm_tkn} or {_asm_tkn}. ') + return asm_vals + + def _compile_reactions(self): + # Retrieve constants + ins = self.ins[0] + outs = self.outs[0] + rtol = self.rtol + atol = self.atol + + cmps_adm = ins.components + X_c_i_N = cmps_adm.X_c.i_N + X_pr_i_N = cmps_adm.X_pr.i_N + S_aa_i_N = cmps_adm.S_aa.i_N + adm_X_I_i_N = cmps_adm.X_I.i_N + adm_S_I_i_N = cmps_adm.S_I.i_N + adm_i_N = cmps_adm.i_N + adm_bio_N_indices = cmps_adm.indices(('X_su', 'X_aa', 'X_fa', + 'X_c4', 'X_pro', 'X_ac', 'X_h2')) + + cmps_asm = outs.components + + X_S_i_N = cmps_asm.X_S.i_N + S_F_i_N = cmps_asm.S_F.i_N + + asm_X_I_i_N = cmps_asm.X_I.i_N + asm_S_I_i_N = cmps_asm.S_I.i_N + + asm_ions_idx = cmps_asm.indices(('S_A', 'S_NH4', 'S_NO3', 'S_PO4','S_ALK', 'X_PP')) + + alpha_IN = self.alpha_IN + alpha_IC = self.alpha_IC + alpha_vfa = self.alpha_vfa + f_corr = self.balance_cod_tkn + conserve_particulate_N = self.conserve_particulate_N + + def adm2asm(adm_vals): + + S_su, S_aa, S_fa, S_va, S_bu, S_pro, S_ac, S_h2, S_ch4, S_IC, S_IN, S_I, X_c, \ + X_ch, X_pr, X_li, X_su, X_aa, X_fa, X_c4, X_pro, X_ac, X_h2, X_I, S_cat, S_an, H2O = adm_vals + + # Step 0: snapshot of charged components + _ions = np.array([S_IN, S_IC, S_ac, S_pro, S_bu, S_va]) + + # Step 1a: convert biomass into X_S and X_I + + bio_cod = X_su + X_aa + X_fa + X_c4 + X_pro + X_ac + X_h2 + bio_n = sum((adm_vals*adm_i_N)[adm_bio_N_indices]) + + #!!! In default ASM2d stoichiometry, biomass decay (cell lysis) + #!!! yields 90% particulate substrate + 10% X_I + #!!! so: convert both biomass and X_I in adm to X_S and X_I in asm + xi_n = X_I*adm_X_I_i_N + xs_cod = bio_cod * self.bio_to_xs + xs_ndm = xs_cod * X_S_i_N + + xi_cod = bio_cod * (1-self.bio_to_xs) + X_I + xi_ndm = xi_cod * asm_X_I_i_N + + if xs_ndm > bio_n: + warn('Not enough biomass N to map the specified proportion of ' + 'biomass COD into X_S. Rest of the biomass COD goes to S_A') + X_S = bio_n / X_S_i_N + xs_cod -= X_S + bio_n = 0 + else: + X_S = xs_cod + xs_cod = 0 + bio_n -= xs_ndm + + if xi_ndm > bio_n + xi_n + S_IN: + warn('Not enough N in biomass and X_I to map the specified proportion of ' + 'biomass COD into X_I. Rest of the biomass COD goes to S_A') + X_I = (bio_n + xi_n + S_IN) / asm_X_I_i_N + xi_cod -= X_I + bio_n = xi_n = S_IN = 0 + else: + X_I = xi_cod + xi_cod = 0 + xi_n -= xi_ndm + if xi_n < 0: + bio_n += xi_n + xi_n = 0 + if bio_n < 0: + S_IN += bio_n + bio_n = 0 + + xsub_cod = X_c + X_ch + X_pr + X_li + xsub_n = X_c*X_c_i_N + X_pr*X_pr_i_N + + xs_ndm = xsub_cod * X_S_i_N + + if xs_ndm > xsub_n + bio_n: + X_S_temp = (xsub_n + bio_n)/X_S_i_N + X_S += X_S_temp + xsub_cod -= X_S_temp + xsub_n = bio_n = 0 + else: + X_S += xsub_cod + xsub_cod = 0 + xsub_n -= xs_ndm + if xsub_n < 0: + bio_n += xsub_n + xsub_n = 0 + + ssub_cod = S_su + S_aa + S_fa + ssub_n = S_aa * S_aa_i_N + sf_ndm = ssub_cod * S_F_i_N + + if sf_ndm > ssub_n + xsub_n + bio_n: + S_F = (ssub_n + xsub_n + bio_n) / S_F_i_N + ssub_cod -= S_F + ssub_n = xsub_n = bio_n = 0 + else: + S_F = ssub_cod + ssub_cod = 0 + ssub_n -= sf_ndm + if ssub_n < 0: + xsub_n += ssub_n + ssub_n = 0 + if xsub_n < 0: + bio_n += xsub_n + xsub_n = 0 + + S_A = S_ac + S_pro + S_bu + S_va + + si_cod = S_I + si_n = S_I * adm_S_I_i_N + si_ndm = si_cod * asm_S_I_i_N + if si_ndm > si_n + xi_n + S_IN: + warn('Not enough N in S_I and X_I to map all S_I from ADM1 to ASM2d. ' + 'Rest of the S_I COD goes to S_A') + S_I = (si_n + xi_n + S_IN) / asm_S_I_i_N + si_cod -= S_I + si_n = xi_n = S_IN = 0 + else: + S_I = si_cod + si_cod = 0 + si_n -= si_ndm + if si_n < 0: + xi_n += si_n + si_n = 0 + if xi_n < 0: + S_IN += xi_n + xi_n = 0 + + + S_NH4 = S_IN + si_n + ssub_n + xsub_n + xi_n + bio_n + S_A += si_cod + ssub_cod + xsub_cod + xi_cod + xs_cod + S_ALK = S_IC + + # Step 6: check COD and TKN balance + asm_vals = np.array(([ + 0, 0, # S_O2, S_N2, + S_NH4, + 0, + 0, + S_F, S_A, S_I, S_ALK, + X_I, X_S, + 0, # X_H, + 0, 0, 0, + 0, # X_AUT, + 0, 0, H2O])) + + if S_h2 > 0 or S_ch4 > 0: + warn('Ignored dissolved H2 or CH4.') + + asm_vals = f_corr(adm_vals, asm_vals) + + # Step 5: charge balance for alkalinity + + # asm_ions_idx = cmps_asm.indices(('S_A', 'S_NH4', 'S_NO3', 'S_PO4','S_ALK', 'X_PP')) + + _sa = asm_vals[asm_ions_idx[0]] + _snh4 = asm_vals[asm_ions_idx[1]] + _sno3 = asm_vals[asm_ions_idx[2]] + _spo4 = asm_vals[asm_ions_idx[3]] + _xpp = asm_vals[asm_ions_idx[5]] + + S_ALK = (sum(_ions * np.append([alpha_IN, alpha_IC], alpha_vfa)) - \ + (- _sa/64 + _snh4/14 - _sno3/14 - 1.5*_spo4/31 - _xpp/31))*(-12) + + asm_vals[asm_ions_idx[4]] = S_ALK + + return asm_vals + + self._reactions = adm2asm + + @property + def alpha_vfa(self): + return 1.0/self.cod_vfa*(-1.0/(1.0 + 10**(self.pKa[3:]-self.pH))) + +#%% + +class mADM1toASM2d(mADMjunction): + ''' + Interface unit to convert modified anaerobic digestion model no. 1 (ADM1) components + to activated sludge model no. 2d (ASM2d) components. + + Parameters + ---------- + upstream : stream or str + Influent stream with ADM components. + downstream : stream or str + Effluent stream with ASM components. + adm1_model : obj + The anaerobic digestion process model (:class:`qsdsan.processes.ADM1_p_extension`). + rtol : float + Relative tolerance for COD and TKN balance. + atol : float + Absolute tolerance for COD and TKN balance. + + References + ---------- + [1] Nopens, I.; Batstone, D. J.; Copp, J. B.; Jeppsson, U.; Volcke, E.; + Alex, J.; Vanrolleghem, P. A. An ASM/ADM Model Interface for Dynamic + Plant-Wide Simulation. Water Res. 2009, 43, 1913–1923. + + [2] Flores-Alsina, X., Solon, K., Kazadi Mbamba, C., Tait, S., Gernaey, K. V., + Jeppsson, U., & Batstone, D. J. (2016). Modelling phosphorus (P), sulfur (S) + and iron (FE) interactions for dynamic simulations of anaerobic digestion processes. + Water Research, 95, 370–382. + See Also + -------- + :class:`qsdsan.sanunits.ADMjunction` + + :class:`qsdsan.sanunits.ASMtoADM` + + `math.isclose ` + ''' + + # User defined values + bio_to_xs = 0.9 + + # Since we are matching PAOs directly from ASM2d to mADM1, it is important + # for PAOs to have identical N/P content across models + + adm_X_PAO_i_N = 0.07 + adm_X_PAO_i_P = 0.02 + + # Should be constants + cod_vfa = np.array([64, 112, 160, 208]) + + def isbalanced(self, lhs, rhs_vals, rhs_i): + rhs = sum(rhs_vals*rhs_i) + error = rhs - lhs + tol = max(self.rtol*lhs, self.rtol*rhs, self.atol) + return abs(error) <= tol, error, tol, rhs + + def balance_cod_tkn(self, adm_vals, asm_vals): + cmps_adm = self.ins[0].components + cmps_asm = self.outs[0].components + asm_i_COD = cmps_asm.i_COD + adm_i_COD = cmps_adm.i_COD + gas_idx = cmps_adm.indices(('S_h2', 'S_ch4')) + + asm_i_N = cmps_asm.i_N + adm_i_N = cmps_adm.i_N + asm_i_P = cmps_asm.i_P + adm_i_P = cmps_adm.i_P + adm_cod = sum(adm_vals*adm_i_COD) - sum(adm_vals[gas_idx]) + adm_tkn = sum(adm_vals*adm_i_N) + adm_tp = sum(adm_vals*adm_i_P) + + cod_bl, cod_err, cod_tol, asm_cod = self.isbalanced(adm_cod, asm_vals, asm_i_COD) + tkn_bl, tkn_err, tkn_tol, asm_tkn = self.isbalanced(adm_tkn, asm_vals, asm_i_N) + tp_bl, tp_err, tp_tol, asm_tp = self.isbalanced(adm_tp, asm_vals, asm_i_P) + + if tkn_bl and tp_bl: + if cod_bl: + return asm_vals + else: + print('COD not balanced') + breakpoint() + if cod_err > 0: dcod = -(cod_err - cod_tol)/asm_cod + else: dcod = -(cod_err + cod_tol)/asm_cod + _asm_vals = asm_vals * (1 + (asm_i_COD>0)*dcod) + _tkn_bl, _tkn_err, _tkn_tol, _asm_tkn = self.isbalanced(adm_tkn, _asm_vals, asm_i_N) + _tp_bl, _tp_err, _tp_tol, _asm_tp = self.isbalanced(adm_tp, _asm_vals, asm_i_P) + if _tkn_bl and _tp_bl: return _asm_vals + else: + warn('cannot balance COD, TKN, and TP at the same \n' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ADM) TKN is {adm_tkn}\n ' + f'effluent (ASM) TKN is {asm_tkn} or {_asm_tkn}\n ' + f'influent (ADM) TP is {adm_tp}\n ' + f'effluent (ASM) TP is {asm_tp} or {_asm_tp}. ' + f'influent (ADM) COD is {adm_cod}\n ' + f'effluent (ASM) COD is {asm_cod} or {asm_cod*(1+dcod)}. ') + return asm_vals + elif cod_bl and tp_bl: + if tkn_bl: + return asm_vals + else: + print('TKN not balanced') + breakpoint() + if tkn_err > 0: dtkn = -(tkn_err - tkn_tol)/asm_tkn + else: dtkn = -(tkn_err + tkn_tol)/asm_tkn + _asm_vals = asm_vals * (1 + (asm_i_N>0)*dtkn) + _cod_bl, _cod_err, _cod_tol, _asm_cod = self.isbalanced(adm_cod, _asm_vals, asm_i_COD) + _tp_bl, _tp_err, _tp_tol, _asm_tp = self.isbalanced(adm_tp, _asm_vals, asm_i_P) + if _cod_bl and _tp_bl: return _asm_vals + else: + warn('cannot balance COD, TKN, and TP at the same time' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ADM) COD is {adm_cod}\n ' + f'effluent (ASM) COD is {asm_cod} or {_asm_cod}\n ' + f'influent (ADM) TP is {adm_tp}\n ' + f'effluent (ASM) TP is {asm_tp} or {_asm_tp}. ' + f'influent (ADM) TKN is {adm_tkn}\n ' + f'effluent (ASM) TKN is {asm_tkn} or {asm_tkn*(1+dtkn)}. ') + return asm_vals + elif cod_bl and tkn_bl: + if tp_bl: + return asm_vals + else: + print('TP not balanced') + breakpoint() + if tp_err > 0: dtp = -(tp_err - tp_tol)/asm_tp + else: dtp = -(tp_err + tp_tol)/asm_tp + _asm_vals = asm_vals * (1 + (asm_i_P>0)*dtp) + _cod_bl, _cod_err, _cod_tol, _asm_cod = self.isbalanced(adm_cod, _asm_vals, asm_i_COD) + _tkn_bl, _tkn_err, _tkn_tol, _asm_tkn = self.isbalanced(adm_tkn, _asm_vals, asm_i_N) + if _cod_bl and _tkn_bl: return _asm_vals + else: + warn('cannot balance COD, TKN, and TP at the same time' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ADM) COD is {adm_cod}\n ' + f'effluent (ASM) COD is {asm_cod} or {_asm_cod}\n ' + f'influent (ADM) TKN is {adm_tkn}\n ' + f'effluent (ASM) TKN is {asm_tkn} or {_asm_tkn}. ' + f'influent (ADM) TP is {adm_tp}\n ' + f'effluent (ASM) TP is {asm_tp} or {asm_tp*(1+dtp)}. ') + return asm_vals + else: + print('At least two of COD, TKN, and TP not balanced') + breakpoint() + warn('cannot balance COD, TKN and TP at the same time. \n' + 'Atleast two of the three COD, TKN, and TP are not balanced \n' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ADM) COD is {adm_cod}\n ' + f'effluent (ASM) COD is {asm_cod}\n ' + f'influent (ADM) TP is {adm_tp}\n ' + f'effluent (ASM) TP is {asm_tp}' + f'influent (ADM) TKN is {adm_tkn}\n ' + f'effluent (ASM) TKN is {asm_tkn}. ') + return asm_vals + + def _compile_reactions(self): + # Retrieve constants + ins = self.ins[0] + outs = self.outs[0] + rtol = self.rtol + atol = self.atol + + cmps_adm = ins.components + # N balance + X_pr_i_N = cmps_adm.X_pr.i_N + S_aa_i_N = cmps_adm.S_aa.i_N + adm_X_I_i_N = cmps_adm.X_I.i_N + adm_S_I_i_N = cmps_adm.S_I.i_N + adm_i_N = cmps_adm.i_N + adm_bio_N_indices = cmps_adm.indices(('X_su', 'X_aa', 'X_fa', + 'X_c4', 'X_pro', 'X_ac', 'X_h2')) + + # P balance + X_pr_i_P = cmps_adm.X_pr.i_P + adm_X_I_i_P = cmps_adm.X_I.i_P + adm_S_I_i_P = cmps_adm.S_I.i_P + S_aa_i_P = cmps_adm.S_aa.i_P + adm_i_P = cmps_adm.i_P + adm_bio_P_indices = cmps_adm.indices(('X_su', 'X_aa', 'X_fa', + 'X_c4', 'X_pro', 'X_ac', 'X_h2')) + + cmps_asm = outs.components + + # N balance + X_S_i_N = cmps_asm.X_S.i_N + S_F_i_N = cmps_asm.S_F.i_N + S_A_i_N = cmps_asm.S_A.i_N + + asm_X_I_i_N = cmps_asm.X_I.i_N + asm_S_I_i_N = cmps_asm.S_I.i_N + asm_ions_idx = cmps_asm.indices(('S_NH4', 'S_A', 'S_NO3', 'S_PO4', 'X_PP', 'S_ALK')) + + # P balance + X_S_i_P = cmps_asm.X_S.i_P + S_F_i_P = cmps_asm.S_F.i_P + S_A_i_P = cmps_asm.S_A.i_P + asm_X_I_i_P = cmps_asm.X_I.i_P + asm_S_I_i_P = cmps_asm.S_I.i_P + + # Checks for direct mapping of X_PAO, X_PP, X_PHA + + # Check for X_PAO (measured as COD so i_COD = 1 in both ASM2d and ADM1) + + asm_X_PAO_i_N = cmps_asm.X_PAO.i_N + adm_X_PAO_i_N = cmps_adm.X_PAO.i_N + + if self.adm_X_PAO_i_N == None: + adm_X_PAO_i_N = cmps_adm.X_PAO.i_N + else: + adm_X_PAO_i_N = self.adm_X_PAO_i_N + + if asm_X_PAO_i_N != adm_X_PAO_i_N: + raise RuntimeError('X_PAO cannot be directly mapped as N content' + f'in asm2d_X_PAO_i_N = {asm_X_PAO_i_N} is not equal to' + f'adm_X_PAO_i_N = {adm_X_PAO_i_N}') + + asm_X_PAO_i_P = cmps_asm.X_PAO.i_P + adm_X_PAO_i_P = cmps_adm.X_PAO.i_P + + if self.adm_X_PAO_i_P == None: + adm_X_PAO_i_P = cmps_adm.X_PAO.i_P + else: + adm_X_PAO_i_P = self.adm_X_PAO_i_P + + if asm_X_PAO_i_P != adm_X_PAO_i_P: + raise RuntimeError('X_PAO cannot be directly mapped as P content' + f'in asm2d_X_PAO_i_P = {asm_X_PAO_i_P} is not equal to' + f'adm_X_PAO_i_P = {adm_X_PAO_i_P}') + + # Checks not required for X_PP as measured as P in both, with i_COD = i_N = 0 + # Checks not required for X_PHA as measured as COD in both, with i_N = i_P = 0 + + alpha_IN = self.alpha_IN + alpha_IC = self.alpha_IC + alpha_IP = self.alpha_IP + alpha_vfa = self.alpha_vfa + f_corr = self.balance_cod_tkn + + # To convert components from mADM1 to ASM2d (madm1-2-asm2d) + def madm12asm2d(adm_vals): + + S_su, S_aa, S_fa, S_va, S_bu, S_pro, S_ac, S_h2, S_ch4, S_IC, S_IN, S_IP, S_I, \ + X_ch, X_pr, X_li, X_su, X_aa, X_fa, X_c4, X_pro, X_ac, X_h2, X_I, \ + X_PHA, X_PP, X_PAO, S_K, S_Mg, X_MeOH, X_MeP, S_cat, S_an, H2O = adm_vals + + # Step 0: snapshot of charged components + # Not sure about charge on X_PP, S_Mg, S_K (PHA and PAO would have zero charge) + # Step 0: snapshot of charged components + # _ions = np.array([S_IN, S_IC, S_ac, S_pro, S_bu, S_va]) + _ions = np.array([S_IN, S_IC, S_IP, X_PP, S_Mg, S_K, S_ac, S_pro, S_bu, S_va]) + + # Step 1a: convert biomass and inert particulates into X_S and X_I + + # What is available + bio_cod = X_su + X_aa + X_fa + X_c4 + X_pro + X_ac + X_h2 + bio_n = sum((adm_vals*adm_i_N)[adm_bio_N_indices]) + bio_p = sum((adm_vals*adm_i_P)[adm_bio_P_indices]) + + + #!!! In default ASM2d stoichiometry, biomass decay (cell lysis) + #!!! yields 90% particulate substrate + 10% X_I + #!!! so: convert both biomass and X_I in adm to X_S and X_I in asm + + # ----------------------------Rai version--------------------------------------------- + # What is available + # xi_n = X_I*adm_X_I_i_N + # xi_p = X_I*adm_X_I_i_P + # ----------------------------Rai version--------------------------------------------- + + # What would be formed by X_S + xs_cod = bio_cod * self.bio_to_xs + xs_ndm = xs_cod * X_S_i_N + xs_pdm = xs_cod * X_S_i_P + + # What would be formed by X_I (ASM2d) + xi_cod = bio_cod * (1 - self.bio_to_xs) + X_I + + # ----------------------------Rai version--------------------------------------------- + # xi_ndm = xi_cod * asm_X_I_i_N + # xi_pdm = xi_cod * asm_X_I_i_P + # ----------------------------Rai version--------------------------------------------- + + # MAPPING OF X_S + + # Case I: Both bio_N and bio_P are sufficient + if xs_ndm <= bio_n and xs_pdm <= bio_p: + X_S = xs_cod + xs_cod = 0 + bio_n -= xs_ndm + bio_p -= xs_pdm + else: + # Case II, III, and, IV: At least one of the two biological N/P is not sufficient + if bio_p / X_S_i_P > bio_n / X_S_i_N: + warn('Not enough biomass N to map the specified proportion of ' + 'biomass COD into X_S. Rest of the biomass COD goes to S_A in last step') + X_S = bio_n / X_S_i_N + xs_cod -= X_S + bio_n = 0 + bio_p -= X_S*X_S_i_P #mathematically, bio_p can become negative at this point + if bio_p < 0: + S_IP += bio_p + bio_p = 0 + else: + warn('Not enough biomass P to map the specified proportion of ' + 'biomass COD into X_S. Rest of the biomass COD goes to S_A in last step') + X_S = bio_p / X_S_i_P + xs_cod -= X_S + bio_p = 0 + bio_n -= X_S*X_S_i_N #mathematically, bio_n can become negative at this point + if bio_n < 0: + S_IN += bio_n + bio_n = 0 + + # Step 2: MAPPING OF X_I + + # Flores Alsina + + if asm_X_I_i_N == adm_X_I_i_N and asm_X_I_i_P == adm_X_I_i_P: + X_I = xi_cod + excess_N = (bio_cod * (1 - self.bio_to_xs))*asm_X_I_i_N + excess_P = (bio_cod * (1 - self.bio_to_xs))*asm_X_I_i_P + else: + raise RuntimeError('N and P content in X_I should be same') + + # ----------------------------Rai version--------------------------------------------- + # if xi_ndm < bio_n + xi_n + S_IN and xi_pdm < bio_p + xi_p + S_IP: + + # X_I = xi_cod + # xi_cod = 0 + + # xi_n -= xi_ndm + # if xi_n < 0: + # bio_n += xi_n + # xi_n = 0 + # if bio_n < 0: + # S_IN += bio_n + # bio_n = 0 + + # xi_p -= xi_pdm + # if xi_p < 0: + # bio_p += xi_p + # xi_p = 0 + # if bio_p < 0: + # S_IP += bio_p + # bio_p = 0 + + # else: + # if (bio_p + xi_p + S_IP) / asm_X_I_i_P > (bio_n + xi_n + S_IN) / asm_X_I_i_N: + + # warn('Not enough N in biomass and X_I to map the specified proportion of' + # 'biomass COD into X_I. Rest of the biomass COD goes to S_A') + # X_I = (bio_n + xi_n + S_IN) / asm_X_I_i_N + # xi_cod -= X_I + + # bio_n = xi_n = S_IN = 0 + + # xi_p -= X_I*asm_X_I_i_P + # if xi_p < 0: + # bio_p += xi_p + # xi_p = 0 + # if bio_p < 0: + # S_IP += bio_p + # bio_p = 0 + + # else: + + # warn('Not enough P in biomass and X_I to map the specified proportion of' + # 'biomass COD into X_I. Rest of the biomass COD goes to S_A') + # X_I = (bio_p + xi_p + S_IP) / asm_X_I_i_P + # xi_cod -= X_I + + # bio_p = xi_p = S_IP = 0 + + # xi_n -= X_I*asm_X_I_i_N + # if xi_n < 0: + # bio_n += xi_n + # xi_n = 0 + # if bio_n < 0: + # S_IN += bio_n + # bio_n = 0 + #------------------------------Rai version--------------------------------------------- + + # Step 1(b) + + xsub_cod = X_ch + X_pr + X_li + xsub_n = X_pr*X_pr_i_N + xsub_p = X_pr*X_pr_i_P + + xs_ndm = xsub_cod * X_S_i_N + xs_pdm = xsub_cod * X_S_i_P + + if xs_ndm <= xsub_n + bio_n and xs_pdm <= xsub_p + bio_p: + X_S += xsub_cod + xsub_cod = 0 + xsub_n -= xs_ndm + xsub_p -= xs_pdm + else: + if (xsub_n + bio_n)/X_S_i_N < (xsub_p + bio_p)/X_S_i_P: + + X_S_temp = (xsub_n + bio_n)/X_S_i_N + X_S += X_S_temp + xsub_cod -= X_S_temp + xsub_n = bio_n = 0 + + xsub_p -= X_S_temp*X_S_i_P + if xsub_p < 0: + bio_p += xsub_p + xsub_p = 0 + + else: + X_S_temp = (xsub_p + bio_p)/X_S_i_P + X_S += X_S_temp + xsub_cod -= X_S_temp + xsub_p = bio_p = 0 + + xsub_n -= X_S_temp*X_S_i_N + if xsub_n < 0: + bio_n += xsub_n + xsub_n = 0 + + # Step 3(A) + + # P balance not required as S_su, S_aa, S_fa do not have P + ssub_cod = S_su + S_aa + S_fa + ssub_n = S_aa * S_aa_i_N + ssub_p = S_aa * S_aa_i_P # which would be 0 + + sf_ndm = ssub_cod * S_F_i_N + sf_pdm = ssub_cod * S_F_i_P + + if sf_ndm <= ssub_n + xsub_n + bio_n and sf_pdm <= ssub_p + xsub_p + bio_p: + + S_F = ssub_cod + ssub_cod = 0 + + ssub_n -= sf_ndm + if ssub_n < 0: + xsub_n += ssub_n + ssub_n = 0 + if xsub_n < 0: + bio_n += xsub_n + xsub_n = 0 + + ssub_p -= sf_pdm + if ssub_p < 0: + xsub_p += ssub_p + ssub_p = 0 + if xsub_p < 0: + bio_p += xsub_p + xsub_p = 0 + + else: + if (ssub_n + xsub_n + bio_n) / S_F_i_N < (ssub_p + xsub_p + bio_p) / S_F_i_P: + + S_F = (ssub_n + xsub_n + bio_n) / S_F_i_N + ssub_cod -= S_F + ssub_n = xsub_n = bio_n = 0 + + ssub_p -= S_F*S_F_i_P + + if ssub_p < 0: + xsub_p += ssub_p + ssub_p = 0 + if xsub_p < 0: + bio_p += xsub_p + xsub_p = 0 + + else: + + S_F = (ssub_p + xsub_p + bio_p) / S_F_i_P + ssub_cod -= S_F + ssub_p = xsub_p = bio_p = 0 + + ssub_n -= S_F*S_F_i_N + + if ssub_n < 0: + xsub_n += ssub_n + ssub_n = 0 + if xsub_n < 0: + bio_n += xsub_n + xsub_n = 0 + + # N and P balance not required as S_ac, S_pro, S_bu, and S_va do not have N and P + S_A = S_ac + S_pro + S_bu + S_va + + # Step 4 + + if asm_S_I_i_N == adm_S_I_i_N and asm_S_I_i_P == adm_S_I_i_P: + S_I = S_I + else: + raise RuntimeError('N and P content in S_I should be same') + + #------------------------------Rai version--------------------------------------------- + # si_n = S_I * adm_S_I_i_N + # si_p = S_I * adm_S_I_i_P + + # si_ndm = si_cod * asm_S_I_i_N + # si_pdm = si_cod * asm_S_I_i_P + + # if si_ndm <= si_n + xi_n + S_IN and si_pdm <= si_p + xi_p + S_IP: + # S_I = si_cod + # si_cod = 0 + # si_n -= si_ndm + # if si_n < 0: + # xi_n += si_n + # si_n = 0 + # if xi_n < 0: + # S_IN += xi_n + # xi_n = 0 + # si_p -= si_pdm + # if si_p < 0: + # xi_p += si_p + # si_p = 0 + # if xi_p < 0: + # S_IP += xi_p + # xi_p = 0 + # else: + # if (si_n + xi_n + S_IN) / asm_S_I_i_N < (si_p + xi_p + S_IP) / asm_S_I_i_P: + # S_I = (si_n + xi_n + S_IN) / asm_S_I_i_N + # si_cod -= S_I + # si_n = xi_n = S_IN = 0 + # si_p -= S_I * asm_S_I_i_P + # if si_p < 0: + # xi_p += si_p + # si_p = 0 + # if xi_p < 0: + # S_IP += xi_p + # xi_p = 0 + # else: + # S_I = (si_p + xi_p + S_IP) / asm_S_I_i_P + # si_cod -= S_I + # si_p = xi_p = S_IP = 0 + # si_n -= S_I * asm_S_I_i_N + # if si_n < 0: + # xi_n += si_n + # si_n = 0 + # if xi_n < 0: + # S_IN += xi_n + # xi_n = 0 + #------------------------------Rai version--------------------------------------------- + + #------------------------------Rai version--------------------------------------------- + # S_NH4 = S_IN + si_n + ssub_n + xsub_n + xi_n + bio_n + # S_PO4 = S_IP + si_p + ssub_p + xsub_p + xi_p + bio_p + #------------------------------Rai version--------------------------------------------- + + S_NH4 = S_IN + ssub_n + xsub_n + bio_n - excess_N + S_PO4 = S_IP + ssub_p + xsub_p + bio_p - excess_P + + #------------------------------Rai version--------------------------------------------- + # S_A += si_cod + ssub_cod + xsub_cod + xi_cod + xs_cod + #------------------------------Rai version--------------------------------------------- + + S_A += ssub_cod + xsub_cod + xs_cod + + # Step 6: check COD and TKN balance + asm_vals = np.array(([ + 0, 0, # S_O2, S_N2, + S_NH4, + 0, # S_NO3 + S_PO4, S_F, S_A, S_I, + 0, # S_ALK(for now) + X_I, X_S, + 0, # X_H, + X_PAO, X_PP, X_PHA, # directly mapped + 0, # X_AUT, + X_MeOH, X_MeP, H2O])) # directly mapped + + if S_h2 > 0 or S_ch4 > 0: + warn('Ignored dissolved H2 or CH4.') + + asm_vals = f_corr(adm_vals, asm_vals) + + # Step 5: charge balance for alkalinity + + # asm_ions_idx = cmps_asm.indices(('S_NH4', 'S_A', 'S_NO3', 'S_PO4', 'X_PP', 'S_ALK')) + + S_NH4 = asm_vals[asm_ions_idx[0]] + S_A = asm_vals[asm_ions_idx[1]] + S_NO3 = asm_vals[asm_ions_idx[2]] + S_PO4 = asm_vals[asm_ions_idx[3]] + X_PP = asm_vals[asm_ions_idx[4]] + + # Need to include S_K, S_Mg in the charge balance + + # _ions = np.array([S_IN, S_IC, S_ac, S_pro, S_bu, S_va]) + # S_ALK = (sum(_ions * np.append([alpha_IN, alpha_IC], alpha_vfa)) - S_NH/14)*(-12) + + # _ions = np.array([S_IN, S_IC, S_IP, X_PP, S_Mg, S_K, S_ac, S_pro, S_bu, S_va]) + + S_ALK = (sum(_ions * np.append([alpha_IN, alpha_IC, alpha_IP, -1/31, 2, 1], alpha_vfa)) - (S_NH4/14 - S_A/64 - S_NO3/14 -1.5*S_PO4/31 - X_PP/31))*(-12) + + asm_vals[asm_ions_idx[5]] = S_ALK + + return asm_vals + + self._reactions = madm12asm2d + + @property + def alpha_vfa(self): + return 1.0/self.cod_vfa*(-1.0/(1.0 + 10**(self.pKa[4:]-self.pH))) + +# %% + +# While using this interface X_I.i_N in ASM2d should be 0.06, instead of 0.02. +class ASM2dtomADM1(mADMjunction): + ''' + Interface unit to convert activated sludge model (ASM) components + to anaerobic digestion model (ADM) components. + + Parameters + ---------- + upstream : stream or str + Influent stream with ASM components. + downstream : stream or str + Effluent stream with ADM components. + adm1_model : obj + The anaerobic digestion process model (:class:`qsdsan.processes.ADM1_p_extension`). + xs_to_li : float + Split of slowly biodegradable substrate COD to lipid, + after all N is mapped into protein. + bio_to_li : float + Split of biomass COD to lipid, after all biomass N is + mapped into protein. + frac_deg : float + Biodegradable fraction of biomass COD. + rtol : float + Relative tolerance for COD and TKN balance. + atol : float + Absolute tolerance for COD and TKN balance. + + References + ---------- + [1] Nopens, I.; Batstone, D. J.; Copp, J. B.; Jeppsson, U.; Volcke, E.; + Alex, J.; Vanrolleghem, P. A. An ASM/ADM Model Interface for Dynamic + Plant-Wide Simulation. Water Res. 2009, 43, 1913–1923. + + [2] Flores-Alsina, X., Solon, K., Kazadi Mbamba, C., Tait, S., Gernaey, K. V., + Jeppsson, U., & Batstone, D. J. (2016). Modelling phosphorus (P), sulfur (S) + and iron (FE) interactions for dynamic simulations of anaerobic digestion processes. + Water Research, 95, 370–382. + + See Also + -------- + :class:`qsdsan.sanunits.ADMjunction` + + :class:`qsdsan.sanunits.ADMtoASM` + + `math.isclose ` + ''' + # User defined values + xs_to_li = 0.7 + bio_to_li = 0.4 + frac_deg = 0.68 + + # Since we are matching PAOs, X_I, S_I, directly from ASM2d to mADM1, it is important + # for PAOs to have identical N/P content across models + + adm_X_PAO_i_N = 0.07 + adm_X_PAO_i_P = 0.02 + + asm_X_I_i_N = 0.0600327162 + asm_X_I_i_P = 0.01 + asm_S_I_i_N = 0.06 + asm_S_I_i_P = 0.01 + + def isbalanced(self, lhs, rhs_vals, rhs_i): + rhs = sum(rhs_vals*rhs_i) + error = rhs - lhs + tol = max(self.rtol*lhs, self.rtol*rhs, self.atol) + return abs(error) <= tol, error, tol, rhs + + def balance_cod_tkn_tp(self, asm_vals, adm_vals): + cmps_asm = self.ins[0].components + cmps_adm = self.outs[0].components + asm_i_COD = cmps_asm.i_COD + adm_i_COD = cmps_adm.i_COD + non_tkn_idx = cmps_asm.indices(('S_N2', 'S_NO3')) + asm_i_N = cmps_asm.i_N + + adm_i_N = cmps_adm.i_N + asm_i_P = cmps_asm.i_P + adm_i_P = cmps_adm.i_P + asm_cod = sum(asm_vals*asm_i_COD) + asm_tkn = sum(asm_vals*asm_i_N) - sum(asm_vals[non_tkn_idx]) + asm_tp = sum(asm_vals*asm_i_P) + cod_bl, cod_err, cod_tol, adm_cod = self.isbalanced(asm_cod, adm_vals, adm_i_COD) + tkn_bl, tkn_err, tkn_tol, adm_tkn = self.isbalanced(asm_tkn, adm_vals, adm_i_N) + tp_bl, tp_err, tp_tol, adm_tp = self.isbalanced(asm_tp, adm_vals, adm_i_P) + + if tkn_bl and tp_bl: + if cod_bl: + return adm_vals + else: + if cod_err > 0: dcod = -(cod_err - cod_tol)/adm_cod + else: dcod = -(cod_err + cod_tol)/adm_cod + _adm_vals = adm_vals * (1 + (adm_i_COD>0)*dcod) + _tkn_bl, _tkn_err, _tkn_tol, _adm_tkn = self.isbalanced(asm_tkn, _adm_vals, adm_i_N) + _tp_bl, _tp_err, _tp_tol, _adm_tp = self.isbalanced(asm_tp, _adm_vals, adm_i_P) + if _tkn_bl and _tp_bl: return _adm_vals + else: + warn('cannot balance COD, TKN, and TP at the same \n' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ASM) TKN is {asm_tkn}\n ' + f'effluent (ADM) TKN is {adm_tkn} or {_adm_tkn}\n ' + f'influent TP is {asm_tp}\n ' + f'effluent TP is {adm_tp} or {_adm_tp}. ' + f'influent COD is {asm_cod}\n ' + f'effluent COD is {adm_cod} or {adm_cod*(1+dcod)}. ') + return adm_vals + elif cod_bl and tp_bl: + if tkn_bl: + return adm_vals + else: + if tkn_err > 0: dtkn = -(tkn_err - tkn_tol)/adm_tkn + else: dtkn = -(tkn_err + tkn_tol)/adm_tkn + _adm_vals = adm_vals * (1 + (adm_i_N>0)*dtkn) + _cod_bl, _cod_err, _cod_tol, _adm_cod = self.isbalanced(asm_cod, _adm_vals, adm_i_COD) + _tp_bl, _tp_err, _tp_tol, _adm_tp = self.isbalanced(asm_tp, _adm_vals, adm_i_P) + if _cod_bl and _tp_bl: return _adm_vals + else: + warn('cannot balance COD, TKN, and TP at the same time' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ASM) COD is {asm_cod}\n ' + f'effluent (ADM) COD is {adm_cod} or {_adm_cod}\n ' + f'influent TP is {asm_tp}\n ' + f'effluent TP is {adm_tp} or {_adm_tp}. ' + f'influent TKN is {asm_tkn}\n ' + f'effluent TKN is {adm_tkn} or {adm_tkn*(1+dtkn)}. ' + 'To balance TKN please ensure ASM2d(X_I.i_N) = ADM1(X_I.i_N)') + return adm_vals + elif cod_bl and tkn_bl: + if tp_bl: + return adm_vals + else: + if tp_err > 0: dtp = -(tp_err - tp_tol)/adm_tp + else: dtp = -(tp_err + tp_tol)/adm_tp + _adm_vals = adm_vals * (1 + (adm_i_P>0)*dtp) + _cod_bl, _cod_err, _cod_tol, _adm_cod = self.isbalanced(asm_cod, _adm_vals, adm_i_COD) + _tkn_bl, _tkn_err, _tkn_tol, _adm_tkn = self.isbalanced(asm_tkn, _adm_vals, adm_i_N) + if _cod_bl and _tkn_bl: return _adm_vals + else: + warn('cannot balance COD, TKN, and TP at the same time' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ASM) COD is {asm_cod}\n ' + f'effluent (ADM) COD is {adm_cod} or {_adm_cod}\n ' + f'influent TKN is {asm_tkn}\n ' + f'effluent TKN is {adm_tkn} or {_adm_tkn}. ' + f'influent TP is {asm_tp}\n ' + f'effluent TP is {adm_tp} or {adm_tp*(1+dtp)}. ') + return adm_vals + else: + warn('cannot balance COD, TKN and TP at the same time. \n' + 'Atleast two of the three COD, TKN, and TP are not balanced \n' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ASM) COD is {asm_cod}\n ' + f'effluent (ADM) COD is {adm_cod}\n ' + f'influent TP is {asm_tp}\n ' + f'effluent TP is {adm_tp}' + f'influent TKN is {asm_tkn}\n ' + f'effluent TKN is {adm_tkn}. ') + return adm_vals + + def _compile_reactions(self): + # Retrieve constants + ins = self.ins[0] + outs = self.outs[0] + rtol = self.rtol + atol = self.atol + + cmps_asm = ins.components + cmps_adm = outs.components + + # For COD balance + S_NO3_i_COD = cmps_asm.S_NO3.i_COD + + # For N balance + X_H_i_N = cmps_asm.X_H.i_N + X_AUT_i_N = cmps_asm.X_AUT.i_N + S_F_i_N = cmps_asm.S_F.i_N + X_S_i_N = cmps_asm.X_S.i_N + S_A_i_N = cmps_asm.S_A.i_N + + # Due to issue with mapping of X_I across ASM2d and ADM1, making this user dependent is important + + # PUT A CHECK ON USER DEFINED N AND P CONTENT IN SI and XI here. + if self.asm_X_I_i_N == None: + asm_X_I_i_N = cmps_asm.X_I.i_N + else: + asm_X_I_i_N = self.asm_X_I_i_N + + if self.asm_X_I_i_P == None: + asm_X_I_i_P = cmps_asm.X_I.i_P + else: + asm_X_I_i_P = self.asm_X_I_i_P + + if self.asm_S_I_i_N == None: + asm_S_I_i_N = cmps_asm.S_I.i_N + else: + asm_S_I_i_N = self.asm_X_I_i_N + + if self.asm_S_I_i_P == None: + asm_S_I_i_P = cmps_asm.S_I.i_P + else: + asm_S_I_i_P = self.asm_S_I_i_P + + # For P balance + X_H_i_P = cmps_asm.X_H.i_P + X_AUT_i_P = cmps_asm.X_AUT.i_P + S_F_i_P = cmps_asm.S_F.i_P + X_S_i_P = cmps_asm.X_S.i_P + S_A_i_P = cmps_asm.S_A.i_P + + if cmps_asm.S_A.i_N > 0: + warn(f'S_A in ASM has positive nitrogen content: {cmps_asm.S_S.i_N} gN/gCOD. ' + 'These nitrogen will be ignored by the interface model ' + 'and could lead to imbalance of TKN after conversion.') + + if cmps_asm.S_A.i_P > 0: + warn(f'S_A in ASM has positive phosphorous content: {cmps_asm.S_S.i_P} gN/gCOD. ' + 'These phosphorous will be ignored by the interface model ' + 'and could lead to imbalance of TP after conversion.') + + # ------------------------------Rai Version--------------------------------------------- + # if cmps_asm.S_I.i_P > 0: + # warn(f'S_I in ASM has positive phosphorous content: {cmps_asm.S_I.i_P} gN/gCOD. ' + # 'These phosphorous will be ignored by the interface model ' + # 'and could lead to imbalance of TP after conversion.') + # ------------------------------Rai Version--------------------------------------------- + + # We do not need to check if X_S.i_N != 0 since we take care of it using X_ND_asm1 + # We do not need to check if S_F.i_N != 0 since we take care of it using S_ND_asm1 + + # For nitrogen balance + S_ac_i_N = cmps_adm.S_ac.i_N + S_aa_i_N = cmps_adm.S_aa.i_N + X_pr_i_N = cmps_adm.X_pr.i_N + adm_S_I_i_N = cmps_adm.S_I.i_N + adm_X_I_i_N = cmps_adm.X_I.i_N + + # For phosphorous balance + S_ac_i_P = cmps_adm.S_ac.i_P + X_pr_i_P = cmps_adm.X_pr.i_P + adm_S_I_i_P = cmps_adm.S_I.i_P + adm_X_I_i_P = cmps_adm.X_I.i_P + + # Checks for direct mapping of X_PAO, X_PP, X_PHA + + # Check for X_PAO (measured as COD so i_COD = 1 in both ASM2d and ADM1) + asm_X_PAO_i_N = cmps_asm.X_PAO.i_N + + if self.adm_X_PAO_i_N == None: + adm_X_PAO_i_N = cmps_adm.X_PAO.i_N + else: + adm_X_PAO_i_N = self.adm_X_PAO_i_N + + if asm_X_PAO_i_N != adm_X_PAO_i_N: + raise RuntimeError('X_PAO cannot be directly mapped as N content' + f'in asm2d_X_PAO_i_N = {asm_X_PAO_i_N} is not equal to' + f'adm_X_PAO_i_N = {adm_X_PAO_i_N}') + + asm_X_PAO_i_P = cmps_asm.X_PAO.i_P + + if self.adm_X_PAO_i_P == None: + adm_X_PAO_i_P = cmps_adm.X_PAO.i_P + else: + adm_X_PAO_i_P = self.adm_X_PAO_i_P + + if asm_X_PAO_i_P != adm_X_PAO_i_P: + raise RuntimeError('X_PAO cannot be directly mapped as P content' + f'in asm2d_X_PAO_i_P = {asm_X_PAO_i_P} is not equal to' + f'adm_X_PAO_i_P = {adm_X_PAO_i_P}') + + # Checks not required for X_PP as measured as P in both, with i_COD = i_N = 0 + # Checks not required for X_PHA as measured as COD in both, with i_N = i_P = 0 + + if asm_X_I_i_N != adm_X_I_i_N: + raise RuntimeError('X_I cannot be directly mapped as N content' + f'in asm2d_X_I_i_N = {asm_X_I_i_N} is not equal to' + f'adm_X_I_i_N = {adm_X_I_i_N}') + + if asm_X_I_i_P != adm_X_I_i_P: + raise RuntimeError('X_I cannot be directly mapped as P content' + f'in asm2d_X_I_i_P = {asm_X_I_i_P} is not equal to' + f'adm_X_I_i_P = {adm_X_I_i_P}') + + if asm_S_I_i_N != adm_X_I_i_N: + raise RuntimeError('S_I cannot be directly mapped as N content' + f'in asm2d_S_I_i_N = {asm_S_I_i_N} is not equal to' + f'adm_S_I_i_N = {adm_S_I_i_N}') + + if asm_S_I_i_P != adm_S_I_i_P: + raise RuntimeError('S_I cannot be directly mapped as P content' + f'in asm2d_S_I_i_P = {asm_S_I_i_P} is not equal to \n' + f'adm_S_I_i_P = {adm_S_I_i_P}') + + adm_ions_idx = cmps_adm.indices(['S_IN', 'S_IP', 'S_IC', 'S_cat', 'S_an']) + + frac_deg = self.frac_deg + alpha_IP = self.alpha_IP + alpha_IN = self.alpha_IN + alpha_IC = self.alpha_IC + proton_charge = 10**(-self.pKa[0]+self.pH) - 10**(-self.pH) # self.pKa[0] is pKw + f_corr = self.balance_cod_tkn_tp + + # To convert components from ASM2d to mADM1 (asm2d-2-madm1) + def asm2d2madm1(asm_vals): + # S_I, S_S, X_I, X_S, X_BH, X_BA, X_P, S_O, S_NO, S_NH, S_ND, X_ND, S_ALK, S_N2, H2O = asm_vals + + S_O2, S_N2, S_NH4, S_NO3, S_PO4, S_F, S_A, S_I, S_ALK, X_I, X_S, X_H, \ + X_PAO, X_PP, X_PHA, X_AUT, X_MeOH, X_MeP, H2O = asm_vals + + # Step 0: charged component snapshot (# pg. 84 of IWA ASM textbook) + _sno3 = S_NO3 + _snh4 = S_NH4 + _salk = S_ALK + _spo4 = S_PO4 + _sa = S_A + _xpp = X_PP + + # Step 1: remove any remaining COD demand + O2_coddm = S_O2 + NO3_coddm = -S_NO3*S_NO3_i_COD + + # cod_spl = S_S + X_S + X_BH + X_BA + # Replacing S_S with S_F + S_A (IWA ASM textbook) + + cod_spl = (S_A + S_F) + X_S + (X_H + X_AUT) + + # bioN = X_BH*X_BH_i_N + X_BA*X_BA_i_N + + bioN = X_H*X_H_i_N + X_AUT*X_AUT_i_N + bioP = X_H*X_H_i_P + X_AUT*X_AUT_i_P + + # To be used in Step 2 + S_ND_asm1 = S_F*S_F_i_N #S_ND (in asm1) equals the N content in S_F + # To be used in Step 3 + X_ND_asm1 = X_S*X_S_i_N #X_ND (in asm1) equals the N content in X_S + # To be used in Step 5 (a) + X_S_P = X_S*X_S_i_P + # To be used in Step 5 (b) + S_F_P = S_F*S_F_i_P + + if cod_spl <= O2_coddm: + S_O2 = O2_coddm - cod_spl + S_F = S_A = X_S = X_H = X_AUT = 0 + elif cod_spl <= O2_coddm + NO3_coddm: + S_O2 = 0 + S_NO3 = -(O2_coddm + NO3_coddm - cod_spl)/S_NO3_i_COD + S_A = S_F = X_S = X_H = X_AUT = 0 + else: + S_A -= O2_coddm + NO3_coddm + if S_A < 0: + S_F += S_A + S_A = 0 + if S_F < 0: + X_S += S_F + S_F = 0 + if X_S < 0: + X_H += X_S + X_S = 0 + if X_H < 0: + X_AUT += X_H + X_H = 0 + S_O2 = S_NO3 = 0 + + # Step 2: convert any readily biodegradable + # COD and TKN into amino acids and sugars + + # Directly map acetate + if S_ac_i_N == S_A_i_N and S_ac_i_P == S_A_i_P: + S_ac = S_A + else: + raise RuntimeError('N and P content should be the same in S_A and S_ac for direct translation') + + # First we calculate the amount of amino acid required in ADM1 + # if all available soluble organic N can be mapped to amino acid + req_scod = S_ND_asm1 / S_aa_i_N + + # if available S_F is not enough to fulfill that amino acid requirement + if S_F < req_scod: + # then all available S_F is mapped to amino acids + S_aa = S_F + # and no S_S would be available for conversion to sugars + S_su = 0 + # This needs to be followed by a corresponding loss in soluble organic N + S_ND_asm1 -= S_aa * S_aa_i_N + # if available S_F is more than enough to fulfill that amino acid requirement + else: + # All soluble organic N will be mapped to amino acid + S_aa = req_scod + # The line above implies that a certain portion of S_S would also be consumed to form amino acid + # The S_S which is left would form sugar + # In simpler terms; S_F = S_F - S_aa; S_su = S_F + S_su = S_F - S_aa + # All soluble organic N would thus be consumed in amino acid formation + S_ND_asm1 = 0 + + # Step 3: convert slowly biodegradable COD and TKN + # into proteins, lipids, and carbohydrates + + # First we calculate the amount of protein required in ADM1 + # if all available particulate organic N can be mapped to amino acid + req_xcod = X_ND_asm1 / X_pr_i_N + # Since X_pr_i_N >> X_pr_i_P there's no need to check req_xcod for N and P separately (CONFIRM LATER 05/16) + + # if available X_S is not enough to fulfill that protein requirement + if X_S < req_xcod: + # then all available X_S is mapped to amino acids + X_pr = X_S + # and no X_S would be available for conversion to lipid or carbohydrates + X_li = X_ch = 0 + # This needs to be followed by a corresponding loss in particulate organic N + X_ND_asm1 -= X_pr * X_pr_i_N + + # For P balance + # This needs to be followed by a corresponding loss in particulate organic P + X_S_P -= X_pr * X_pr_i_P + + # if available X_S is more than enough to fulfill that protein requirement + else: + # All particulate organic N will be mapped to amino acid + X_pr = req_xcod + # The line above implies that a certain portion of X_S would also be consumed to form protein + # The X_S which is left would form lipid and carbohydrates in a percentage define by the user + X_li = self.xs_to_li * (X_S - X_pr) + X_ch = (X_S - X_pr) - X_li + # All particulate organic N would thus be consumed in amino acid formation + X_ND_asm1 = 0 + + # For P balance + # This needs to be followed by a corresponding loss in particulate organic P + X_S_P -= X_pr * X_pr_i_P + + # Step 4: convert active biomass into protein, lipids, + # carbohydrates and potentially particulate TKN + + # First the amount of biomass N/P available for protein, lipid etc is determined + # For this calculation, from total biomass N available the amount + # of particulate inert N/P expected in ADM1 is subtracted + + available_bioN = bioN - (X_H + X_AUT) * (1-frac_deg) * adm_X_I_i_N + if available_bioN < 0: + raise RuntimeError('Not enough N in X_H and X_AUT to fully convert ' + 'the non-biodegradable portion into X_I in ADM1.') + + available_bioP = bioP - (X_H + X_AUT) * (1-frac_deg) * adm_X_I_i_P + if available_bioP < 0: + raise RuntimeError('Not enough P in X_H and X_AUT to fully convert ' + 'the non-biodegradable portion into X_I in ADM1.') + + # Then the amount of biomass N/P required for biomass conversion to protein is determined + req_bioN = (X_H + X_AUT) * frac_deg * X_pr_i_N + req_bioP = (X_H + X_AUT) * frac_deg * X_pr_i_P + + # Case I: if both available biomass N/P and particulate organic N/P is greater than + # required biomass N/P for conversion to protein + if available_bioN + X_ND_asm1 >= req_bioN and available_bioP + X_S_P >= req_bioP: + # then all biodegradable biomass N/P (corrsponding to protein demand) is converted to protein + X_pr += (X_H + X_AUT) * frac_deg + # the remaining biomass N/P is transfered as organic N/P + X_ND_asm1 += available_bioN - req_bioN + X_S_P += available_bioP - req_bioP + + # Case II: if available biomass N and particulate organic N is less than + # required biomass N for conversion to protein, but available biomass P and + # particulate organic P is greater than required biomass P for conversion to protein + + # Case III: if available biomass P and particulate organic P is less than + # required biomass P for conversion to protein, but available biomass N and + # particulate organic N is greater than required biomass N for conversion to protein + + # Case IV: if both available biomass N/P and particulate organic N/P is less than + # required biomass N/P for conversion to protein + else: + + if (available_bioP + X_S_P)/X_pr_i_P < (available_bioN + X_ND_asm1)/X_pr_i_N: + # all available P and particulate organic P is converted to protein + bio2pr = (available_bioP + X_S_P)/X_pr_i_P + X_pr += bio2pr + # Biodegradable biomass available after conversion to protein is calculated + bio_to_split = (X_H + X_AUT) * frac_deg - bio2pr + # Part of the remaining biomass is mapped to lipid based on user defined value + bio_split_to_li = bio_to_split * self.bio_to_li + X_li += bio_split_to_li + # The other portion of the remanining biomass is mapped to carbohydrates + X_ch += (bio_to_split - bio_split_to_li) + # Since all organic P has been mapped to protein, none is left + X_S_P = 0 + + # the remaining biomass N is transfered as organic N + X_ND_asm1 += available_bioN - (bio2pr*X_pr_i_N) + + else: + # all available N and particulate organic N is converted to protein + bio2pr = (available_bioN + X_ND_asm1)/X_pr_i_N + X_pr += bio2pr + # Biodegradable biomass available after conversion to protein is calculated + bio_to_split = (X_H + X_AUT) * frac_deg - bio2pr + # Part of the remaining biomass is mapped to lipid based on user defined value + bio_split_to_li = bio_to_split * self.bio_to_li + X_li += bio_split_to_li + # The other portion of the remanining biomass is mapped to carbohydrates + X_ch += (bio_to_split - bio_split_to_li) + # Since all organic N has been mapped to protein, none is left + X_ND_asm1 = 0 + + # the remaining biomass P is transfered as organic P + X_S_P += available_bioP - (bio2pr*X_pr_i_P) + + + # Step 5: map particulate inerts + + # 5 (a) + + # --------------------------Rai version-------------------------------------- + # # First determine the amount of particulate inert N/P available from ASM2d + # xi_nsp_asm2d = X_I * asm_X_I_i_N + # xi_psp_asm2d = X_I * asm_X_I_i_P + + # # Then determine the amount of particulate inert N/P that could be produced + # # in ADM1 given the ASM1 X_I + # xi_ndm = X_I * adm_X_I_i_N + # xi_pdm = X_I * adm_X_I_i_P + + # # if particulate inert N available in ASM1 is greater than ADM1 demand + # if xi_nsp_asm2d + X_ND_asm1 >= xi_ndm: + # deficit = xi_ndm - xi_nsp_asm2d + # # COD balance + # X_I += (X_H+X_AUT) * (1-frac_deg) + # # N balance + # X_ND_asm1 -= deficit + # # P balance + # if xi_psp_asm2d + X_S_P >= xi_pdm: + # deficit = xi_pdm - xi_psp_asm2d + # X_S_P -= deficit + # elif isclose(xi_psp_asm2d+X_S_P, xi_pdm, rel_tol=rtol, abs_tol=atol): + # X_S_P = 0 + # else: + # raise RuntimeError('Not enough P in X_I, X_S to fully ' + # 'convert X_I in ASM2d into X_I in ADM1.') + # elif isclose(xi_nsp_asm2d+X_ND_asm1, xi_ndm, rel_tol=rtol, abs_tol=atol): + # # COD balance + # X_I += (X_H+X_AUT) * (1-frac_deg) + # # N balance + # X_ND_asm1 = 0 + # # P balance + # if xi_psp_asm2d + X_S_P >= xi_pdm: + # deficit = xi_pdm - xi_psp_asm2d + # X_S_P -= deficit + # elif isclose(xi_psp_asm2d+X_S_P, xi_pdm, rel_tol=rtol, abs_tol=atol): + # X_S_P = 0 + # else: + # raise RuntimeError('Not enough P in X_I, X_S to fully ' + # 'convert X_I in ASM2d into X_I in ADM1.') + # else: + # # Since the N balance cannot hold, the P balance is not futher checked + # raise RuntimeError('Not enough N in X_I, X_S to fully ' + # 'convert X_I in ASM2d into X_I in ADM1.') + # --------------------------Rai version-------------------------------------- + + # --------------------------Flores Alsina et al. 2016 version-------------------------------------- + # X_I = X_I + # COD balance + X_I += (X_H+X_AUT) * (1-frac_deg) + # --------------------------Flores Alsina et al. 2016 version-------------------------------------- + + # # 5(b) + + # --------------------------Rai version-------------------------------------- + # # Then determine the amount of soluble inert N/P that could be produced + # # in ADM1 given the ASM1 X_I + # req_sn = S_I * adm_S_I_i_N + # req_sp = S_I * adm_S_I_i_P + + # supply_inert_n_asm2d = S_I * asm_S_I_i_N + + # # N balance + # if req_sn <= S_ND_asm1 + supply_inert_n_asm2d: + # S_ND_asm1 -= (req_sn - supply_inert_n_asm2d) + # supply_inert_n_asm2d = 0 + # # P balance + # if req_sp <= S_F_P: + # S_F_P -= req_sp + # elif req_sp <= S_F_P + X_S_P: + # X_S_P -= (req_sp - S_F_P) + # S_F_P = 0 + # elif req_sp <= S_F_P + X_S_P + S_PO4: + # S_PO4 -= (req_sp - S_F_P - X_S_P) + # S_F_P = X_S_P = 0 + # else: + # S_PO4 -= (req_sp - S_F_P - X_S_P) + # S_F_P = X_S_P = 0 + # # N balance + # elif req_sn <= S_ND_asm1 + X_ND_asm1 + supply_inert_n_asm2d: + # X_ND_asm1 -= (req_sn - S_ND_asm1 - supply_inert_n_asm2d) + # S_ND_asm1 = supply_inert_n_asm2d = 0 + # # P balance + # if req_sp <= S_F_P: + # S_F_P -= req_sp + # elif req_sp <= S_F_P + X_S_P: + # X_S_P -= (req_sp - S_F_P) + # S_F_P = 0 + # elif req_sp <= S_F_P + X_S_P + S_PO4: + # S_PO4 -= (req_sp - S_F_P - X_S_P) + # S_F_P = X_S_P = 0 + # else: + # S_PO4 -= (req_sp - S_F_P - X_S_P) + # S_F_P = X_S_P = 0 + # # N balance + # elif req_sn <= S_ND_asm1 + X_ND_asm1 + S_NH4 + supply_inert_n_asm2d: + # S_NH4 -= (req_sn - S_ND_asm1 - X_ND_asm1 - supply_inert_n_asm2d) + # S_ND_asm1 = X_ND_asm1 = supply_inert_n_asm2d = 0 + # # P balance + # if req_sp <= S_F_P: + # S_F_P -= req_sp + # elif req_sp <= S_F_P + X_S_P: + # X_S_P -= (req_sp - S_F_P) + # S_F_P = 0 + # elif req_sp <= S_F_P + X_S_P + S_PO4: + # S_PO4 -= (req_sp - S_F_P - X_S_P) + # S_F_P = X_S_P = 0 + # else: + # S_PO4 -= (req_sp - S_F_P - X_S_P) + # S_F_P = X_S_P = 0 + # elif req_sp <= S_F_P or req_sp <= S_F_P + X_S_P or req_sp <= S_F_P + X_S_P + S_PO4: + + # S_NH4 -= (req_sn - S_ND_asm1 - X_ND_asm1 - supply_inert_n_asm2d) + # S_ND_asm1 = X_ND_asm1 = supply_inert_n_asm2d = 0 + + # if req_sp <= S_F_P: + # S_F_P -= req_sp + # elif req_sp <= S_F_P + X_S_P: + # X_S_P -= (req_sp - S_F_P) + # S_F_P = 0 + # elif req_sp <= S_F_P + X_S_P + S_PO4: + # S_PO4 -= (req_sp - S_F_P - X_S_P) + # S_F_P = X_S_P = 0 + # else: + # if (S_ND_asm1 + X_ND_asm1 + S_NH4 + supply_inert_n_asm2d)/adm_S_I_i_N < (S_F_P + X_S_P + S_PO4)/adm_S_I_i_P: + # warn('Additional soluble inert COD is mapped to S_su.') + # SI_cod = (S_ND_asm1 + X_ND_asm1 + S_NH4 + supply_inert_n_asm2d)/adm_S_I_i_N + # S_su += S_I - SI_cod + # S_I = SI_cod + # S_ND_asm1 = X_ND_asm1 = S_NH4 = supply_inert_n_asm2d = 0 + + # req_sp = S_I * adm_S_I_i_P + # S_PO4 -= (req_sp - S_F_P - X_S_P) + # S_F_P = X_S_P = 0 + # else: + # warn('Additional soluble inert COD is mapped to S_su.') + # SI_cod = (S_F_P + X_S_P + S_PO4)/adm_S_I_i_P + # S_su += S_I - SI_cod + # S_I = SI_cod + # S_F_P = X_S_P = S_PO4 = 0 + + # req_sn = S_I * adm_S_I_i_N + # S_NH4 -= (req_sn - S_ND_asm1 - X_ND_asm1 - supply_inert_n_asm2d) + # S_ND_asm1 = X_ND_asm1 = supply_inert_n_asm2d = 0 + + # if S_PO4 < 0: + # raise RuntimeError(f'S_PO4 = {S_PO4}\n''Not enough P in S_F_P, X_S_P and S_PO4 to fully ' + # 'convert S_I in ASM2d into S_I in ADM1. Consider ' + # 'increasing the value of P content in S_I (ASM2d)') + + # if S_NH4 < 0: + # raise RuntimeError(f'S_NH4 = {S_NH4}\n''Not enough N in S_I, S_ND_asm1, X_ND_asm1, and S_NH4 to fully ' + # 'convert S_I in ASM2d into S_I in ADM1. Consider ' + # 'increasing the value of N content in S_I (ASM2d)') + # --------------------------Rai version-------------------------------------- + + # --------------------------Flores Alsina et al. 2016 version-------------------------------------- + # S_I = S_I + # --------------------------Flores Alsina et al. 2016 version-------------------------------------- + + # Step 6: Step map any remaining TKN/P + + # --------------------------Rai version-------------------------------------- + # S_IN = S_ND_asm1 + X_ND_asm1 + S_NH4 + supply_inert_n_asm2d + # --------------------------Rai version-------------------------------------- + + # --------------------------Flores Alsina et al. 2016 version-------------------------------------- + S_IN = S_ND_asm1 + X_ND_asm1 + S_NH4 + # --------------------------Flores Alsina et al. 2016 version-------------------------------------- + + S_IP = S_F_P + X_S_P + S_PO4 + + # Step 8: check COD and TKN balance + # has TKN: S_aa, S_IN, S_I, X_pr, X_I + S_IC = S_cat = S_an = 0 + + # When mapping components directly in Step 9 ensure the values of + # cmps.i_N, cmps.i_P, and cmps.i_COD are same in both ASM2d and ADM1 + + # Step 9: Mapping common state variables directly + # The next three commented lines are executed when outputting + # array of ADM1 components + # X_PAO (ADM1) = X_PAO (ASM2d) + # X_PP (ADM1) = X_PP (ASM2d) + # X_PHA (ADM1) = X_PHA (ASM2d) + # X_MeOH (ADM1) = X_MeOH (ASM2d) + # X_MeP (ADM1) = X_MeP (ASM2d) + + adm_vals = np.array([ + S_su, S_aa, + 0, 0, 0, 0, S_ac, # S_fa, S_va, S_bu, S_pro, + 0, 0, # S_h2, S_ch4, + S_IC, S_IN, S_IP, S_I, + X_ch, X_pr, X_li, + 0, 0, 0, 0, 0, 0, 0, # X_su, X_aa, X_fa, X_c4, X_pro, X_ac, X_h2, + X_I, X_PHA, X_PP, X_PAO, + 0, 0, # S_K, S_Mg, + X_MeOH, X_MeP, + S_cat, S_an, H2O]) + + adm_vals = f_corr(asm_vals, adm_vals) + + # Step 7: charge balance + asm_charge_tot = - _sa/64 + _snh4/14 - _sno3/14 - 1.5*_spo4/31 - _salk - _xpp/31 #Based on page 84 of IWA ASM handbook + + #!!! charge balance should technically include VFAs, S_K, S_Mg, + # but since their concentrations are assumed zero it is acceptable. + + S_IN = adm_vals[adm_ions_idx[0]] + S_IP = adm_vals[adm_ions_idx[1]] + + S_IC = (asm_charge_tot -S_IN*alpha_IN -S_IP*alpha_IP)/alpha_IC + + # proton_charge = (OH)^-1 - (H)^+1 + # net_Scat = Scat - San + net_Scat = asm_charge_tot + proton_charge + + if net_Scat > 0: + S_cat = net_Scat + S_an = 0 + else: + S_cat = 0 + S_an = -net_Scat + + adm_vals[adm_ions_idx[2:]] = [S_IC, S_cat, S_an] + + return adm_vals + + self._reactions = asm2d2madm1 \ No newline at end of file diff --git a/qsdsan/sanunits/_junction_copy.py b/qsdsan/sanunits/_junction_copy.py new file mode 100644 index 00000000..b8d7077a --- /dev/null +++ b/qsdsan/sanunits/_junction_copy.py @@ -0,0 +1,1421 @@ +# -*- coding: utf-8 -*- +''' +QSDsan: Quantitative Sustainable Design for sanitation and resource recovery systems + +This module is developed by: + + Joy Zhang + + Yalin Li + + Saumitra Rai + +This module is under the University of Illinois/NCSA Open Source License. +Please refer to https://github.com/QSD-Group/QSDsan/blob/main/LICENSE.txt +for license details. +''' + +import numpy as np +from warnings import warn +from math import isclose +from biosteam.units import Junction as BSTjunction +from .. import SanUnit, processes as pc + +__all__ = ( + 'Junction', + 'ADMjunction', 'ADMtoASM', 'ASMtoADM', + ) + +#%% +class Junction(SanUnit): + ''' + A non-reactive class that serves to convert a stream with one set of components + and into another. + + Thermal conditions of the downstream (T, P) will be copied from those of the upstream. + + Parameters + ---------- + upstream : stream or str + Influent stream. + downstream : stream or str + Effluent stream. + reactions : iterable(dict) | callable + Iterable of dict that has the conversion of upstream components to + downstream components, + or a function that will return the concentration of the effluent + with influent concentration as the input. + + If given as an iterable of dict, keys of each of the dict should be + the ID or alias of components, + values should be the conversion/yield, + which should be negative for reactants and positive for products. + ''' + _graphics = BSTjunction._graphics + + def __init__(self, ID='', upstream=None, downstream=(), thermo=None, + init_with='WasteStream', F_BM_default=None, isdynamic=False, + reactions=None, **kwargs): + thermo = downstream.thermo if downstream else thermo + SanUnit.__init__(self, ID, ins=upstream, outs=downstream, thermo=thermo, + init_with=init_with, + F_BM_default=F_BM_default, isdynamic=isdynamic, + skip_property_package_check=True) + if reactions: self.reactions = reactions + else: self.reactions = None + + for key, val in kwargs.items(): + setattr(self, key, val) + + def _no_parse_reactions(self, rxns): + if rxns is None: return + raise RuntimeError('Reactions are automatically compiled.') + + + def _parse_reactions(self, rxns): + cmps_in = self.ins[0].components + cmps_outs = self.outs[0].components + + num_rxns = len(rxns) + num_upcmps = len(cmps_in) + num_downcmps = len(cmps_outs) + RX = np.zeros(shape=(num_rxns, num_upcmps)) # each row is a reaction + RY = np.zeros(shape=(num_rxns, num_downcmps)) + + for n, dct in enumerate(rxns): + RXn, RYn = RX[n], RY[n] + for cmp, val in dct.items(): + if val < 0: RXn[cmps_in.index(cmp)] = val + elif val > 0: RYn[cmps_outs.index(cmp)] = val + + # Transfer overlapped components + overlapped = set(cmps_in.IDs).intersection(set(cmps_outs.IDs)) + RXsum = RX.sum(axis=0) + RXnew = [] + RYnew = [] + for ID in overlapped: + idx_up = cmps_in.index(ID) + idx_down= cmps_outs.index(ID) + if RXsum[idx_up] != -1: + newRXn = np.zeros(num_upcmps) + newRYn = np.zeros(num_downcmps) + newRXn[idx_up] = -1 - RXsum[idx_up] + newRYn[idx_down] = -newRXn[idx_up] + RXnew.append(newRXn) + RYnew.append(newRYn) + RX = np.concatenate((RX, np.array(RXnew))) + RY = np.concatenate((RY, np.array(RYnew))) + + # Check if all upstream components are converted + RXsum = RX.sum(axis=0) + if np.where(RXsum!=-1)[0].any(): + index = np.where(RXsum!=-1)[0][0] + raise ValueError(f'Conversion for Component "{cmps_in.IDs[index]}" ' + f'is {abs(RXsum[index])}, not 100%.') + self._RX = RX + self._RY = RY + + + def _compile_reactions(self): + def reactions(X): + X.reshape(1, len(X)) + Yarr = -(self._RX*X).T @ self._RY # _RX: (num_rxns, num_upcmps); _RY: (num_rxns, num_downcmps) + Y = Yarr.sum(axis=0) # Yarr: (num_upcmps, num_downcmps) + return Y.reshape(Y.shape[1],) + self._reactions = reactions + + + def _run(self): + ins = self.ins[0] + rxns = self.reactions + X = ins.conc.value + Y = rxns(X) + outs = self.outs[0] + outs.thermal_condition.copy_like(ins.thermal_condition) + concs = dict(zip(outs.components.IDs, Y)) + concs.pop('H2O', None) + outs.set_flow_by_concentration( + flow_tot=ins.F_vol, + concentrations=concs, + units=('m3/hr', 'mg/L')) + + + # Below are dynamic simulation-related properties + @property + def state(self): + '''The state of the Junction, including component concentrations [mg/L] and flow rate [m^3/d].''' + if self._state is None: return None + else: + return dict(zip(list(self.components.IDs) + ['Q'], self._state)) + + def _init_dynamic(self): + super()._init_dynamic() + # Need to use ins' components, otherwise _ins_QC will follow the shape of + # the unit's (i.e., downstream) components + self._ins_QC = np.zeros((len(self._ins), len(self.ins[0].components)+1)) + self._ins_dQC = self._ins_QC.copy() + + def _init_state(self): + ''' + Initialize state by specifying or calculating component concentrations + based on influents. Total flow rate is always initialized as the sum of + influent wastestream flows. + ''' + self._state = np.append(self.outs[0].conc, self.outs[0].F_vol*24) + self._dstate = self._state * 0. + self._cached_state = self._state.copy() + self._cached_t = 0 + + def _update_state(self): + ''' + Updates conditions of output stream based on conditions of the Junction. + ''' + self._outs[0].state = self._state + + def _update_dstate(self): + ''' + Updates rates of change of output stream from rates of change of the Junction. + ''' + self._outs[0].dstate = self._dstate + + # The unit's state should be the same as the effluent state + # react the state arr and dstate arr + def _compile_AE(self): + _state = self._state + _dstate = self._dstate + _update_state = self._update_state + _update_dstate = self._update_dstate + rxns = self.reactions + def yt(t, QC_ins, dQC_ins): + for i, j in zip((QC_ins, dQC_ins), (_state, _dstate)): + X = i[0][:-1] # shape = (1, num_upcmps) + Y = rxns(X) + j[:-1] = Y + j[-1] = i[0][-1] # volumetric flow of outs should equal that of ins + _update_state() + _update_dstate() + self._AE = yt + + + @property + def upstream(self): + '''[qsdsan.WasteStream] Influent.''' + return self.ins[0] + @upstream.setter + def upstream(self, upstream): + self.ins[0] = upstream + + @property + def downstream(self): + '''[qsdsan.WasteStream] Effluent.''' + return self.outs[0] + @downstream.setter + def downstream(self, downstream): + self.outs[0] = downstream + + @property + def AE(self): + if self._AE is None: + self._compile_AE() + return self._AE + + @property + def reactions(self): + ''' + [callable] Function that takes the concentration array of the influent + and convert to the concentration array of the effluent. + ''' + if not self._reactions: self._compile_reactions() + return self._reactions + @reactions.setter + def reactions(self, i): + if callable(i): self._reactions = i + else: + self._parse_reactions(i) + self._compile_reactions() + + +# %% + +#TODO: add a `rtol` kwargs for error checking +class ADMjunction(Junction): + ''' + An abstract superclass holding common properties of ADM interface classes. + Users should use its subclasses (e.g., ``ASMtoADM``, ``ADMtoASM``) instead. + + See Also + -------- + :class:`qsdsan.sanunits.Junction` + + :class:`qsdsan.sanunits.ADMtoASM` + + :class:`qsdsan.sanunits.ASMtoADM` + ''' + _parse_reactions = Junction._no_parse_reactions + rtol = 1e-2 + atol = 1e-6 + + def __init__(self, ID='', upstream=None, downstream=(), thermo=None, + init_with='WasteStream', F_BM_default=None, isdynamic=False, + adm1_model=None): + self.adm1_model = adm1_model # otherwise there won't be adm1_model when `_compile_reactions` is called + if thermo is None: + warn('No `thermo` object is provided and is prone to raise error. ' + 'If you are not sure how to get the `thermo` object, ' + 'use `thermo = qsdsan.set_thermo` after setting thermo with the `Components` object.') + super().__init__(ID=ID, upstream=upstream, downstream=downstream, + thermo=thermo, init_with=init_with, + F_BM_default=F_BM_default, isdynamic=isdynamic) + + + @property + def T(self): + '''[float] Temperature of the upstream/downstream [K].''' + return self.ins[0].T + @T.setter + def T(self, T): + self.ins[0].T = self.outs[0].T = T + + @property + def pH(self): + '''[float] pH of the upstream/downstream.''' + return self.ins[0].pH + + @property + def adm1_model(self): + '''[qsdsan.Process] ADM process model.''' + return self._adm1_model + @adm1_model.setter + def adm1_model(self, model): + if not isinstance(model, pc.ADM1): + raise ValueError('`adm1_model` must be an `AMD1` object, ' + f'the given object is {type(model).__name__}.') + self._adm1_model = model + + @property + def T_base(self): + '''[float] Base temperature in the ADM1 model.''' + return self.adm1_model.rate_function.params['T_base'] + + @property + def pKa_base(self): + '''[float] pKa of the acid-base pairs at the base temperature in the ADM1 model.''' + Ka_base = self.adm1_model.rate_function.params['Ka_base'] + return -np.log10(Ka_base) + + @property + def Ka_dH(self): + '''[float] Heat of reaction for Ka.''' + return self.adm1_model.rate_function.params['Ka_dH'] + + @property + def pKa(self): + ''' + [numpy.array] pKa array of the following acid-base pairs: + ('H+', 'OH-'), ('NH4+', 'NH3'), ('H3PO4', 'H2PO4-'), ('CO2', 'HCO3-'), + ('HAc', 'Ac-'), ('HPr', 'Pr-'), ('HBu', 'Bu-'), ('HVa', 'Va-') + ''' + return self.pKa_base-np.log10(np.exp(pc.T_correction_factor(self.T_base, self.T, self.Ka_dH))) + + @property + def alpha_IC(self): + '''[float] Charge per g of C.''' + pH = self.pH + pKa_IC = self.pKa[2] + return -1/(1+10**(pKa_IC-pH))/12 + + @property + def alpha_IN(self): + '''[float] Charge per g of N.''' + pH = self.pH + pKa_IN = self.pKa[1] + return 10**(pKa_IN-pH)/(1+10**(pKa_IN-pH))/14 + + + def _compile_AE(self): + _state = self._state + _dstate = self._dstate + _cached_state = self._cached_state + _update_state = self._update_state + _update_dstate = self._update_dstate + rxn = self.reactions + + def yt(t, QC_ins, dQC_ins): + before_vals = QC_ins[0,:-1] + _state[:-1] = rxn(before_vals) + _state[-1] = QC_ins[0,-1] + if t > self._cached_t: + _dstate[:] = (_state - _cached_state)/(t-self._cached_t) + _cached_state[:] = _state + self._cached_t = t + _update_state() + _update_dstate() + + self._AE = yt + +# %% + +class ADMtoASM(ADMjunction): + ''' + Interface unit to convert anaerobic digestion model (ADM) components + to activated sludge model (ASM) components. + + Parameters + ---------- + upstream : stream or str + Influent stream with ADM components. + downstream : stream or str + Effluent stream with ASM components. + adm1_model : obj + The anaerobic digestion process model (:class:`qsdsan.processes.ADM1_p_extension`). + rtol : float + Relative tolerance for COD and TKN balance. + atol : float + Absolute tolerance for COD and TKN balance. + + References + ---------- + [1] Nopens, I.; Batstone, D. J.; Copp, J. B.; Jeppsson, U.; Volcke, E.; + Alex, J.; Vanrolleghem, P. A. An ASM/ADM Model Interface for Dynamic + Plant-Wide Simulation. Water Res. 2009, 43, 1913–1923. + + [2] Flores-Alsina, X., Solon, K., Kazadi Mbamba, C., Tait, S., Gernaey, K. V., + Jeppsson, U., & Batstone, D. J. (2016). Modelling phosphorus (P), sulfur (S) + and iron (FE) interactions for dynamic simulations of anaerobic digestion processes. + Water Research, 95, 370–382. + See Also + -------- + :class:`qsdsan.sanunits.ADMjunction` + + :class:`qsdsan.sanunits.ASMtoADM` + + `math.isclose ` + ''' + # User defined values + # bio_to_xs = 0.7 (Not using this split since no X_P exists in ASM2d) + + # Should be constants + cod_vfa = np.array([64, 112, 160, 208]) + + def isbalanced(self, lhs, rhs_vals, rhs_i): + rhs = sum(rhs_vals*rhs_i) + error = rhs - lhs + tol = max(self.rtol*lhs, self.rtol*rhs, self.atol) + return abs(error) <= tol, error, tol, rhs + + def balance_cod_tkn(self, adm_vals, asm_vals): + cmps_adm = self.ins[0].components + cmps_asm = self.outs[0].components + asm_i_COD = cmps_asm.i_COD + adm_i_COD = cmps_adm.i_COD + gas_idx = cmps_adm.indices(('S_h2', 'S_ch4')) + asm_i_N = cmps_asm.i_N + adm_i_N = cmps_adm.i_N + asm_i_P = cmps_asm.i_P + adm_i_P = cmps_adm.i_P + adm_cod = sum(adm_vals*adm_i_COD) - sum(adm_vals[gas_idx]) + adm_tkn = sum(adm_vals*adm_i_N) + adm_tp = sum(adm_vals*adm_i_P) + + cod_bl, cod_err, cod_tol, asm_cod = self.isbalanced(adm_cod, asm_vals, asm_i_COD) + tkn_bl, tkn_err, tkn_tol, asm_tkn = self.isbalanced(adm_tkn, asm_vals, asm_i_N) + tp_bl, tp_err, tp_tol, asm_tp = self.isbalanced(adm_tp, asm_vals, asm_i_P) + + if tkn_bl and tp_bl: + if cod_bl: + return asm_vals + else: + if cod_err > 0: dcod = -(cod_err - cod_tol)/asm_cod + else: dcod = -(cod_err + cod_tol)/asm_cod + _asm_vals = asm_vals * (1 + (asm_i_COD>0)*dcod) + _tkn_bl, _tkn_err, _tkn_tol, _asm_tkn = self.isbalanced(adm_tkn, _asm_vals, asm_i_N) + _tp_bl, _tp_err, _tp_tol, _asm_tp = self.isbalanced(adm_tp, _asm_vals, asm_i_P) + if _tkn_bl and _tp_bl: return _asm_vals + else: + warn('cannot balance COD, TKN, and TP at the same \n' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ADM) TKN is {adm_tkn}\n ' + f'effluent (ASM) TKN is {asm_tkn} or {_asm_tkn}\n ' + f'influent (ADM) TP is {adm_tp}\n ' + f'effluent (ASM) TP is {asm_tp} or {_asm_tp}. ' + f'influent (ADM) COD is {adm_cod}\n ' + f'effluent (ASM) COD is {asm_cod} or {asm_cod*(1+dcod)}. ') + return asm_vals + elif cod_bl and tp_bl: + if tkn_bl: + return asm_vals + else: + if tkn_err > 0: dtkn = -(tkn_err - tkn_tol)/asm_tkn + else: dtkn = -(tkn_err + tkn_tol)/asm_tkn + _asm_vals = asm_vals * (1 + (asm_i_N>0)*dtkn) + _cod_bl, _cod_err, _cod_tol, _asm_cod = self.isbalanced(adm_cod, _asm_vals, asm_i_COD) + _tp_bl, _tp_err, _tp_tol, _asm_tp = self.isbalanced(adm_tp, _asm_vals, asm_i_P) + if _cod_bl and _tp_bl: return _asm_vals + else: + warn('cannot balance COD, TKN, and TP at the same time' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ADM) COD is {adm_cod}\n ' + f'effluent (ASM) COD is {asm_cod} or {_asm_cod}\n ' + f'influent (ADM) TP is {adm_tp}\n ' + f'effluent (ASM) TP is {asm_tp} or {_asm_tp}. ' + f'influent (ADM) TKN is {adm_tkn}\n ' + f'effluent (ASM) TKN is {asm_tkn} or {asm_tkn*(1+dtkn)}. ') + return asm_vals + elif cod_bl and tkn_bl: + if tp_bl: + return asm_vals + else: + if tp_err > 0: dtp = -(tp_err - tp_tol)/asm_tp + else: dtp = -(tp_err + tp_tol)/asm_tp + _asm_vals = asm_vals * (1 + (asm_i_P>0)*dtp) + _cod_bl, _cod_err, _cod_tol, _asm_cod = self.isbalanced(adm_cod, _asm_vals, asm_i_COD) + _tkn_bl, _tkn_err, _tkn_tol, _asm_tkn = self.isbalanced(adm_tkn, _asm_vals, asm_i_N) + if _cod_bl and _tkn_bl: return _asm_vals + else: + warn('cannot balance COD, TKN, and TP at the same time' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ADM) COD is {adm_cod}\n ' + f'effluent (ASM) COD is {asm_cod} or {_asm_cod}\n ' + f'influent (ADM) TKN is {adm_tkn}\n ' + f'effluent (ASM) TKN is {asm_tkn} or {_asm_tkn}. ' + f'influent (ADM) TP is {adm_tp}\n ' + f'effluent (ASM) TP is {asm_tp} or {asm_tp*(1+dtp)}. ') + return asm_vals + else: + warn('cannot balance COD, TKN and TP at the same time. \n' + 'Atleast two of the three COD, TKN, and TP are not balanced \n' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ADM) COD is {adm_cod}\n ' + f'effluent (ASM) COD is {asm_cod}\n ' + f'influent (ADM) TP is {adm_tp}\n ' + f'effluent (ASM) TP is {asm_tp}' + f'influent (ADM) TKN is {adm_tkn}\n ' + f'effluent (ASM) TKN is {asm_tkn}. ') + return asm_vals + + def _compile_reactions(self): + # Retrieve constants + ins = self.ins[0] + outs = self.outs[0] + rtol = self.rtol + atol = self.atol + + cmps_adm = ins.components + # N balance + X_pr_i_N = cmps_adm.X_pr.i_N + S_aa_i_N = cmps_adm.S_aa.i_N + adm_X_I_i_N = cmps_adm.X_I.i_N + adm_S_I_i_N = cmps_adm.S_I.i_N + adm_i_N = cmps_adm.i_N + adm_bio_N_indices = cmps_adm.indices(('X_su', 'X_aa', 'X_fa', + 'X_c4', 'X_pro', 'X_ac', 'X_h2')) + + # P balance + X_pr_i_P = cmps_adm.X_pr.i_P + adm_X_I_i_P = cmps_adm.X_I.i_P + adm_S_I_i_P = cmps_adm.S_I.i_P + adm_i_P = cmps_adm.i_P + adm_bio_P_indices = cmps_adm.indices(('X_su', 'X_aa', 'X_fa', + 'X_c4', 'X_pro', 'X_ac', 'X_h2')) + + cmps_asm = outs.components + + # N balance + X_S_i_N = cmps_asm.X_S.i_N + S_F_i_N = cmps_asm.S_F.i_N + S_A_i_N = cmps_asm.S_A.i_N + asm_X_I_i_N = cmps_asm.X_I.i_N + asm_S_I_i_N = cmps_asm.S_I.i_N + asm_ions_idx = cmps_asm.indices(('S_NH4', 'S_A', 'S_NO3', 'S_PO4', 'X_PP', 'S_ALK')) + + # P balance + X_S_i_P = cmps_asm.X_S.i_P + S_F_i_P = cmps_asm.S_F.i_P + S_A_i_P = cmps_asm.S_A.i_P + asm_X_I_i_P = cmps_asm.X_I.i_P + asm_S_I_i_P = cmps_asm.S_I.i_P + + # Checks for direct mapping of X_PAO, X_PP, X_PHA + + # Check for X_PAO (measured as COD so i_COD = 1 in both ASM2d and ADM1) + asm_X_PAO_i_N = cmps_asm.X_PAO.i_N + adm_X_PAO_i_N = cmps_adm.X_PAO.i_N + if asm_X_PAO_i_N != adm_X_PAO_i_N: + raise RuntimeError('X_PAO cannot be directly mapped as N content' + f'in asm2d_X_PAO_i_N = {asm_X_PAO_i_N} is not equal to' + f'adm_X_PAO_i_N = {adm_X_PAO_i_N}') + + asm_X_PAO_i_P = cmps_asm.X_PAO.i_P + adm_X_PAO_i_P = cmps_adm.X_PAO.i_P + if asm_X_PAO_i_P != adm_X_PAO_i_P: + raise RuntimeError('X_PAO cannot be directly mapped as P content' + f'in asm2d_X_PAO_i_P = {asm_X_PAO_i_P} is not equal to' + f'adm_X_PAO_i_P = {adm_X_PAO_i_P}') + + # Checks not required for X_PP as measured as P in both, with i_COD = i_N = 0 + # Checks not required for X_PHA as measured as COD in both, with i_N = i_P = 0 + + alpha_IN = self.alpha_IN + alpha_IC = self.alpha_IC + alpha_IP = self.alpha_IP + alpha_vfa = self.alpha_vfa + f_corr = self.balance_cod_tkn + + def adm2asm(adm_vals): + S_su, S_aa, S_fa, S_va, S_bu, S_pro, S_ac, S_h2, S_ch4, S_IC, S_IN, S_IP, S_I, \ + X_ch, X_pr, X_li, X_su, X_aa, X_fa, X_c4, X_pro, X_ac, X_h2, X_I, \ + X_PHA, X_PP, X_PAO, S_K, S_Mg, X_MeOH, X_MeP, S_cat, S_an, H2O = adm_vals + + # Step 0: snapshot of charged components + # Not sure about charge on X_PP, S_Mg, S_K (PHA and PAO would have zero charge) + _ions = np.array([S_IN, S_IC, S_IP, X_PP, S_Mg, S_K, S_ac, S_pro, S_bu, S_va]) + + # Step 1a: convert biomass into X_S+X_ND + bio_cod = X_su + X_aa + X_fa + X_c4 + X_pro + X_ac + X_h2 + bio_n = sum((adm_vals*adm_i_N)[adm_bio_N_indices]) + bio_p = sum((adm_vals*adm_i_P)[adm_bio_P_indices]) + + # There is no X_P (particulate products arising due to biomass decay) + # or equivalent component in ASM2d + # xp_cod = bio_cod * (1-self.bio_to_xs) + # xp_ndm = xp_cod*X_P_i_N + # if xp_ndm > bio_n: + # warn('Not enough biomass N to map the specified proportion of ' + # 'biomass COD into X_P. Mapped as much COD as possible, the rest ' + # 'goes to X_S.') + # X_P = bio_n/asm_X_P_i_N + # bio_n = 0 + # else: + # X_P = xp_cod + # bio_n -= xp_ndm + + # X_S = bio_cod - X_P + + # COD balance + X_S = bio_cod + + # N balance + X_S_N = X_S*X_S_i_N + if X_S_N <= bio_n: + # In ASM1-ADM1 interface if there was excess bio_n compared to + # X_S_N, it was transferred to X_ND. But since X_ND does not + # exist in ASM2d, S_IN is used for N balance + # Here, S_IN is also used based on the fact that in case of + # deficit bio_N, S_IN is used to compensate for excess X_S_N + # in Nopens at al. 2009 + S_IN += (bio_n - X_S_N) + bio_n = 0 + elif X_S_N <= bio_n + S_IN: + S_IN -= (X_S_N - bio_n) + bio_n = 0 + else: + if isclose(X_S_N, bio_n + S_IN, rel_tol=rtol, abs_tol=atol): + S_IN = bio_n = 0 + else: + raise RuntimeError('Not enough nitrogen (S_IN + biomass) to map ' + 'all biomass COD into X_S') + + # P balance + X_S_P = X_S*X_S_i_P + if X_S_P <= bio_p: + # Here, S_IP is based on the fact that in case of + # deficit bio_N, S_IN is used to compensate for excess X_S_N + # in Nopens at al. 2009 + S_IP += (bio_p - X_S_P) + bio_p = 0 + elif X_S_P <= bio_p + S_IP: + S_IP -= (X_S_P - bio_p) + bio_p = 0 + else: + if isclose(X_S_P, bio_p + S_IP, rel_tol=rtol, abs_tol=atol): + S_IP = bio_p = 0 + else: + raise RuntimeError('Not enough phosphorous (S_IP + biomass) to map ' + 'all biomass COD into X_S') + + # Step 1b: convert particulate substrates into X_S + + # First we calculate the COD, N, and P content in particulate substrate + xsub_cod = X_ch + X_pr + X_li + xsub_n = X_pr*X_pr_i_N + xsub_p = X_pr*X_pr_i_P + + # Then we determine the amount of X_S required for COD, N, and P balance + X_S_cod = xsub_cod + X_S_n = xsub_n/X_S_i_N + X_S_p = xsub_p/X_S_i_P + + # Identify the limiting component and accordingly form X_S + X_S_add = min(X_S_cod, X_S_n, X_S_p) + X_S += X_S_add + + # Calculate the imbalance in the non-limiting components + if X_S_add == X_S_cod: + def_N = (xsub_n - X_S_add*X_S_i_N) + def_P = (xsub_p - X_S_add*X_S_i_P) + S_IN += def_N + S_IP += def_P + elif X_S_add == X_S_n: + def_COD = (xsub_cod - X_S_add) + def_P = (xsub_p - X_S_add*X_S_i_P) + S_A = def_COD # suggested by Jeremy + S_IP += def_P + else: + def_COD = (xsub_cod - X_S_add) + def_N = (xsub_n - X_S_add*X_S_i_N) + S_A = def_COD # suggested by Jeremy + S_IN += def_N + + # Step 2: map all X_I from ADM to ASM + excess_XIn = X_I * (adm_X_I_i_N - asm_X_I_i_N) + S_IN += excess_XIn + if S_IN < 0: + if isclose(S_IN, 0, rel_tol=rtol, abs_tol=atol): S_IN = 0 + # This seems to be a typo. Shouldn't there be an else here? + raise RuntimeError('Not enough nitrogen (X_I + S_IN) to map ' + 'all ADM X_I into ASM X_I') + + excess_XIp = X_I * (adm_X_I_i_P - asm_X_I_i_P) + S_IP += excess_XIp + if S_IP < 0: + if isclose(S_IP, 0, rel_tol=rtol, abs_tol=atol): S_IP = 0 + # This seems to be a typo. Shouldn't there be an else here? + raise RuntimeError('Not enough phosphorous (X_I + S_IP) to map ' + 'all ADM X_I into ASM X_I') + + # Step 3: map ADM S_I into ASM S_I and S_NH4 + excess_SIn = S_I * (adm_S_I_i_N - asm_S_I_i_N) + if excess_SIn > 0: + S_NH4 = excess_SIn + else: + S_NH4 = 0 + S_IN += excess_SIn + if S_IN < 0: + if isclose(S_IN, 0, rel_tol=rtol, abs_tol=atol): S_IN = 0 + # This seems to be a typo. Shouldn't there be an else here? + raise RuntimeError('Not enough nitrogen (S_I + S_IN) to map ' + 'all ADM S_I into ASM S_I') + S_NH4 += S_IN + + excess_SIp = S_I * (adm_S_I_i_P - asm_S_I_i_P) + if excess_SIp > 0: + S_PO4 = excess_SIp + else: + S_PO4 = 0 + S_IP += excess_SIp + if S_IP < 0: + if isclose(S_IP, 0, rel_tol=rtol, abs_tol=atol): S_IP = 0 + # This seems to be a typo. Shouldn't there be an else here? + raise RuntimeError('Not enough nitrogen (S_I + S_IP) to map ' + 'all ADM S_I into ASM S_I') + S_PO4 += S_IP + + # Step 4: map all soluble substrates into S_A and S_F + ssub_cod = S_su + S_aa + S_fa + S_va + S_bu + S_pro + S_ac + ssub_n = S_aa * S_aa_i_N + + # P balance not required as all the 7 soluble substrates have no P + + # N balance + S_F = ssub_n/S_F_i_N + # COD balance + S_A += ssub_cod - S_F + + # Technically both S_A_i_N and S_A_i_P would be 0 + if S_A_i_N > 0 or S_A_i_P > 0: + # excess N formed subtracted from S_NH4 + S_NH4 -= (ssub_cod - S_F)*S_A_i_N + # excess P (due to S_A) formed subtracted from S_PO4 + S_PO4 -= (ssub_cod - S_F)*S_A_i_P + + if S_F_i_P > 0: + # excess P (due to S_F) formed subtracted from S_PO4 + S_PO4 -= S_F*S_F_i_P + + + # Step 6: check COD and TKN balance + asm_vals = np.array(([ + 0, 0, # S_O2, S_N2, + S_NH4, + 0, + S_PO4, S_F, S_A, S_I, + 0, # S_ALK, + X_I, X_S, + 0, # X_H, + X_PAO, X_PP, X_PHA, # directly mapped + 0, # X_AUT, + X_MeOH, X_MeP, H2O])) # directly mapped + + if S_h2 > 0 or S_ch4 > 0: + warn('Ignored dissolved H2 or CH4.') + + asm_vals = f_corr(adm_vals, asm_vals) + + # Step 5: charge balance for alkalinity + S_NH4 = asm_vals[asm_ions_idx[0]] + S_A = asm_vals[asm_ions_idx[1]] + S_NO3 = asm_vals[asm_ions_idx[2]] + S_PO4 = asm_vals[asm_ions_idx[3]] + X_PP = asm_vals[asm_ions_idx[4]] + # Need to include S_K, S_Mg in the charge balance (Maybe ask Joy/Jeremy) + S_ALK = (sum(_ions * np.append([alpha_IN, alpha_IC, alpha_IP], alpha_vfa)) - (S_NH4/14 - S_A/64 - S_NO3/14 -1.5*S_PO4/31 - X_PP/31))*(-12) + asm_vals[asm_ions_idx[5]] = S_ALK + + return asm_vals + + self._reactions = adm2asm + + @property + def alpha_vfa(self): + # This may need change based on P-extension of ADM1 (ask Joy?) + return 1.0/self.cod_vfa*(-1.0/(1.0 + 10**(self.pKa[3:]-self.pH))) + + +# %% + +# While using this interface X_I.i_N in ASM2d should be 0.06, instead of 0.02. +class ASMtoADM(ADMjunction): + ''' + Interface unit to convert activated sludge model (ASM) components + to anaerobic digestion model (ADM) components. + + Parameters + ---------- + upstream : stream or str + Influent stream with ASM components. + downstream : stream or str + Effluent stream with ADM components. + adm1_model : obj + The anaerobic digestion process model (:class:`qsdsan.processes.ADM1_p_extension`). + xs_to_li : float + Split of slowly biodegradable substrate COD to lipid, + after all N is mapped into protein. + bio_to_li : float + Split of biomass COD to lipid, after all biomass N is + mapped into protein. + frac_deg : float + Biodegradable fraction of biomass COD. + rtol : float + Relative tolerance for COD and TKN balance. + atol : float + Absolute tolerance for COD and TKN balance. + + References + ---------- + [1] Nopens, I.; Batstone, D. J.; Copp, J. B.; Jeppsson, U.; Volcke, E.; + Alex, J.; Vanrolleghem, P. A. An ASM/ADM Model Interface for Dynamic + Plant-Wide Simulation. Water Res. 2009, 43, 1913–1923. + + [2] Flores-Alsina, X., Solon, K., Kazadi Mbamba, C., Tait, S., Gernaey, K. V., + Jeppsson, U., & Batstone, D. J. (2016). Modelling phosphorus (P), sulfur (S) + and iron (FE) interactions for dynamic simulations of anaerobic digestion processes. + Water Research, 95, 370–382. + + See Also + -------- + :class:`qsdsan.sanunits.ADMjunction` + + :class:`qsdsan.sanunits.ADMtoASM` + + `math.isclose ` + ''' + # User defined values + xs_to_li = 0.7 + bio_to_li = 0.4 + frac_deg = 0.68 + + + def isbalanced(self, lhs, rhs_vals, rhs_i): + rhs = sum(rhs_vals*rhs_i) + error = rhs - lhs + tol = max(self.rtol*lhs, self.rtol*rhs, self.atol) + return abs(error) <= tol, error, tol, rhs + + def balance_cod_tkn_tp(self, asm_vals, adm_vals): + cmps_asm = self.ins[0].components + cmps_adm = self.outs[0].components + asm_i_COD = cmps_asm.i_COD + adm_i_COD = cmps_adm.i_COD + non_tkn_idx = cmps_asm.indices(('S_NO', 'S_N2')) + asm_i_N = cmps_asm.i_N + adm_i_N = cmps_adm.i_N + asm_i_P = cmps_asm.i_P + adm_i_P = cmps_adm.i_P + asm_cod = sum(asm_vals*asm_i_COD) + asm_tkn = sum(asm_vals*asm_i_N) - sum(asm_vals[non_tkn_idx]) + asm_tp = sum(asm_vals*asm_i_P) + cod_bl, cod_err, cod_tol, adm_cod = self.isbalanced(asm_cod, adm_vals, adm_i_COD) + tkn_bl, tkn_err, tkn_tol, adm_tkn = self.isbalanced(asm_tkn, adm_vals, adm_i_N) + tp_bl, tp_err, tp_tol, adm_tp = self.isbalanced(asm_tp, adm_vals, adm_i_P) + + if tkn_bl and tp_bl: + if cod_bl: + return adm_vals + else: + if cod_err > 0: dcod = -(cod_err - cod_tol)/adm_cod + else: dcod = -(cod_err + cod_tol)/adm_cod + _adm_vals = adm_vals * (1 + (adm_i_COD>0)*dcod) + _tkn_bl, _tkn_err, _tkn_tol, _adm_tkn = self.isbalanced(asm_tkn, _adm_vals, adm_i_N) + _tp_bl, _tp_err, _tp_tol, _adm_tp = self.isbalanced(asm_tp, _adm_vals, adm_i_P) + if _tkn_bl and _tp_bl: return _adm_vals + else: + warn('cannot balance COD, TKN, and TP at the same \n' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ASM) TKN is {asm_tkn}\n ' + f'effluent (ADM) TKN is {adm_tkn} or {_adm_tkn}\n ' + f'influent TP is {asm_tp}\n ' + f'effluent TP is {adm_tp} or {_adm_tp}. ' + f'influent COD is {asm_cod}\n ' + f'effluent COD is {adm_cod} or {adm_cod*(1+dcod)}. ') + return adm_vals + elif cod_bl and tp_bl: + if tkn_bl: + return adm_vals + else: + if tkn_err > 0: dtkn = -(tkn_err - tkn_tol)/adm_tkn + else: dtkn = -(tkn_err + tkn_tol)/adm_tkn + _adm_vals = adm_vals * (1 + (adm_i_N>0)*dtkn) + _cod_bl, _cod_err, _cod_tol, _adm_cod = self.isbalanced(asm_cod, _adm_vals, adm_i_COD) + _tp_bl, _tp_err, _tp_tol, _adm_tp = self.isbalanced(asm_tp, _adm_vals, adm_i_P) + if _cod_bl and _tp_bl: return _adm_vals + else: + warn('cannot balance COD, TKN, and TP at the same time' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ASM) COD is {asm_cod}\n ' + f'effluent (ADM) COD is {adm_cod} or {_adm_cod}\n ' + f'influent TP is {asm_tp}\n ' + f'effluent TP is {adm_tp} or {_adm_tp}. ' + f'influent TKN is {asm_tkn}\n ' + f'effluent TKN is {adm_tkn} or {adm_tkn*(1+dtkn)}. ' + 'To balance TKN please ensure ASM2d(X_I.i_N) = ADM1(X_I.i_N)') + return adm_vals + elif cod_bl and tkn_bl: + if tp_bl: + return adm_vals + else: + if tp_err > 0: dtp = -(tp_err - tp_tol)/adm_tp + else: dtp = -(tp_err + tp_tol)/adm_tp + _adm_vals = adm_vals * (1 + (adm_i_P>0)*dtp) + _cod_bl, _cod_err, _cod_tol, _adm_cod = self.isbalanced(asm_cod, _adm_vals, adm_i_COD) + _tkn_bl, _tkn_err, _tkn_tol, _adm_tkn = self.isbalanced(asm_tkn, _adm_vals, adm_i_N) + if _cod_bl and _tkn_bl: return _adm_vals + else: + warn('cannot balance COD, TKN, and TP at the same time' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ASM) COD is {asm_cod}\n ' + f'effluent (ADM) COD is {adm_cod} or {_adm_cod}\n ' + f'influent TKN is {asm_tkn}\n ' + f'effluent TKN is {adm_tkn} or {_adm_tkn}. ' + f'influent TP is {asm_tp}\n ' + f'effluent TP is {adm_tp} or {adm_tp*(1+dtp)}. ') + return adm_vals + else: + warn('cannot balance COD, TKN and TP at the same time. \n' + 'Atleast two of the three COD, TKN, and TP are not balanced \n' + f'time with rtol={self.rtol} and atol={self.atol}.\n ' + f'influent (ASM) COD is {asm_cod}\n ' + f'effluent (ADM) COD is {adm_cod}\n ' + f'influent TP is {asm_tp}\n ' + f'effluent TP is {adm_tp}' + f'influent TKN is {asm_tkn}\n ' + f'effluent TKN is {adm_tkn}. ') + return adm_vals + + def _compile_reactions(self): + # Retrieve constants + ins = self.ins[0] + outs = self.outs[0] + rtol = self.rtol + atol = self.atol + + cmps_asm = ins.components + + # For COD balance + S_NO3_i_COD = cmps_asm.S_NO3.i_COD + + # For N balance + X_H_i_N = cmps_asm.X_H.i_N + X_AUT_i_N = cmps_asm.X_AUT.i_N + S_F_i_N = cmps_asm.S_F.i_N + X_S_i_N = cmps_asm.X_S.i_N + asm_X_I_i_N = cmps_asm.X_I.i_N + asm_S_I_i_N = cmps_asm.S_I.i_N + + # For P balance + X_H_i_P = cmps_asm.X_H.i_P + X_AUT_i_P = cmps_asm.X_AUT.i_P + S_F_i_P = cmps_asm.S_F.i_P + X_S_i_P = cmps_asm.X_S.i_P + asm_X_I_i_P = cmps_asm.X_I.i_P + + if cmps_asm.S_A.i_N > 0: + warn(f'S_A in ASM has positive nitrogen content: {cmps_asm.S_S.i_N} gN/gCOD. ' + 'These nitrogen will be ignored by the interface model ' + 'and could lead to imbalance of TKN after conversion.') + if cmps_asm.S_A.i_P > 0: + warn(f'S_A in ASM has positive phosphorous content: {cmps_asm.S_S.i_P} gN/gCOD. ' + 'These phosphorous will be ignored by the interface model ' + 'and could lead to imbalance of TP after conversion.') + if cmps_asm.S_I.i_P > 0: + warn(f'S_I in ASM has positive phosphorous content: {cmps_asm.S_I.i_P} gN/gCOD. ' + 'These phosphorous will be ignored by the interface model ' + 'and could lead to imbalance of TP after conversion.') + # We do not need to check if X_S.i_N != 0 since we take care of it using X_ND_asm1 + # We do not need to check if S_F.i_N != 0 since we take care of it using S_ND_asm1 + + cmps_adm = outs.components + + # For nitrogen balance + S_aa_i_N = cmps_adm.S_aa.i_N + X_pr_i_N = cmps_adm.X_pr.i_N + adm_S_I_i_N = cmps_adm.S_I.i_N + adm_X_I_i_N = cmps_adm.X_I.i_N + + # For phosphorous balance + X_pr_i_P = cmps_adm.X_pr.i_P + adm_S_I_i_P = cmps_adm.S_I.i_P + adm_X_I_i_P = cmps_adm.X_I.i_P + + # Checks for direct mapping of X_PAO, X_PP, X_PHA + + # Check for X_PAO (measured as COD so i_COD = 1 in both ASM2d and ADM1) + asm_X_PAO_i_N = cmps_asm.X_PAO.i_N + adm_X_PAO_i_N = cmps_adm.X_PAO.i_N + if asm_X_PAO_i_N != adm_X_PAO_i_N: + raise RuntimeError('X_PAO cannot be directly mapped as N content' + f'in asm2d_X_PAO_i_N = {asm_X_PAO_i_N} is not equal to' + f'adm_X_PAO_i_N = {adm_X_PAO_i_N}') + + asm_X_PAO_i_P = cmps_asm.X_PAO.i_P + adm_X_PAO_i_P = cmps_adm.X_PAO.i_P + if asm_X_PAO_i_P != adm_X_PAO_i_P: + raise RuntimeError('X_PAO cannot be directly mapped as P content' + f'in asm2d_X_PAO_i_P = {asm_X_PAO_i_P} is not equal to' + f'adm_X_PAO_i_P = {adm_X_PAO_i_P}') + + # Checks not required for X_PP as measured as P in both, with i_COD = i_N = 0 + # Checks not required for X_PHA as measured as COD in both, with i_N = i_P = 0 + + adm_ions_idx = cmps_adm.indices(['S_IN', 'S_IP', 'S_IC', 'S_cat', 'S_an']) + + frac_deg = self.frac_deg + alpha_IP = self.alpha_IP + alpha_IN = self.alpha_IN + alpha_IC = self.alpha_IC + proton_charge = 10**(-self.pKa[0]+self.pH) - 10**(-self.pH) # self.pKa[0] is pKw + f_corr = self.balance_cod_tkn_tp + + def asm2adm(asm_vals): + # S_I, S_S, X_I, X_S, X_BH, X_BA, X_P, S_O, S_NO, S_NH, S_ND, X_ND, S_ALK, S_N2, H2O = asm_vals + + S_O2, S_N2, S_NH4, S_NO3, S_PO4, S_F, S_A, S_I, S_ALK, X_I, X_S, X_H, \ + X_PAO, X_PP, X_PHA, X_AUT, X_MeOH, X_MeP, H2O = asm_vals + + # Step 0: charged component snapshot (# pg. 84 of IWA ASM textbook) + _sno3 = S_NO3 + _snh4 = S_NH4 + _salk = S_ALK + _spo4 = S_PO4 + _sa = S_A + _xpp = X_PP + + # Step 1: remove any remaining COD demand + O2_coddm = S_O2 + NO3_coddm = -S_NO3*S_NO3_i_COD + + # cod_spl = S_S + X_S + X_BH + X_BA + # Replacing S_S with S_F + S_A (IWA ASM textbook) + + cod_spl = (S_A + S_F) + X_S + (X_H + X_AUT) + + # bioN = X_BH*X_BH_i_N + X_BA*X_BA_i_N + + bioN = X_H*X_H_i_N + X_AUT*X_AUT_i_N + bioP = X_H*X_H_i_P + X_AUT*X_AUT_i_P + + # To be used in Step 2 + S_ND_asm1 = S_F*S_F_i_N #S_ND (in asm1) equals the N content in S_F + # To be used in Step 3 + X_ND_asm1 = X_S*X_S_i_N #X_ND (in asm1) equals the N content in X_S + # To be used in Step 5 (a) + X_S_P = X_S*X_S_i_P + # To be used in Step 5 (b) + S_F_P = S_F*S_F_i_P + + if cod_spl <= O2_coddm: + S_O2 = O2_coddm - cod_spl + S_F = S_A = X_S = X_H = X_AUT = 0 + elif cod_spl <= O2_coddm + NO3_coddm: + S_O2 = 0 + S_NO3 = -(O2_coddm + NO3_coddm - cod_spl)/S_NO3_i_COD + S_A = S_F = X_S = X_H = X_AUT = 0 + else: + S_A -= O2_coddm + NO3_coddm + if S_A < 0: + S_F += S_A + S_A = 0 + if S_F < 0: + X_S += S_F + S_F = 0 + if X_S < 0: + X_H += X_S + X_S = 0 + if X_H < 0: + X_AUT += X_H + X_H = 0 + S_O2 = S_NO3 = 0 + + # Step 2: convert any readily biodegradable + # COD and TKN into amino acids and sugars + + # S_S (in asm1) equals to the sum of S_F and S_A (pg. 82 IWA ASM models handbook) + S_S_asm1 = S_F + S_A + + # First we calculate the amount of amino acid required in ADM1 + # if all available soluble organic N can be mapped to amino acid + req_scod = S_ND_asm1 / S_aa_i_N + + # if available S_S is not enough to fulfill that amino acid requirement + if S_S_asm1 < req_scod: + # then all available S_S is mapped to amino acids + S_aa = S_S_asm1 + # and no S_S would be available for conversion to sugars + S_su = 0 + # This needs to be followed by a corresponding loss in soluble organic N + S_ND_asm1 -= S_aa * S_aa_i_N + # if available S_S is more than enough to fulfill that amino acid requirement + else: + # All soluble organic N will be mapped to amino acid + S_aa = req_scod + # The line above implies that a certain portion of S_S would also be consumed to form amino acid + # The S_S which is left would form sugar + # In simpler terms; S_S = S_S - S_aa; S_su = S_S + S_su = S_S_asm1 - S_aa + # All soluble organic N would thus be consumed in amino acid formation + S_ND_asm1 = 0 + + # Step 3: convert slowly biodegradable COD and TKN + # into proteins, lipids, and carbohydrates + + # First we calculate the amount of protein required in ADM1 + # if all available particulate organic N can be mapped to amino acid + req_xcod = X_ND_asm1 / X_pr_i_N + # Since X_pr_i_N >> X_pr_i_P there's no need to check req_xcod for N and P separately (CONFIRM LATER 05/16) + + # if available X_S is not enough to fulfill that protein requirement + if X_S < req_xcod: + # then all available X_S is mapped to amino acids + X_pr = X_S + # and no X_S would be available for conversion to lipid or carbohydrates + X_li = X_ch = 0 + # This needs to be followed by a corresponding loss in particulate organic N + X_ND_asm1 -= X_pr * X_pr_i_N + + # For P balance (CONFIRM LATER 05/16) + # This needs to be followed by a corresponding loss in particulate organic N + X_S_P -= X_pr * X_pr_i_P + + # if available X_S is more than enough to fulfill that protein requirement + else: + # All particulate organic N will be mapped to amino acid + X_pr = req_xcod + # The line above implies that a certain portion of X_S would also be consumed to form protein + # The X_S which is left would form lipid and carbohydrates in a percentage define by the user + X_li = self.xs_to_li * (X_S - X_pr) + X_ch = (X_S - X_pr) - X_li + # All particulate organic N would thus be consumed in amino acid formation + X_ND_asm1 = 0 + + # For P balance (CONFIRM LATER 05/16) + # This needs to be followed by a corresponding loss in particulate organic N + X_S_P -= X_pr * X_pr_i_P + + # Step 4: convert active biomass into protein, lipids, + # carbohydrates and potentially particulate TKN + + # First the amount of biomass N/P available for protein, lipid etc is determined + # For this calculation, from total biomass N available the amount + # of particulate inert N/P expected in ADM1 is subtracted + + available_bioN = bioN - (X_H + X_AUT) * (1-frac_deg) * adm_X_I_i_N + if available_bioN < 0: + raise RuntimeError('Not enough N in X_H and X_AUT to fully convert ' + 'the non-biodegradable portion into X_I in ADM1.') + + available_bioP = bioP - (X_H + X_AUT) * (1-frac_deg) * adm_X_I_i_P + if available_bioP < 0: + raise RuntimeError('Not enough P in X_H and X_AUT to fully convert ' + 'the non-biodegradable portion into X_I in ADM1.') + + # Then the amount of biomass N/P required for biomass conversion to protein is determined + req_bioN = (X_H + X_AUT) * frac_deg * X_pr_i_N + req_bioP = (X_H + X_AUT) * frac_deg * X_pr_i_P + + # Case I: if both available biomass N/P and particulate organic N/P is greater than + # required biomass N/P for conversion to protein + if available_bioN + X_ND_asm1 >= req_bioN and available_bioP + X_S_P >= req_bioP: + # then all biodegradable biomass N/P (corrsponding to protein demand) is converted to protein + X_pr += (X_H + X_AUT) * frac_deg + # the remaining biomass N/P is transfered as organic N/P + X_ND_asm1 += available_bioN - req_bioN + X_S_P += available_bioP - req_bioP + + # Case II: if available biomass N and particulate organic N is less than + # required biomass N for conversion to protein, but available biomass P and + # particulate organic P is greater than required biomass P for conversion to protein + + # Case III: if available biomass P and particulate organic P is less than + # required biomass P for conversion to protein, but available biomass N and + # particulate organic N is greater than required biomass N for conversion to protein + + # Case IV: if both available biomass N/P and particulate organic N/P is less than + # required biomass N/P for conversion to protein + else: + + if (available_bioP + X_S_P)/X_pr_i_P < (available_bioN + X_ND_asm1)/X_pr_i_N: + # all available P and particulate organic P is converted to protein + bio2pr = (available_bioP + X_S_P)/X_pr_i_P + X_pr += bio2pr + # Biodegradable biomass available after conversion to protein is calculated + bio_to_split = (X_H + X_AUT) * frac_deg - bio2pr + # Part of the remaining biomass is mapped to lipid based on user defined value + bio_split_to_li = bio_to_split * self.bio_to_li + X_li += bio_split_to_li + # The other portion of the remanining biomass is mapped to carbohydrates + X_ch += (bio_to_split - bio_split_to_li) + # Since all organic P has been mapped to protein, none is left + X_S_P = 0 + + # the remaining biomass N is transfered as organic N + X_ND_asm1 += available_bioN - (bio2pr*X_pr_i_N) + + else: + # all available N and particulate organic N is converted to protein + bio2pr = (available_bioN + X_ND_asm1)/X_pr_i_N + X_pr += bio2pr + # Biodegradable biomass available after conversion to protein is calculated + bio_to_split = (X_H + X_AUT) * frac_deg - bio2pr + # Part of the remaining biomass is mapped to lipid based on user defined value + bio_split_to_li = bio_to_split * self.bio_to_li + X_li += bio_split_to_li + # The other portion of the remanining biomass is mapped to carbohydrates + X_ch += (bio_to_split - bio_split_to_li) + # Since all organic N has been mapped to protein, none is left + X_ND_asm1 = 0 + + # the remaining biomass P is transfered as organic P + X_S_P += available_bioP - (bio2pr*X_pr_i_P) + + + # Step 5: map particulate inerts + + # 5 (a) + # First determine the amount of particulate inert N/P available from ASM2d + xi_nsp_asm2d = X_I * asm_X_I_i_N + xi_psp_asm2d = X_I * asm_X_I_i_P + + # Then determine the amount of particulate inert N/P that could be produced + # in ADM1 given the ASM1 X_I + xi_ndm = X_I * adm_X_I_i_N + xi_pdm = X_I * adm_X_I_i_P + + # if particulate inert N available in ASM1 is greater than ADM1 demand + if xi_nsp_asm2d + X_ND_asm1 >= xi_ndm: + deficit = xi_ndm - xi_nsp_asm2d + # COD balance + X_I += (X_H+X_AUT) * (1-frac_deg) + # N balance + X_ND_asm1 -= deficit + # P balance + if xi_psp_asm2d + X_S_P >= xi_pdm: + deficit = xi_pdm - xi_psp_asm2d + X_S_P -= deficit + elif isclose(xi_psp_asm2d+X_S_P, xi_pdm, rel_tol=rtol, abs_tol=atol): + X_S_P = 0 + else: + raise RuntimeError('Not enough P in X_I, X_S to fully ' + 'convert X_I in ASM2d into X_I in ADM1.') + elif isclose(xi_nsp_asm2d+X_ND_asm1, xi_ndm, rel_tol=rtol, abs_tol=atol): + # COD balance + X_I += (X_H+X_AUT) * (1-frac_deg) + # N balance + X_ND_asm1 = 0 + # P balance + if xi_psp_asm2d + X_S_P >= xi_pdm: + deficit = xi_pdm - xi_psp_asm2d + X_S_P -= deficit + elif isclose(xi_psp_asm2d+X_S_P, xi_pdm, rel_tol=rtol, abs_tol=atol): + X_S_P = 0 + else: + raise RuntimeError('Not enough P in X_I, X_S to fully ' + 'convert X_I in ASM2d into X_I in ADM1.') + else: + # Since the N balance cannot hold, the P balance is not futher checked + raise RuntimeError('Not enough N in X_I, X_S to fully ' + 'convert X_I in ASM2d into X_I in ADM1.') + + # 5(b) + + # Then determine the amount of soluble inert N/P that could be produced + # in ADM1 given the ASM1 X_I + req_sn = S_I * adm_S_I_i_N + req_sp = S_I * adm_S_I_i_P + + supply_inert_n_asm2d = S_I * asm_S_I_i_N + + # N balance + if req_sn <= S_ND_asm1 + supply_inert_n_asm2d: + S_ND_asm1 -= (req_sn - supply_inert_n_asm2d) + supply_inert_n_asm2d = 0 + # P balance + if req_sp <= S_F_P: + S_F_P -= req_sp + elif req_sp <= S_F_P + X_S_P: + X_S_P -= (req_sp - S_F_P) + S_F_P = 0 + elif req_sp <= S_F_P + X_S_P + S_PO4: + S_PO4 -= (req_sp - S_F_P - X_S_P) + S_F_P = X_S_P = 0 + else: + warn('Additional soluble inert COD is mapped to S_su.') + # Can these be executed in this case? I think so + SI_cod = (S_F_P + X_S_P + S_PO4)/adm_S_I_i_P + S_su += S_I - SI_cod + S_I = SI_cod + S_F_P = X_S_P = S_PO4 = 0 + # Should I redo N balance here? + # N balance + elif req_sn <= S_ND_asm1 + X_ND_asm1 + supply_inert_n_asm2d: + X_ND_asm1 -= (req_sn - S_ND_asm1 - supply_inert_n_asm2d) + S_ND_asm1 = supply_inert_n_asm2d = 0 + # P balance + if req_sp <= S_F_P: + S_F_P -= req_sp + elif req_sp <= S_F_P + X_S_P: + X_S_P -= (req_sp - S_F_P) + S_F_P = 0 + elif req_sp <= S_F_P + X_S_P + S_PO4: + S_PO4 -= (req_sp - S_F_P - X_S_P) + S_F_P = X_S_P = 0 + else: + warn('Additional soluble inert COD is mapped to S_su.') + # Can these be executed in this case? I think so + SI_cod = (S_F_P + X_S_P + S_PO4)/adm_S_I_i_P + S_su += S_I - SI_cod + S_I = SI_cod + S_F_P = X_S_P = S_PO4 = 0 + # Should I redo N balance here? + # N balance + elif req_sn <= S_ND_asm1 + X_ND_asm1 + S_NH4 + supply_inert_n_asm2d: + S_NH4 -= (req_sn - S_ND_asm1 - X_ND_asm1 - supply_inert_n_asm2d) + S_ND_asm1 = X_ND_asm1 = supply_inert_n_asm2d = 0 + # P balance + if req_sp <= S_F_P: + S_F_P -= req_sp + elif req_sp <= S_F_P + X_S_P: + X_S_P -= (req_sp - S_F_P) + S_F_P = 0 + elif req_sp <= S_F_P + X_S_P + S_PO4: + S_PO4 -= (req_sp - S_F_P - X_S_P) + S_F_P = X_S_P = 0 + else: + warn('Additional soluble inert COD is mapped to S_su.') + # Can these be executed in this case? I think so + SI_cod = (S_F_P + X_S_P + S_PO4)/adm_S_I_i_P + S_su += S_I - SI_cod + S_I = SI_cod + S_F_P = X_S_P = S_PO4 = 0 + # Should I redo N balance here? + elif req_sp <= S_F_P or req_sp <= S_F_P + X_S_P or req_sp <= S_F_P + X_S_P + S_PO4: + warn('Additional soluble inert COD is mapped to S_su.') + SI_cod = (S_ND_asm1 + X_ND_asm1 + S_NH4 + supply_inert_n_asm2d)/adm_S_I_i_N + S_su += S_I - SI_cod + S_I = SI_cod + S_ND_asm1 = X_ND_asm1 = S_NH4 = supply_inert_n_asm2d = 0 + req_sp = S_I * adm_S_I_i_P + if req_sp <= S_F_P: + S_F_P -= req_sp + elif req_sp <= S_F_P + X_S_P: + X_S_P -= (req_sp - S_F_P) + S_F_P = 0 + elif req_sp <= S_F_P + X_S_P + S_PO4: + S_PO4 -= (req_sp - S_F_P - X_S_P) + S_F_P = X_S_P = 0 + else: + if (S_ND_asm1 + X_ND_asm1 + S_NH4 + supply_inert_n_asm2d)/adm_S_I_i_N < (S_F_P + X_S_P + S_PO4)/adm_S_I_i_P: + warn('Additional soluble inert COD is mapped to S_su.') + SI_cod = (S_ND_asm1 + X_ND_asm1 + S_NH4 + supply_inert_n_asm2d)/adm_S_I_i_N + S_su += S_I - SI_cod + S_I = SI_cod + S_ND_asm1 = X_ND_asm1 = S_NH4 = supply_inert_n_asm2d = 0 + + req_sp = S_I * adm_S_I_i_P + S_PO4 -= (req_sp - S_F_P - X_S_P) + S_F_P = X_S_P = 0 + else: + warn('Additional soluble inert COD is mapped to S_su.') + SI_cod = (S_F_P + X_S_P + S_PO4)/adm_S_I_i_P + S_su += S_I - SI_cod + S_I = SI_cod + S_F_P = X_S_P = S_PO4 = 0 + + req_sn = S_I * adm_S_I_i_N + S_NH4 -= (req_sn - S_ND_asm1 - X_ND_asm1 - supply_inert_n_asm2d) + S_ND_asm1 = X_ND_asm1 = supply_inert_n_asm2d = 0 + + # Step 6: Step map any remaining TKN/P + S_IN = S_ND_asm1 + X_ND_asm1 + S_NH4 + supply_inert_n_asm2d + S_IP = S_F_P + X_S_P + S_PO4 + + # Step 8: check COD and TKN balance + # has TKN: S_aa, S_IN, S_I, X_pr, X_I + S_IC = S_cat = S_an = 0 + + # When mapping components directly in Step 9 ensure the values of + # cmps.i_N, cmps.i_P, and cmps.i_COD are same in both ASM2d and ADM1 + + # Step 9: Mapping common state variables directly + # The next three commented lines are executed when outputting + # array of ADM1 components + # X_PAO (ADM1) = X_PAO (ASM2d) + # X_PP (ADM1) = X_PP (ASM2d) + # X_PHA (ADM1) = X_PHA (ASM2d) + # X_MeOH (ADM1) = X_MeOH (ASM2d) + # X_MeP (ADM1) = X_MeP (ASM2d) + + adm_vals = np.array([ + S_su, S_aa, + 0, 0, 0, 0, 0, # S_fa, S_va, S_bu, S_pro, S_ac, + 0, 0, # S_h2, S_ch4, + S_IC, S_IN, S_IP, S_I, + X_ch, X_pr, X_li, + 0, 0, 0, 0, 0, 0, 0, # X_su, X_aa, X_fa, X_c4, X_pro, X_ac, X_h2, + X_I, X_PHA, X_PP, X_PAO, + 0, 0, # S_K, S_Mg, + X_MeOH, X_MeP, + S_cat, S_an, H2O]) + + adm_vals = f_corr(asm_vals, adm_vals) + + # Step 7: charge balance + asm_charge_tot = - _sa/64 + _snh4/14 - _sno3/14 - 1.5*_spo4/31 - _salk - _xpp/31 #Based on page 84 of IWA ASM handbook + #!!! charge balance should technically include VFAs, + # but VFAs concentrations are assumed zero per previous steps?? + S_IN = adm_vals[adm_ions_idx[0]] + S_IP = adm_vals[adm_ions_idx[1]] + #!!! charge balance from ADM1 should technically include S_K, and S_Mg, + #but since both are zero, it is acceptable + S_IC = (asm_charge_tot -S_IN*alpha_IN -S_IP*alpha_IP)/alpha_IC + net_Scat = asm_charge_tot + proton_charge + if net_Scat > 0: + S_cat = net_Scat + S_an = 0 + else: + S_cat = 0 + S_an = -net_Scat + + adm_vals[adm_ions_idx[2:]] = [S_IC, S_cat, S_an] + + return adm_vals + + self._reactions = asm2adm diff --git a/qsdsan/sanunits/_membrane_gas_extraction.py b/qsdsan/sanunits/_membrane_gas_extraction.py new file mode 100644 index 00000000..f28aec98 --- /dev/null +++ b/qsdsan/sanunits/_membrane_gas_extraction.py @@ -0,0 +1,566 @@ +# -*- coding: utf-8 -*- +""" +QSDsan: Quantitative Sustainable Design for sanitation and resource recovery systems + +This module is developed by: + + Ian Song + + Saumitra Rai + + Joy Zhang + +This module is under the University of Illinois/NCSA Open Source License. +Please refer to https://github.com/QSD-Group/QSDsan/blob/main/LICENSE.txt +for license details. +""" + +from qsdsan import SanUnit +import numpy as np + +__all__ = ('GasExtractionMembrane',) + +class GasExtractionMembrane(SanUnit): + + """ + Gas Extraction Membrane + + Parameters + ---------- + ID : str + ID for the Gas Extraction Membrane. The default is 'GEM'. + ins : class:`WasteStream` + Influent to the Gas Extraction Membrane. Expected number of influent is 3. + outs : class:`WasteStream` + Gas and Liquid streams are expected effluents. + FiberID : float + Inner diameter of the membrane [m]. + FiberOD : float + Outer diameter of the membrane [m]. + NumTubes : float + The number of fibers in the membrane. + ShellDia : float + The diameter of the shell [m]. + SurfArea : float + Surface area of membrane [m^2]. + GasID : array + Array containing IDs of gases to be extracted. + PVac : float + Operating vaccum pressure in the membrane [Pa]. + segs : float + Number of segments considered in the membrane. + GasPerm : dict + Dictionary of permeability of gases. + HenryPreFac : dict + Dictionary of Henry's Law Factor for gases. + HenrySlope : dict + Dictionary of Henry's Slope for gases. + WilkeChang : dict + Dictionary of Wilke Chang correlation for gases. + """ + + _N_ins = 1 + _N_outs = 2 + + # All gas properties are in form of dictionaries + + _GasPerm = { + 'H2': 650*(3.35e-16), + 'O2': 600*(3.35e-16), + 'N2': 280*(3.35e-16), + 'CO2': 3250*(3.35e-16), + 'CH4': 950*(3.35e-16), + 'H2O': 36000*(3.35e-16) + } + + _HenryPreFac = { + 'H2': 7.8e-6, + 'O2': 1.2e-5, + 'N2': 6e-6, + 'CO2' : 3.5e-4, + 'CH4': 1.3e-5, + 'H2O': 1 + } + + _HenrySlope = { + 'H2': 640, + 'O2': 1800, + 'N2': 1300, + 'CO2' : 2600, + 'CH4' : 1900, + 'H2O': 1 + } + + _WilkeChang = { + 'H2': 9.84, + 'O2': 1.90, + 'N2': 1.77, + 'CO2': 2.6, + 'CH4': 2.2, + 'H2O': 1 + } + + # Constructor: Initialize the instance variables + def __init__(self, ID='GEM', ins=None, outs=(), thermo=None, isdynamic=True, + init_with='WasteStream', F_BM_default=None, FiberID=190e-6, + FiberOD=300e-6, NumTubes=1512, ShellDia=1.89e-2, SurfArea=0.1199, + GasID = ['H2', 'O2', 'N2', 'CO2', 'CH4', 'H2O'], PVac = 97.325, + segs = 50, GasPerm = {}, HenryPreFac = {}, HenrySlope = {}, + WilkeChang = {}): + + SanUnit.__init__(self, ID, ins, outs, thermo, isdynamic=isdynamic, + init_with=init_with, F_BM_default=F_BM_default) + self.FiberID = FiberID # Fiber Inner Diameter [m] + self.FiberOD = FiberOD # Fiber Outer Diameter [m] + self.MemThick = (FiberOD - FiberID)/2 # Membrane Thickness [m] + self.NumTubes = NumTubes # Number of Tubes [] + self.ShellDia = ShellDia # Shell Diameter [m] + self.SurfArea = SurfArea # Surface Area [m^2] + self.GasID = GasID # IDs of gas used in the process + self.PVac = PVac # Operating Vacuum Pressure [-kPa] + self.segs = segs # Number of segments + #self.Volume = VolBatchTank # Volume of the bioreactor (Don't think this is needed) + + self.indexer = GasID.index + dct_gas_perm = GasPerm or self._GasPerm + self.set_GasPerm(**dct_gas_perm) + dct_gas_hpf = HenryPreFac or self._HenryPreFac + self.set_HenryPreFac(**dct_gas_hpf) + dct_gas_hs = HenrySlope or self._HenrySlope + self.set_HenrySlope(**dct_gas_hs) + dct_gas_wc = WilkeChang or self._WilkeChang + self.set_WilkeChang(**dct_gas_wc) + + cmps = self.thermo.chemicals + # self.indexer = cmps.index + # self.idx ensures that the indexing in further code is only for gases + # and not all components in the influent + self.idx = cmps.indices(self.GasID) + # to save the index of water in the array of gases, to be used later + # for i, ID in enumerate(GasID): + # if ID == 'H2O': + # self.h2o_j = i + # break + #!!! alternatively + self.h2o_j = GasID.index('H2O') + self.gas_mass2mol = (cmps.i_mass/cmps.chem_MW)[self.idx] + + @property + def FiberOD(self): + return self._FiberOD + + @FiberOD.setter + def FiberOD(self, FiberOD): + if FiberOD is not None: + self._FiberOD = FiberOD + else: + raise ValueError('FiberOD expected from user') + + @property + def FiberID(self): + return self._FiberID + + @FiberID.setter + def FiberID(self, FiberID): + if FiberID is not None: + self._FiberID = FiberID + else: + raise ValueError('Inner Diameter of fiber expected from user') + + @property + def NumTubes(self): + return self._NumTubes + + @NumTubes.setter + def NumTubes(self, NumTubes): + if NumTubes is not None: + self._NumTubes = NumTubes + else: + raise ValueError('Number of tubes expected from user') + + @property + def ShellDia(self): + return self._ShellDia + + @ShellDia.setter + def ShellDia(self, ShellDia): + if ShellDia is not None: + self._ShellDia = ShellDia + else: + raise ValueError('Diameter of the shell expected from user') + + @property + def SurfArea(self): + return self._SurfArea + + @SurfArea.setter + def SurfArea(self, SurfArea): + if SurfArea is not None: + self._SurfArea = SurfArea + else: + raise ValueError('Surface Area of Membrane expected from user') + + # Calculate the volume fraction of the lumen to the shell. + @property + def VolFrac(self): + lumenVol = self.NumTubes*np.pi*((self.FiberID/2)**2) + shellVol = (np.pi*((self.ShellDia/2)**2)) - self.NumTubes*np.pi*((self.FiberOD/2)**2) + return lumenVol/shellVol + + # Calculate the effective length of the membrane by taking the ratio of the + # declared surface area, and dividing it by the area per length of the tube. + @property + def Length(self): + memSurf = self.NumTubes*np.pi*self.FiberOD + return self.SurfArea/memSurf + + # Determine the Shell cross-sectional area + @property + def ShellAc(self): + return np.pi*((self.ShellDia/2)**2) - self.NumTubes*np.pi*((self.FiberOD/2)**2) + + @property + def HeL(self): + # Define the constant properties of gas + TRefH = 298.15 # Reference T for Henry's Law [K] + NIST_HeL = self._hpf*(np.exp(self._hs*(1/self.ins[0].T - 1/TRefH))) + return 1/NIST_HeL + + @property + def Diff(self): + inf, = self.ins + cmps = inf.components + self._Vc = np.array([cmp.Vc for cmp in cmps]) + Phi = self._wc # Wilke Chang Correlation Factor + + # Define the constant properties of gas + TRefMu = 300 # Reference T for H2O Viscosity [K] + MWH2O = cmps.H2O.MW # Molar Weight of the Solvent [Da] + + # Reduced Temp for Water Viscosity + Tb = self.ins[0].T/TRefMu + + # Temperature Dependent Water Viscosity [cP] + mu = (1*10**(-3))*(280.68*(Tb**(-1.9)) + 511.45*(Tb**(-7.7)) + 61.131*(Tb**(-19.6)) + 0.45903*(Tb**(-40)) ) + + # Molar Volume at Normal Boiling Point [cm^3/mol] + # Yoel: Critical models are not very reliable + # Yoel: Eqns of State or other models already available in QSDsan can be looked into + V1 = 0.285*(self._Vc*1000000)**(1.048) # unit conversion from m^3/mol (QSDsan) to cm^3/mol (here) + # Diffusion Coefficient [m^2/s] + D = 0.0001*(7.4*10**(-8))*np.sqrt(MWH2O*Phi)*self.ins[0].T/(mu*V1**(0.6)) + return D + + def set_GasPerm(self, **kwargs): + self.set_prop('_gasp', **kwargs) + + def set_WilkeChang(self, **kwargs): + self.set_prop('_wc', **kwargs) + + def set_HenryPreFac(self, **kwargs): + self.set_prop('_hpf', **kwargs) + + def set_HenrySlope(self, **kwargs): + self.set_prop('_hs', **kwargs) + + def set_prop(self, attr_name, **kwargs): + idxr = self.indexer + try: attr = getattr(self, attr_name) + except: attr = self.__dict__[attr_name] = np.zeros(len(self.chemicals)) + for k, v in kwargs.items(): + attr[idxr(k)] = v + + def _init_state(self): + # inf, = self.ins + # cmps = inf.components + # C = self._ins_QC[0,:-1]/cmps.chem_MW*cmps.i_mass + # Cs = C[self.idx] #idx selects only gases + # Seg = self.segs + numGas = len(self.GasID) + # self._state = np.zeros(2*Seg*numGas) + # for i in range(0, 2*Seg*numGas, 2*numGas): + # for j in range(numGas): + # self._state[j+i] = Cs[j] + seg_i = np.zeros(2*numGas) + seg_i[:numGas] = self._ins_QC[0,self.idx]*self.gas_mass2mol + self._state = np.tile(seg_i, self.segs) + self._dstate = self._state*0 + + def _update_state(self): + inf, = self.ins + gas, liq = self.outs + idx = self.idx + numGas = len(self.GasID) + mass2mol = self.gas_mass2mol + y = self._state + # Need to add effluent streams for liquid (this includes all cmps of influent ws) and gas. + # The multiplication of any of the first n-1 array element with last element should give out g/day values. + + if gas.state is None: + cmps = inf.components + gas.state = np.zeros(len(cmps) + 1) + liq.state = np.zeros(len(cmps) + 1) + + liq.state[:] = inf.state + liq.state[idx] = y[-2*numGas: -numGas]/mass2mol + + gas.state[-1] = 1 + gas.state[idx] = (y[:numGas] - y[-2*numGas: -numGas])/mass2mol * liq.state[-1] + + # The of the effluent gas in extraction membrane is the difference + # between lumen concentration in the last and first segment + #!!! why? It seems this only holds when the unit is at steady state + # gas_state_in_unit = y[:numGas] - y[-2*numGas: -numGas] # in mol/m3 + # Molar_flow_gases = self._ins_QC[0,-1]*gas_state_in_unit # (m3/day)*(mol/m3) = mol/day + # Mass_flow_gases = Molar_flow_gases*cmps.chem_MW[idx] #(mol/day)*(g/mol) = (g/day) + + # self._outs[0].state[idx] = Mass_flow_gases # (g/day) + # self._outs[0].state[-1] = 1 #(So the mutiplication with Q would give out g/day values) + + # # The state of effluent Liquid stream is simply the concentration of + # # the last lumen segment in the extraction membrane + # liquid_state_in_unit = y[-2*numGas: -numGas] # in mol/m3 + # liquid_state_in_unit = (liquid_state_in_unit*cmps.chem_MW[idx])/cmps.i_mass[idx] # (mol/m3)*(g/mol) = g/m3 = mg/l + + # self._outs[1].state[:] = self._ins_QC[0] + # self._outs[1].state[idx] = liquid_state_in_unit + + + def _update_dstate(self): + inf, = self.ins + gas, liq = self.outs + numGas = len(self.GasID) + mass2mol = self.gas_mass2mol + idx = self.idx + dy = self._dstate + + if gas.dstate is None: + cmps = inf.components + gas.dstate = np.zeros(len(cmps) + 1) + liq.dstate = np.zeros(len(cmps) + 1) + + liq.dstate[:] = inf.dstate + liq.dstate[idx] = dy[-2*numGas: -numGas]/mass2mol + + #!!! this is probably wrong + gas.dstate[idx] = (dy[:numGas] - dy[-2*numGas: -numGas])/mass2mol * liq.dstate[-1] + + # self._outs[0].dstate = np.zeros(len(cmps) + 1) + # # The of the effluent gas in extraction membrane is the difference + # # between lumen concentration in the last and first segment + # gas_dstate_in_unit = self._dstate[ :numGas] - self._dstate[ -2*numGas: -numGas]# in mol/m3 + # Molar_dflow_gases = self._ins_dQC[0,-1]*gas_dstate_in_unit # (m3/day)*(mol/m3) = mol/day + # Mass_dflow_gases = Molar_dflow_gases*cmps.chem_MW[self.idx] #(mol/day)*(g/mol) = (g/day) + + # self._outs[0].dstate[idx] = Mass_dflow_gases # (g/day) + # self._outs[0].dstate[-1] = 0 # Just differentiating constant 1 to 0 + + # self._outs[1].dstate = np.zeros(len(cmps) + 1) + # # The state of effluent Liquid stream is simply the concentration of + # # the last lumen segment in the extraction membrane + # liquid_dstate_in_unit = self._dstate[-2*numGas: -numGas] # in mol/m3 + # liquid_dstate_in_unit = (liquid_dstate_in_unit*cmps.chem_MW[self.idx])/cmps.i_mass[self.idx] # (mol/m3)*(g/mol) = g/m3 = mg/l + + # self._outs[1].dstate = self._ins_dQC[0] + # self._outs[1].dstate[idx] = liquid_dstate_in_unit + + def _run(self): + s_in, = self.ins + gas, liq = self.outs + gas.phase = 'g' + liq.copy_like(s_in) + + + @property + def ODE(self): + if self._ODE is None: + self._compile_ODE() + return self._ODE + + def _compile_ODE(self): + # Synthesizes the ODEs to simulate a batch reactor with side-flow gas extraction. The code takes in an object of class Membrane (Mem) and an array of objects of class Gas (GasVec). It also takes in an array of experimental conditions ExpCond. + + # Extract Operating Parameters from ExpCond + # Q = self.ins[0].F_vol # Volumetric Flowrate [m3/sec] + T = self.ins[0].T # Temperature [K] + P = self.PVac*1000 # Vacuum Pressure [Pa] + #V = self.Volume # Volume of the Batch Tank [L] + + idx = self.idx + h2o_j = self.h2o_j + mass2mol = self.gas_mass2mol + + # Calculate vapor pressure of water at operating temperature + TCel = T-273.15 # Temperature [C] + PVapH2O = np.exp(34.494-(4924.99/(TCel+237.1)))/((TCel+105)**1.57) # Saturated Vapor Pressure of Water [Pa] + + # Define Constants + R = 8.314 # Universal Gas Constant [J/K mol] + + # Extract Membrane Properties + D = self.FiberID # Membrane Fiber ID [m] + l = self.MemThick # Membrane Thickness [m] + num_tubes = self.NumTubes # Number of Tubes in the Module [] + L = self.Length # Membrane Length [m] + Segs = self.segs # Number of segments? Ask Ian + vFrac = self.VolFrac # Lumen/Shell Volume Fraction [m^3/m^3] + + # Pre-allocate vectors for gas thermophysical properties + numGas = len(self.GasID) + # numVec = 2*numGas + + inf, = self.ins + # cmps = inf.components + # Extract Gas Properties + #for i in range(0,numGas): + + #Diff[i] = GasVec[i].Diff() + Diff = self.Diff + + #Perm_SI[i] = Mem.PermDict[GasVec[i].Name] + Perm_SI = self._gasp + + #H[i] = GasVec[i].HeL() + H = self.HeL + + # Diff = np.array([0.0265e-7, 0.0296e-7, 0.0253e-7, 0.3199e-7]) + # Calculate dx and u + dx = L/Segs # Length of Segments [m] + # u = Q/((np.pi*D**2/4)*num_tubes) # Linear Flow Velocity [m/s] + cross_section_A = ((np.pi*D**2/4)*num_tubes) + + # Calculate the Kinematic Viscosity of Water + Tb = T/300 # Reduced Temperature [] + mu = (1e-6)*(280.68*(Tb**(-1.9)) + 511.45*(Tb**(-7.7)) + 61.131*(Tb**(-19.6)) + 0.45903*(Tb**(-40))) # Absolute Viscosity of Water [Pa s] + rho = (-13.851 + 0.64038*T - 1.9124e-3*T**2 + 1.8211e-6*T**3)*18 # Liquid Density of Water [kg/m^3] from DIPPR + nu = mu/rho # Kinematic Viscosity of Water [m^2/s] + + #!!! These variables should be calculated within ODE because it is + #!!! dependent on the influent Q, which could change during simulation + #!!! unless we can assume this change is negligible + + # # Calculate the dimensionless numbers + # # Reynolds + # Re = u*D/nu + # Schmidt + Sc = nu/Diff + # # Sherwood + # Sh = 1.615*(Re*Sc*D/L)**(1/3) + + # # Calculate Mass Transfer Coefficients + KMem = Perm_SI/l + # KLiq = Sh*Diff/D + # KTot = 1/(1/KLiq + 1/(KMem*H)) + # # for j in range(0, numGas): + # # #if GasVec[j].Name == 'H2O': + # # if j == self.h2o_j: + # # KTot[j] = KMem[j] + # #!!! alternatively + # KTot[self.h2o_j] = KMem[self.h2o_j] + + # Initialize + # C = self._state + dC_lumen = np.zeros((Segs, numGas)) + dC_shell = dC_lumen.copy() + + sumCp_init = P/(R*T) + # sumCp_fin = np.zeros(Segs) + + # C = self._ins_QC[0,:-1]/cmps.chem_MW*cmps.i_mass # conc. in mol/m^3 as defined by Ian + # Cs = C[self.idx] #self.idx ensures its only for gases + + dC = self._dstate + _update_dstate = self._update_dstate + + + def dy_dt(t, QC_ins, QC, dQC_ins): + # QC is exactly 'the state' as we define in _init_ + # C = QC + Q = QC_ins[0,-1]/24/3600 + C_in = QC_ins[0, idx] * mass2mol # mol/m^3 + u = Q/cross_section_A + + # Calculate the dimensionless numbers + # Reynolds + Re = u*D/nu + # Sherwood + Sh = 1.615*(Re*Sc*D/L)**(1/3) + + # Calculate Mass Transfer Coefficients + KLiq = Sh*Diff/D + KTot = 1/(1/KLiq + 1/(KMem*H)) + KTot[h2o_j] = KMem[h2o_j] + + QC = QC.reshape((Segs, numGas*2)) + C_lumen = QC[:,:numGas] + C_shell = QC[:,numGas:] + #!!! alternatively + + # # For the first segment: + # for j in range(0, numGas): + # #if GasVec[j].Name == 'H2O': + # if j == h2o_j: + # dC[j+numGas] = (KTot[j]/(D/4))*(PVapH2O- (C[j+numGas]/sumCp_init)*P)*vFrac + # dC[j] = 0 + # else: + # # dC[j] = (u/dx)*(Cs[j] - C[j]) - (KTot[j]/(D/4))*(C[j] - (C[j+numGas]/sumCp_init)*P/H[j]) + # #!!! It seems like Cs should be the dissolved gas concentration in influent + # dC[j] = (u/dx)*(C_in[j] - C[j]) - (KTot[j]/(D/4))*(C[j] - (C[j+numGas]/sumCp_init)*P/H[j]) + # dC[j+numGas] = (KTot[j]/(D/4))*(C[j]-(C[j+numGas]/sumCp_init)*P/H[j])*vFrac + + # # Calculate the total gas concentration in the shell after the change + # sumCp_fin[0] += C[j+numGas] + dC[j+numGas] + + transmembrane = (KTot/(D/4))*(C_lumen - C_shell/sumCp_init*P/H) + dC_lumen[0] = (u/dx)*(C_in - C_lumen[0]) - transmembrane[0] + dC_lumen[1:] = (u/dx)*(C_lumen[:-1] - C_lumen[1:]) - transmembrane[1:] + dC_lumen[:,h2o_j] = 0 + dC_shell[:] = transmembrane*vFrac + dC_shell[:,h2o_j] = (KTot[h2o_j]/(D/4))*(PVapH2O - (C_shell[:,h2o_j]/sumCp_init)*P)*vFrac + + # sumCp_fin = np.sum(C_shell+dC_shell, axis=1) + + + # for i in range(1,Segs): + # # For the remaining segments: + # # Calculate the rate of change of the shell and lumen for all remaining segments. + # for j in range(0, numGas): + + # # Lumen + # dC[numVec*(i)+j] = (u/dx)*(C[numVec*(i-1)+j] - C[numVec*(i)+j]) - (KTot[j]/(D/4))*(C[numVec*(i)+j] - (C[numVec*(i)+j+numGas]/sumCp_init)*(P/H[j])) + + # # Shell + # dC[numVec*(i)+j+numGas] = (KTot[j]/(D/4))*(C[numVec*(i)+j] - (C[numVec*(i)+j+numGas]/sumCp_init)*(P/H[j]))*vFrac + + # # If the gas is H2O, then it follows a different formula: + # #if GasVec[j].Name == 'H2O': + # if j == self.h2o_j: + # dC[numVec*i+j+numGas] = (KTot[j]/(D/4))*(PVapH2O-(C[numVec*i+j+numGas]/sumCp_init)*P)*vFrac + # dC[numVec*i+j] = 0 + + # # Calculate the total gas concentration in the shell after the change + # sumCp_fin[i] += C[numVec*(i)+j+numGas] + dC[numVec*(i)+j+numGas] + + # Re-scale the shell concentration so that vacuum pressure stays constant during the entire process. Given the change of concentration that we have calculated above for the shell, we can re-scale the shell concentrations with the sumCp at each segment. + + # This FOR LOOP maintains consistent pressure in the shell + # for i in range(0, Segs): + # for j in range(0, numGas): + # # Calculate the new concentration of gases in the shell + # newCp = (C[numVec*(i)+j+numGas] + dC[numVec*(i)+j+numGas]) + + # # Re-scale the concentration to vacuum pressure + # newCp = (newCp/sumCp_fin[i])*P/(R*T) + + # # Calculate the actual difference of concentration that the function will output + # dC[numVec*(i)+j+numGas] = newCp- C[numVec*(i)+j+numGas] + # # Return the difference in concentration + + # #return dC + + new_C_shell = C_shell + dC_shell + sumCp_fin = np.sum(new_C_shell, axis=1) + dC_shell[:] = np.diag(sumCp_init/sumCp_fin) @ new_C_shell - C_shell + dC[:] = np.hstack((dC_lumen, dC_shell)).flatten() + _update_dstate() + self._ODE = dy_dt \ No newline at end of file diff --git a/qsdsan/sanunits/_pumping.py b/qsdsan/sanunits/_pumping.py index 9d5e74dd..0443339f 100644 --- a/qsdsan/sanunits/_pumping.py +++ b/qsdsan/sanunits/_pumping.py @@ -716,7 +716,7 @@ def design_sludge(self, Q_mgd=None, N_pump=None, **kwargs): val_dct = dict( L_s=50, # length of suction pipe, [ft] L_d=50, # length of discharge pipe, [ft] - H_ts=0., # H_ds_LIFT (D) - H_ss_LIFT (0) + H_ts=5., # H_ds_LIFT (D) - H_ss_LIFT (0) H_p=0. # no pressure ) val_dct.update(kwargs) diff --git a/qsdsan/sanunits/_sludge_treatment.py b/qsdsan/sanunits/_sludge_treatment.py new file mode 100644 index 00000000..3c395be5 --- /dev/null +++ b/qsdsan/sanunits/_sludge_treatment.py @@ -0,0 +1,1228 @@ +# -*- coding: utf-8 -*- +''' +QSDsan: Quantitative Sustainable Design for sanitation and resource recovery systems + +This module is developed by: + + Saumitra Rai + + Joy Zhang + +This module is under the University of Illinois/NCSA Open Source License. +Please refer to https://github.com/QSD-Group/QSDsan/blob/main/LICENSE.txt +for license details. +''' + +from .. import SanUnit, WasteStream +import numpy as np +from ..sanunits import WWTpump +from warnings import warn +from ..sanunits._pumping import ( + default_F_BM as default_WWTpump_F_BM, + default_equipment_lifetime as default_WWTpump_equipment_lifetime, + ) + +__all__ = ('Thickener', 'Centrifuge', 'Incinerator') + +# Assign a bare module of 1 to all +default_F_BM = { + 'Wall concrete': 1., + 'Slab concrete': 1., + 'Wall stainless steel': 1., + 'Scraper': 1, + 'v notch weir': 1, + 'Pumps': 1, + + # Centrifuge + 'Bowl stainless steel': 1, + 'Conveyor': 1 + } +default_F_BM.update(default_WWTpump_F_BM) + +#%% Thickener +# <<<<<<< Updated upstream +# ======= + +# def calc_f_thick(thickener_perc, TSS_in): +# if TSS_in > 0: +# thickener_factor = thickener_perc*10000/TSS_in +# if thickener_factor < 1: thickener_factor = 1 +# return thickener_factor +# else: +# raise ValueError(f'Influent TSS is not valid: ({TSS_in:.2f} mg/L).') + +# def calc_f_Qu_thin(TSS_removal_perc, thickener_factor): +# if thickener_factor <= 1: +# Qu_factor = 1 +# thinning_factor=0 +# else: +# Qu_factor = TSS_removal_perc/(100*thickener_factor) +# thinning_factor = (1 - TSS_removal_perc/100)/(1 - Qu_factor) +# return Qu_factor, thinning_factor + +# >>>>>>> Stashed changes + +class Thickener(SanUnit): + + """ + Thickener based on BSM2 Layout. [1] + ---------- + ID : str + ID for the Thickener. The default is ''. + ins : class:`WasteStream` + Influent to the clarifier. Expected number of influent is 1. + outs : class:`WasteStream` + Treated effluent and sludge. + thickener_perc : float + The percentage of solids in the underflow of the thickener.[1] + TSS_removal_perc : float + The percentage of suspended solids removed in the thickener.[1] + solids_loading_rate : float + Solid loading rate in the thickener in [(kg/hr)/m2]. Default is 4 kg/(m2*hr) [2] + If the thickener is treating: + Only Primary clarifier sludge, then expected range: 4-6 kg/(m2*hr) + Only WAS (treated with air or oxygen): 0.5-1.5 kg/(m2*hr) + Primary clarifier sludge + WAS: 1.5-3.5 kg/(m2/hr) + h_thickener = float + Side water depth of the thickener. Typically lies between 3-4 m. [2] + Height of tank forming the thickener. + downward_flow_velocity : float, optional + Speed on the basis of which center feed diameter is designed [m/hr]. [3] + The default is 36 m/hr. (10 mm/sec) + F_BM : dict + Equipment bare modules. + + Examples + -------- + >>> from qsdsan import set_thermo, Components, WasteStream + >>> cmps = Components.load_default() + >>> cmps_test = cmps.subgroup(['S_F', 'S_NH4', 'X_OHO', 'H2O']) + >>> set_thermo(cmps_test) + >>> ws = WasteStream('ws', S_F = 10, S_NH4 = 20, X_OHO = 15, H2O=1000) + >>> from qsdsan.sanunits import Thickener + >>> TC = Thickener(ID='TC', ins= (ws), outs=('sludge', 'effluent')) + >>> TC.simulate() + >>> sludge, effluent = TC.outs + >>> sludge.imass['X_OHO']/ws.imass['X_OHO'] + 0.98 + >>> TC.show() # doctest: +ELLIPSIS + Thickener: TC + ins... + [0] ws + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 1e+04 + S_NH4 2e+04 + X_OHO 1.5e+04 + H2O 1e+06 + WasteStream-specific properties: + pH : 7.0 + COD : 23873.0 mg/L + BOD : 14963.2 mg/L + TC : 8298.3 mg/L + TOC : 8298.3 mg/L + TN : 20363.2 mg/L + TP : 367.6 mg/L + TK : 68.3 mg/L + outs... + [0] sludge + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 1.56e+03 + S_NH4 3.11e+03 + X_OHO 1.47e+04 + H2O 1.56e+05 + WasteStream-specific properties: + pH : 7.0 + COD : 95050.4 mg/L + BOD : 55228.4 mg/L + TC : 34369.6 mg/L + TOC : 34369.6 mg/L + TN : 24354.4 mg/L + TP : 1724.0 mg/L + TK : 409.8 mg/L + [1] effluent + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 8.44e+03 + S_NH4 1.69e+04 + X_OHO 300 + H2O 8.44e+05 + WasteStream-specific properties: + pH : 7.0 + COD : 9978.2 mg/L + BOD : 7102.9 mg/L + TC : 3208.8 mg/L + TOC : 3208.8 mg/L + TN : 19584.1 mg/L + TP : 102.9 mg/L + TK : 1.6 mg/L + + References + ---------- + .. [1] Gernaey, Krist V., Ulf Jeppsson, Peter A. Vanrolleghem, and John B. Copp. + Benchmarking of control strategies for wastewater treatment plants. IWA publishing, 2014. + .. [2] Chapter-21: Solids Thicknening (Table 21.3). WEF Manual of Practice No. 8. + 6th Edition. Virginia: McGraw-Hill, 2018. + .. [3] Introduction to Wastewater Clarifier Design by Nikolay Voutchkov, PE, BCEE. + .. [4] Metcalf, Leonard, Harrison P. Eddy, and Georg Tchobanoglous. Wastewater + engineering: treatment, disposal, and reuse. Vol. 4. New York: McGraw-Hill, 1991. + """ + + _N_ins = 1 + _N_outs = 2 + _ins_size_is_fixed = False + _outs_size_is_fixed = False + + # Costs + wall_concrete_unit_cost = 1081.73 # $/m3 (Hydromantis. CapdetWorks 4.0. https://www.hydromantis.com/CapdetWorks.html) + slab_concrete_unit_cost = 582.48 # $/m3 (Hydromantis. CapdetWorks 4.0. https://www.hydromantis.com/CapdetWorks.html) + stainless_steel_unit_cost=1.8 # Alibaba. Brushed Stainless Steel Plate 304. https://www.alibaba.com/product-detail/brushed-stainless-steel-plate-304l-stainless_1600391656401.html?spm=a2700.details.0.0.230e67e6IKwwFd + + pumps = ('sludge',) + + def __init__(self, ID='', ins=None, outs=(), thermo=None, isdynamic=False, + init_with='WasteStream', F_BM_default=default_F_BM, thickener_perc=7, + TSS_removal_perc=98, solids_loading_rate =4, h_thickener=4, + downward_flow_velocity= 36, F_BM=default_F_BM, **kwargs): + SanUnit.__init__(self, ID, ins, outs, thermo, isdynamic=isdynamic, + init_with=init_with) + self.thickener_perc = thickener_perc + self.TSS_removal_perc = TSS_removal_perc + self.solids_loading_rate = solids_loading_rate + self.h_thickener = h_thickener + self.downward_flow_velocity = downward_flow_velocity + self.F_BM.update(F_BM) + self._mixed = WasteStream(f'{ID}_mixed', thermo = thermo) + self._sludge = self.outs[0].copy(f'{ID}_sludge') + self._thickener_factor = None + self._thinning_factor = None + self._Qu_factor = None + + @property + def thickener_perc(self): + '''tp is the percentage of Suspended Sludge in the underflow of the thickener''' + return self._tp + + @thickener_perc.setter + def thickener_perc(self, tp): + if tp is not None: + if tp>=100 or tp<=0: + raise ValueError(f'should be between 0 and 100 not {tp}') + self._tp = tp + else: + raise ValueError('percentage of SS in the underflow of the thickener expected from user') + + @property + def solids_loading_rate(self): + '''solids_loading_rate is the loading in the thickener''' + return self._slr + + @solids_loading_rate.setter + def solids_loading_rate(self, slr): + if slr is not None: + self._slr = slr + else: + raise ValueError('solids_loading_rate of the thickener expected from user') + + @property + def TSS_removal_perc(self): + '''The percentage of suspended solids removed in the thickener''' + return self._TSS_rmv + + @TSS_removal_perc.setter + def TSS_removal_perc(self, TSS_rmv): + if TSS_rmv is not None: + if TSS_rmv>=100 or TSS_rmv<=0: + raise ValueError(f'should be between 0 and 100 not {TSS_rmv}') + self._TSS_rmv = TSS_rmv + else: + raise ValueError('percentage of suspended solids removed in the thickener expected from user') + + @property + def thickener_factor(self): + if self._thickener_factor is None: + self._mixed.mix_from(self.ins) + inf = self._mixed + _cal_thickener_factor = self._cal_thickener_factor + if not self.ins: return + elif inf.isempty(): return + else: + TSS_in = inf.get_TSS() + self._thickener_factor = _cal_thickener_factor(TSS_in) + return self._thickener_factor + + @property + def thinning_factor(self): + if self._thinning_factor is None: + self._Qu_factor, self._thinning_factor = self._cal_Qu_fthin(self.thickener_factor) + return self._thinning_factor + + def _cal_thickener_factor(self, TSS_in): + if TSS_in > 0: + thickener_factor = self._tp*10000/TSS_in + if thickener_factor < 1: thickener_factor = 1 + return thickener_factor + else: + raise ValueError(f'Influent TSS is not valid: ({TSS_in:.2f} mg/L).') + + def _cal_Qu_fthin(self, thickener_factor): + if thickener_factor<1: + Qu_factor = 1 + thinning_factor=0 + else: + Qu_factor = self._TSS_rmv/(100*thickener_factor) + thinning_factor = (1 - (self._TSS_rmv/100))/(1 - Qu_factor) + return Qu_factor, thinning_factor + + def _update_parameters(self): + # Thickener_factor, Thinning_factor, and Qu_factor need to be + # updated again and again. while dynamic simulations + cmps = self.components + TSS_in = np.sum(self._state[:-1]*cmps.i_mass*cmps.x) + self._thickener_factor = f_thick = self._cal_thickener_factor(TSS_in) + self._Qu_factor, self._thinning_factor = self._cal_Qu_fthin(f_thick) + + def _run(self): + self._mixed.mix_from(self.ins) + inf = self._mixed + sludge, eff = self.outs + cmps = self.components + + TSS_rmv = self._TSS_rmv + thinning_factor = self.thinning_factor + thickener_factor = self.thickener_factor + + # The following are splits by mass of particulates and solubles + + # Note: (1 - thinning_factor)/(thickener_factor - thinning_factor) = Qu_factor + Zs = (1 - thinning_factor)/(thickener_factor - thinning_factor)*inf.mass*cmps.s + Ze = (thickener_factor - 1)/(thickener_factor - thinning_factor)*inf.mass*cmps.s + + Xe = (1 - TSS_rmv/100)*inf.mass*cmps.x + Xs = (TSS_rmv/100)*inf.mass*cmps.x + + # e stands for effluent, s stands for sludge + Ce = Ze + Xe + Cs = Zs + Xs + + eff.set_flow(Ce,'kg/hr') + sludge.set_flow(Cs,'kg/hr') + + def _init_state(self): + + # This function is run only once during dynamic simulations + + # Since there could be multiple influents, the state of the unit is + # obtained assuming perfect mixing + Qs = self._ins_QC[:,-1] + Cs = self._ins_QC[:,:-1] + self._state = np.append(Qs @ Cs / Qs.sum(), Qs.sum()) + self._dstate = self._state * 0. + + # To initialize the updated_thickener_factor, updated_thinning_factor + # and updated_Qu_factor for dynamic simulation + self._update_parameters() + + def _update_state(self): + '''updates conditions of output stream based on conditions of the Thickener''' + + # This function is run multiple times during dynamic simulation + + # Remember that here we are updating the state array of size n, which is made up + # of component concentrations in the first (n-1) cells and the last cell is flowrate. + + # So, while in the run function the effluent and sludge are split by mass, + # here they are split by concentration. Therefore, the split factors are different. + + # Updated intrinsic modelling parameters are used for dynamic simulation + thickener_factor = self.thickener_factor + thinning_factor = self.thinning_factor + Qu_factor = self._Qu_factor + cmps = self.components + + # For sludge, the particulate concentrations are multipled by thickener factor, and + # flowrate is multiplied by Qu_factor. The soluble concentrations remains same. + uf, of = self.outs + if uf.state is None: uf.state = np.zeros(len(cmps)+1) + uf.state[:-1] = self._state[:-1]*cmps.s*1 + self._state[:-1]*cmps.x*thickener_factor + uf.state[-1] = self._state[-1]*Qu_factor + + # For effluent, the particulate concentrations are multipled by thinning factor, and + # flowrate is multiplied by Qu_factor. The soluble concentrations remains same. + if of.state is None: of.state = np.zeros(len(cmps)+1) + of.state[:-1] = self._state[:-1]*cmps.s*1 + self._state[:-1]*cmps.x*thinning_factor + of.state[-1] = self._state[-1]*(1 - Qu_factor) + + def _update_dstate(self): + '''updates rates of change of output stream from rates of change of the Thickener''' + + # This function is run multiple times during dynamic simulation + + # Remember that here we are updating the state array of size n, which is made up + # of component concentrations in the first (n-1) cells and the last cell is flowrate. + + # So, while in the run function the effluent and sludge are split by mass, + # here they are split by concentration. Therefore, the split factors are different. + + # Updated intrinsic modelling parameters are used for dynamic simulation + thickener_factor = self.thickener_factor + thinning_factor = self.thinning_factor + Qu_factor = self._Qu_factor + cmps = self.components + + # For sludge, the particulate concentrations are multipled by thickener factor, and + # flowrate is multiplied by Qu_factor. The soluble concentrations remains same. + uf, of = self.outs + if uf.dstate is None: uf.dstate = np.zeros(len(cmps)+1) + uf.dstate[:-1] = self._dstate[:-1]*cmps.s*1 + self._dstate[:-1]*cmps.x*thickener_factor + uf.dstate[-1] = self._dstate[-1]*Qu_factor + + # For effluent, the particulate concentrations are multipled by thinning factor, and + # flowrate is multiplied by Qu_factor. The soluble concentrations remains same. + if of.dstate is None: of.dstate = np.zeros(len(cmps)+1) + of.dstate[:-1] = self._dstate[:-1]*cmps.s*1 + self._dstate[:-1]*cmps.x*thinning_factor + of.dstate[-1] = self._dstate[-1]*(1 - Qu_factor) + + @property + def AE(self): + if self._AE is None: + self._compile_AE() + return self._AE + + def _compile_AE(self): + + # This function is run multiple times during dynamic simulation + + _state = self._state + _dstate = self._dstate + _update_state = self._update_state + _update_dstate = self._update_dstate + _update_parameters = self._update_parameters + def yt(t, QC_ins, dQC_ins): + Q_ins = QC_ins[:, -1] + C_ins = QC_ins[:, :-1] + dQ_ins = dQC_ins[:, -1] + dC_ins = dQC_ins[:, :-1] + Q = Q_ins.sum() + C = Q_ins @ C_ins / Q + _state[-1] = Q + _state[:-1] = C + Q_dot = dQ_ins.sum() + C_dot = (dQ_ins @ C_ins + Q_ins @ dC_ins - Q_dot * C)/Q + _dstate[-1] = Q_dot + _dstate[:-1] = C_dot + + _update_parameters() + _update_state() + _update_dstate() + self._AE = yt + + def _design_pump(self): + ID, pumps = self.ID, self.pumps + self._sludge.copy_like(self.outs[0]) + sludge = self._sludge + + ins_dct = { + 'sludge': sludge, + } + + type_dct = dict.fromkeys(pumps, 'sludge') + inputs_dct = dict.fromkeys(pumps, (1,)) + + D = self.design_results + influent_Q = sludge.get_total_flow('m3/hr')/D['Number of thickeners'] + influent_Q_mgd = influent_Q*0.00634 # m3/hr to MGD + + for i in pumps: + if hasattr(self, f'{i}_pump'): + p = getattr(self, f'{i}_pump') + setattr(p, 'add_inputs', inputs_dct[i]) + else: + ID = f'{ID}_{i}' + capacity_factor=1 + # No. of pumps = No. of influents + pump = WWTpump( + ID=ID, ins= ins_dct[i], thermo = self.thermo, pump_type=type_dct[i], + Q_mgd=influent_Q_mgd, add_inputs=inputs_dct[i], + capacity_factor=capacity_factor, + include_pump_cost=True, + include_building_cost=False, + include_OM_cost=True, + ) + setattr(self, f'{i}_pump', pump) + + pipe_ss, pump_ss = 0., 0. + for i in pumps: + p = getattr(self, f'{i}_pump') + p.simulate() + p_design = p.design_results + pipe_ss += p_design['Pump pipe stainless steel'] + pump_ss += p_design['Pump stainless steel'] + return pipe_ss, pump_ss + + _units = { + 'Design solids loading rate': 'kg/m2/hr', + 'Total mass of solids handled': 'kg', + 'Surface area': 'm2', + 'Thickener diameter': 'm', + 'Number of thickeners' : 'Unitless', + 'Thickener depth': 'm', + 'Conical depth': 'm', + 'Cylindrical depth': 'm', + 'Cylindrical volume': 'm3', + 'Conical volume': 'm3', + 'Thickener volume': 'm3', + + 'Center feed depth': 'm', + 'Downward flow velocity': 'm/hr', + 'Volumetric flow': 'm3/hr', + 'Center feed diameter': 'm', + 'Thickener depth': 'm', + 'Volume of concrete wall': 'm3', + 'Volume of concrete slab': 'm3', + 'Stainless steel': 'kg', + 'Pump pipe stainless steel' : 'kg', + 'Pump stainless steel': 'kg', + 'Number of pumps': 'Unitless' + } + + def _design(self): + + self._mixed.mix_from(self.ins) + mixed = self._mixed + D = self.design_results + + # D['Number of thickeners'] = np.ceil(self._mixed.get_total_flow('m3/hr')/self.design_flow) + D['Design solids loading rate'] = self.solids_loading_rate # in (kg/hr)/m2 + D['Total mass of solids handled'] = (mixed.get_TSS()/1000)*mixed.get_total_flow('m3/hr') # (mg/L)*[1/1000(kg*L)/(mg*m3)](m3/hr) = (kg/hr) + + # Common gravity thickener configurations have tanks with diameter between 21-24m (MOP 8) + diameter_thickener = 24 + number_of_thickeners = 0 + while diameter_thickener >= 22: + number_of_thickeners += 1 + total_surface_area = D['Total mass of solids handled']/D['Design solids loading rate'] #m2 + surface_area_thickener = total_surface_area/number_of_thickeners + diameter_thickener = np.sqrt(4*surface_area_thickener/np.pi) + + D['Surface area'] = surface_area_thickener #in m2 + D['Thickener diameter'] = diameter_thickener #in m + D['Number of thickeners'] = number_of_thickeners + + # Common gravity thickener configurations have sidewater depth between 3-4m (MOP 8) + D['Thickener depth'] = self.h_thickener #in m + # The thickener tank floor generally has slope between 2:12 and 3:12 (MOP 8) + D['Conical depth'] = (2/12)*(D['Thickener diameter']/2) + D['Cylindrical depth'] = D['Thickener depth'] - D['Conical depth'] + + # Checks on depth + if D['Cylindrical depth'] < 0: + cyl_depth = D['Cylindrical depth'] + RuntimeError(f'Cylindrical depth (= {cyl_depth} ) is negative') + + if D['Cylindrical depth'] < D['Conical depth']: + cyl_depth = D['Cylindrical depth'] + con_depth = D['Conical depth'] + RuntimeError(f'Cylindrical depth (= {cyl_depth} ) is lower than Conical depth (= {con_depth})') + + D['Cylindrical volume'] = np.pi*np.square(D['Thickener diameter']/2)*D['Cylindrical depth'] #in m3 + D['Conical volume'] = (np.pi/3)*(D['Thickener diameter']/2)**2*D['Conical depth'] + D['Thickener volume'] = D['Cylindrical volume'] + D['Conical volume'] + + #Check on SOR is pending + + # The design here is for center feed of thickener. + # Depth of the center feed lies between 30-75% of sidewater depth. [2] + D['Center feed depth'] = 0.5*D['Cylindrical depth'] + # Typical conventional feed wells are designed for an average downflow velocity + # of 10-13 mm/s and maximum velocity of 25-30 mm/s. [4] + peak_flow_safety_factor = 2.5 # assumed based on average and maximum velocities + D['Downward flow velocity'] = self.downward_flow_velocity*peak_flow_safety_factor # in m/hr + + D['Volumetric flow'] = mixed.get_total_flow('m3/hr')/D['Number of thickeners'] # m3/hr + Center_feed_area = D['Volumetric flow']/D['Downward flow velocity'] # in m2 + D['Center feed diameter'] = np.sqrt(4*Center_feed_area/np.pi) # in m + + #Diameter of the center feed does not exceed 40% of tank diameter [2] + if D['Center feed diameter'] > 0.40*D['Thickener diameter']: + cf_dia = D['Center feed diameter'] + tank_dia = D['Thickener diameter'] + warn(f'Diameter of the center feed exceeds 40% of tank diameter. It is {cf_dia*100/tank_dia}% of tank diameter') + + # Amount of concrete required + D_tank = D['Thickener depth']*39.37 # m to inches + # Thickness of the wall concrete [m]. Default to be minimum of 1 feet with 1 inch added for every feet of depth over 12 feet. + thickness_concrete_wall = (1 + max(D_tank-12, 0)/12)*0.3048 # from feet to m + inner_diameter = D['Thickener diameter'] + outer_diameter = inner_diameter + 2*thickness_concrete_wall + volume_cylindercal_wall = (np.pi*D['Cylindrical depth']/4)*(outer_diameter**2 - inner_diameter**2) + D['Volume of concrete wall'] = volume_cylindercal_wall # in m3 + + # Concrete slab thickness, [ft], default to be 2 in thicker than the wall thickness. (Brian's code) + thickness_concrete_slab = thickness_concrete_wall + (2/12)*0.3048 # from inch to m + outer_diameter_cone = inner_diameter + 2*(thickness_concrete_wall + thickness_concrete_slab) + volume_conical_wall = (np.pi/(3*4))*(((D['Conical depth'] + thickness_concrete_wall + thickness_concrete_slab)*(outer_diameter_cone**2)) - (D['Conical depth']*(inner_diameter)**2)) + D['Volume of concrete slab'] = volume_conical_wall + + # Amount of metal required for center feed + thickness_metal_wall = 0.3048 # equal to 1 feet, in m (!! NEED A RELIABLE SOURCE !!) + inner_diameter_center_feed = D['Center feed diameter'] + outer_diameter_center_feed = inner_diameter_center_feed + 2*thickness_metal_wall + volume_center_feed = (3.14*D['Center feed depth']/4)*(outer_diameter_center_feed**2 - inner_diameter_center_feed**2) + density_ss = 7930 # kg/m3, 18/8 Chromium + D['Stainless steel'] = volume_center_feed*density_ss # in kg + + # Pumps + pipe, pumps = self._design_pump() + D['Pump pipe stainless steel'] = pipe + D['Pump stainless steel'] = pumps + + #For thickener + D['Number of pumps'] = D['Number of thickeners'] + + def _cost(self): + + self._mixed.mix_from(self.ins) + mixed = self._mixed + + D = self.design_results + C = self.baseline_purchase_costs + + # Construction of concrete and stainless steel walls + C['Wall concrete'] = D['Number of thickeners']*D['Volume of concrete wall']*self.wall_concrete_unit_cost + C['Slab concrete'] = D['Number of thickeners']*D['Volume of concrete slab']*self.slab_concrete_unit_cost + C['Wall stainless steel'] = D['Number of thickeners']*D['Stainless steel']*self.stainless_steel_unit_cost + + # Cost of equipment + # Source of scaling exponents: Process Design and Economics for Biochemical Conversion of Lignocellulosic Biomass to Ethanol by NREL. + + # Scraper + # Source: https://www.alibaba.com/product-detail/Peripheral-driving-clarifier-mud-scraper-waste_1600891102019.html?spm=a2700.details.0.0.47ab45a4TP0DLb + # base_cost_scraper = 2500 + # base_flow_scraper = 1 # in m3/hr (!!! Need to know whether this is for solids or influent !!!) + thickener_flow = mixed.get_total_flow('m3/hr')/D['Number of thickeners'] + # C['Scraper'] = D['Number of thickeners']*base_cost_scraper*(thickener_flow/base_flow_scraper)**0.6 + # base_power_scraper = 2.75 # in kW + # THE EQUATION BELOW IS NOT CORRECT TO SCALE SCRAPER POWER REQUIREMENTS + # scraper_power = D['Number of thickeners']*base_power_scraper*(thickener_flow/base_flow_scraper)**0.6 + + # v notch weir + # Source: https://www.alibaba.com/product-detail/50mm-Tube-Settler-Media-Modules-Inclined_1600835845218.html?spm=a2700.galleryofferlist.normal_offer.d_title.69135ff6o4kFPb + base_cost_v_notch_weir = 6888 + base_flow_v_notch_weir = 10 # in m3/hr + C['v notch weir'] = D['Number of thickeners']*base_cost_v_notch_weir*(thickener_flow/base_flow_v_notch_weir)**0.6 + + # Pump (construction and maintainance) + pumps = self.pumps + add_OPEX = self.add_OPEX + pump_cost = 0. + building_cost = 0. + opex_o = 0. + opex_m = 0. + + for i in pumps: + p = getattr(self, f'{i}_pump') + p_cost = p.baseline_purchase_costs + p_add_opex = p.add_OPEX + pump_cost += p_cost['Pump'] + building_cost += p_cost['Pump building'] + opex_o += p_add_opex['Pump operating'] + opex_m += p_add_opex['Pump maintenance'] + + C['Pumps'] = pump_cost*D['Number of thickeners'] + C['Pump building'] = building_cost*D['Number of thickeners'] + add_OPEX['Pump operating'] = opex_o*D['Number of thickeners'] + add_OPEX['Pump maintenance'] = opex_m*D['Number of thickeners'] + + # Power + pumping = 0. + for ID in self.pumps: + p = getattr(self, f'{ID}_pump') + if p is None: + continue + pumping += p.power_utility.rate + + pumping = pumping*D['Number of thickeners'] + + self.power_utility.rate += pumping + # self.power_utility.rate += scraper_power + +#%% Centrifuge + +# Asign a bare module of 1 to all + +# default_F_BM = { +# 'Bowl stainless steel': 1, +# 'Conveyor': 1, +# 'Pumps': 1, +# } +# default_F_BM.update(default_WWTpump_F_BM) + +class Centrifuge(Thickener): + + """ + Centrifuge based on BSM2 Layout. [1] + + Parameters + ---------- + ID : str + ID for the Dewatering Unit. The default is ''. + ins : class:`WasteStream` + Influent to the Dewatering Unit. Expected number of influent is 1. + outs : class:`WasteStream` + Treated effluent and sludge. + thickener_perc : float + The percentage of Suspended Sludge in the underflow of the dewatering unit.[1] + TSS_removal_perc : float + The percentage of suspended solids removed in the dewatering unit.[1] + solids_feed_rate : float + Rate of solids processed by one centrifuge in dry tonne per day (dtpd). + Default value is 150 dtpd. [6] + g_factor : float + Factor by which g (9.81 m/s2) is multiplied to obtain centrifugal acceleration. + g_factor typically lies between 1500 and 3000. + centrifugal acceleration = g * g_factor = k * (RPM)^2 * diameter [3] + rotational_speed : float + rotational speed of the centrifuge in rpm. Typical rpm is between 2000-3000 rpm [MOP-8, PAGE 1733] + LtoD: The ratio of length to diameter of the centrifuge. + The value typically lies between 3-4. [4] + polymer_dosage : float + mass of polymer utilised (lb) per tonne of dry solid waste (lbs/tonne).[5] + Depends on the type of influents, please refer to [5] for appropriate values. + Default value of 20 lbs/tonne is taken from [5], based on Primary + WAS aerated undigested value. + h_cylindrical: float + length of cylindrical portion of dewatering unit. + h_conical: float + length of conical portion of dewatering unit. + + + References + ---------- + .. [1] Gernaey, Krist V., Ulf Jeppsson, Peter A. Vanrolleghem, and John B. Copp. + Benchmarking of control strategies for wastewater treatment plants. IWA publishing, 2014. + [2] Metcalf, Leonard, Harrison P. Eddy, and Georg Tchobanoglous. Wastewater + engineering: treatment, disposal, and reuse. Vol. 4. New York: McGraw-Hill, 1991. + [3]Design of Municipal Wastewater Treatment Plants: WEF Manual of Practice + No. 8 ASCE Manuals and Reports on Engineering Practice No. 76, Fifth Edition. + [4] https://www.alibaba.com/product-detail/Multifunctional-Sludge-Dewatering-Decanter-Centrifuge_1600285055254.html?spm=a2700.galleryofferlist.normal_offer.d_title.1cd75229sPf1UW&s=p + [5] United States Environmental Protection Agency (EPA) 'Biosolids Technology Fact Sheet Centrifuge Thickening and Dewatering' + [6] San Diego (.gov) Chapter - 3 'Solids Treatment Facility' + (https://www.sandiego.gov/sites/default/files/legacy/mwwd/pdf/mbc/chapterthree.pdf) + """ + + _N_ins = 1 + _N_outs = 2 + _ins_size_is_fixed = False + + pumps = ('sludge',) + + # Costs + stainless_steel_unit_cost=1.8 # $/Kg (Taken from Joy's METAB code) https://www.alibaba.com/product-detail/brushed-stainless-steel-plate-304l-stainless_1600391656401.html?spm=a2700.details.0.0.230e67e6IKwwFd + polymer_cost_by_weight = 2.2 # $/Kg (Source: https://www.alibaba.com/product-detail/dewatering-pool-chemicals-cationic-polyacrylamide-cas_1600194474507.html?spm=a2700.galleryofferlist.topad_classic.i5.5de8615c4zGAhg) + + def __init__(self, ID='', ins=None, outs=(), thermo=None, isdynamic=False, + init_with='WasteStream', F_BM_default=default_F_BM, thickener_perc=28, TSS_removal_perc=98, + solids_feed_rate = 70, g_factor=2500, rotational_speed = 2500, LtoD = 4, F_BM = default_F_BM, + polymer_dosage = 20, h_cylindrical=2, h_conical=1, **kwargs): + + Thickener.__init__(self, ID=ID, ins=ins, outs=outs, thermo=thermo, isdynamic=isdynamic, + init_with=init_with, F_BM_default=1, thickener_perc=thickener_perc, + TSS_removal_perc=TSS_removal_perc, **kwargs) + + self.solids_feed_rate = solids_feed_rate + self.g_factor = g_factor #unitless, centrifugal acceleration = g_factor*9.81 + self.rotational_speed = rotational_speed #in revolution/min + self.LtoD = LtoD + self.polymer_dosage = polymer_dosage #in (lbs,polymer/tonne,solids) + self.h_cylindrical = h_cylindrical + self.h_conical = h_conical + + _units = { + 'Number of centrifuges': 'ea', + 'Diameter of bowl': 'm', + 'Total length of bowl': 'm', + 'Length of cylindrical portion': 'm', + 'Length of conical portion': 'm', + 'Volume of bowl': 'm3', + 'Stainless steel for bowl': 'kg', + 'Polymer feed rate': 'kg/hr', + 'Pump pipe stainless steel' : 'kg', + 'Pump stainless steel': 'kg', + 'Number of pumps': 'ea' + } + + def _design_pump(self): + ID, pumps = self.ID, self.pumps + self._sludge.copy_like(self.outs[0]) + sludge = self._sludge + ins_dct = { + 'sludge': sludge, + } + type_dct = dict.fromkeys(pumps, 'sludge') + inputs_dct = dict.fromkeys(pumps, (1,)) + + D = self.design_results + influent_Q = sludge.get_total_flow('m3/hr')/D['Number of centrifuges'] + influent_Q_mgd = influent_Q*0.00634 # m3/hr to MGD + + for i in pumps: + if hasattr(self, f'{i}_pump'): + p = getattr(self, f'{i}_pump') + setattr(p, 'add_inputs', inputs_dct[i]) + else: + ID = f'{ID}_{i}' + capacity_factor=1 + # No. of pumps = No. of influents + pump = WWTpump( + ID=ID, ins= ins_dct[i], thermo = self.thermo, pump_type=type_dct[i], + Q_mgd=influent_Q_mgd, add_inputs=inputs_dct[i], + capacity_factor=capacity_factor, + include_pump_cost=True, + include_building_cost=False, + include_OM_cost=True, + ) + setattr(self, f'{i}_pump', pump) + + pipe_ss, pump_ss = 0., 0. + for i in pumps: + p = getattr(self, f'{i}_pump') + p.simulate() + p_design = p.design_results + pipe_ss += p_design['Pump pipe stainless steel'] + pump_ss += p_design['Pump stainless steel'] + return pipe_ss, pump_ss + + def _design(self): + self._mixed.mix_from(self.ins) + mixed = self._mixed + + D = self.design_results + TSS_rmv = self._TSS_rmv + solids_feed_rate = 44.66*self.solids_feed_rate # 44.66 is factor to convert tonne/day to kg/hr + # Cake's total solids and TSS are essentially the same (pg. 24-6 [3]) + # If TSS_rmv = 98, then total_mass_dry_solids_removed = (0.98)*(influent TSS mass) + total_mass_dry_solids_removed = (TSS_rmv/100)*((mixed.get_TSS()*self.ins[0].F_vol)/1000) # in kg/hr + D['Number of centrifuges'] = np.ceil(total_mass_dry_solids_removed/solids_feed_rate) + + + # HAVE COMMENTED ALL OF THIS SINCE CENTRIFUGE WOULD PROBABLY BE BROUGHT NOT CONSTRUCTED AT THE FACILITY + + # k = 0.00000056 # Based on emprical formula (pg. 24-23 of [3]) + # g = 9.81 # m/s2 + # # The inner diameterof the bowl is calculated based on an empirical formula. 1000 is used to convert mm to m. + # D['Diameter of bowl'] = (self.g_factor*g)/(k*np.square(self.rotational_speed)*1000) # in m + # D['Total length of bowl'] = self.LtoD*D['Diameter of bowl'] + # # Sanity check: L should be between 1-7 m, diameter should be around 0.25-0.8 m (Source: [4]) + + # fraction_cylindrical_portion = 0.8 + # fraction_conical_portion = 1 - fraction_cylindrical_portion + # D['Length of cylindrical portion'] = fraction_cylindrical_portion*D['Total length of bowl'] + # D['Length of conical portion'] = fraction_conical_portion*D['Total length of bowl'] + # thickness_of_bowl_wall = 0.1 # in m (!!! NEED A RELIABLE SOURCE !!!) + # inner_diameter = D['Diameter of bowl'] + # outer_diameter = inner_diameter + 2*thickness_of_bowl_wall + + # volume_cylindrical_wall = (np.pi*D['Length of cylindrical portion']/4)*(outer_diameter**2 - inner_diameter**2) + # volume_conical_wall = (np.pi/3)*(D['Length of conical portion']/4)*(outer_diameter**2 - inner_diameter**2) + # D['Volume of bowl'] = volume_cylindrical_wall + volume_conical_wall # in m3 + + # density_ss = 7930 # kg/m3, 18/8 Chromium + # D['Stainless steel for bowl'] = D['Volume of bowl']*density_ss # in kg + + polymer_dosage_rate = 0.000453592*self.polymer_dosage # convert from (polymer (lbs)/solids (tonne)) to (polymer (kg)/solids (kg)) + D['Polymer feed rate'] = (polymer_dosage_rate*solids_feed_rate) # in polymer (kg)/hr + + # Pumps + pipe, pumps = self._design_pump() + D['Pump pipe stainless steel'] = pipe + D['Pump stainless steel'] = pumps + + # For centrifuges + D['Number of pumps'] = D['Number of centrifuges'] + + def _cost(self): + + D = self.design_results + C = self.baseline_purchase_costs + + self._mixed.mix_from(self.ins) + mixed = self._mixed + + # HAVE COMMENTED SINCE CENTRIFUGE WOULD PROBABLY BE BROUGHT NOT CONSTRUCTED AT THE FACILITY + # Construction of concrete and stainless steel walls + # C['Bowl stainless steel'] = D['Number of centrifuges']*D['Stainless steel for bowl']*self.stainless_steel_unit_cost + + # Conveyor + # Source: https://www.alibaba.com/product-detail/Sludge-Dewatering-Centrifuge-Decanter-Centrifuge-For_60448094522.html?spm=a2700.galleryofferlist.p_offer.d_title.1c5c5229I5pQeP&s=p + base_cost_centrifuge = 16000 + base_mass_flow_centrifuge = 80 # in tonne/hr + thickener_mass_flow = (mixed.get_total_flow('m3/hr')*mixed.get_TSS())/D['Number of centrifuges'] # IN gm/hr + gm_to_tonne = 0.000001 + thickener_mass_flow = thickener_mass_flow*gm_to_tonne + C['Centrifuge'] = D['Number of centrifuges']*base_cost_centrifuge*(thickener_mass_flow/base_mass_flow_centrifuge)**0.6 + + base_power_motor = 55 # in kW + # THIS IS NOT THE CORRECT EXPRESSION TO SCALE UP POWER OF CENTRIFUGE + motor_power = base_power_motor*(thickener_mass_flow/base_mass_flow_centrifuge) + total_motor_power = D['Number of centrifuges']*motor_power + + # Pump (construction and maintainance) + pumps = self.pumps + add_OPEX = self.add_OPEX + pump_cost = 0. + building_cost = 0. + opex_o = 0. + opex_m = 0. + + add_OPEX['Polymer'] = D['Number of centrifuges']*D['Polymer feed rate']*self.polymer_cost_by_weight + + for i in pumps: + p = getattr(self, f'{i}_pump') + p_cost = p.baseline_purchase_costs + p_add_opex = p.add_OPEX + pump_cost += p_cost['Pump'] + building_cost += p_cost['Pump building'] + opex_o += p_add_opex['Pump operating'] + opex_m += p_add_opex['Pump maintenance'] + + C['Pumps'] = pump_cost*D['Number of pumps'] + C['Pump building'] = building_cost*D['Number of pumps'] + add_OPEX['Pump operating'] = opex_o*D['Number of pumps'] + add_OPEX['Pump maintenance'] = opex_m*D['Number of pumps'] + + # Power + pumping = 0. + for ID in self.pumps: + p = getattr(self, f'{ID}_pump') + if p is None: + continue + pumping += p.power_utility.rate + + pumping = pumping*D['Number of pumps'] + self.power_utility.rate += pumping + self.power_utility.rate += total_motor_power +#%% Incinerator + +class Incinerator(SanUnit): + + """ + Fluidized bed incinerator unit for metroWWTP. + + Parameters + ---------- + ID : str + ID for the Incinerator Unit. The default is ''. + ins : class:`WasteStream` + Influent to the Incinerator Unit. Expected number of influent streams are 3. + Please remember the order of influents as {wastestream, air, fuel} + outs : class:`WasteStream` + Flue gas and ash. + process_efficiency : float + The process efficiency of the incinerator unit. Expected value between 0 and 1. + calorific_value_sludge : float + The calorific value of influent sludge in KJ/kg. The default value used is 12000 KJ/kg. + calorific_value_fuel : float + The calorific value of fuel employed for combustion in KJ/kg. + The default fuel is natural gas with calorific value of 50000 KJ/kg. + + Examples + -------- + >>> import qsdsan as qs + >>> cmps = qs.Components.load_default() + >>> CO2 = qs.Component.from_chemical('S_CO2', search_ID='CO2', particle_size='Soluble', degradability='Undegradable', organic=False) + >>> cmps_test = qs.Components([cmps.S_F, cmps.S_NH4, cmps.X_OHO, cmps.H2O, cmps.S_CH4, cmps.S_O2, cmps.S_N2, cmps.S_H2, cmps.X_Ig_ISS, CO2]) + >>> cmps_test.default_compile() + >>> qs.set_thermo(cmps_test) + >>> ws = qs.WasteStream('ws', S_F=10, S_NH4=20, X_OHO=15, H2O=1000) + >>> natural_gas = qs.WasteStream('nat_gas', phase='g', S_CH4=1000) + >>> air = qs.WasteStream('air', phase='g', S_O2=210, S_N2=780, S_H2=10) + >>> from qsdsan.sanunits import Incinerator + >>> Inc = Incinerator(ID='Inc', ins= (ws, air, natural_gas), outs=('flu_gas', 'ash'), + ... isdynamic=False) + >>> Inc.simulate() + >>> Inc.show() + Incinerator: Inc + ins... + [0] ws + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 1e+04 + S_NH4 2e+04 + X_OHO 1.5e+04 + H2O 1e+06 + WasteStream-specific properties: + pH : 7.0 + COD : 23873.0 mg/L + BOD : 14963.2 mg/L + TC : 8298.3 mg/L + TOC : 8298.3 mg/L + TN : 20363.2 mg/L + TP : 367.6 mg/L + TK : 68.3 mg/L + [1] air + phase: 'g', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_O2 2.1e+05 + S_N2 7.8e+05 + S_H2 1e+04 + WasteStream-specific properties: None for non-liquid waste streams + [2] nat_gas + phase: 'g', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_CH4 1e+06 + WasteStream-specific properties: None for non-liquid waste streams + outs... + [0] flu_gas + phase: 'g', T: 298.15 K, P: 101325 Pa + flow (g/hr): H2O 1e+06 + S_N2 7.8e+05 + S_CO2 2.67e+05 + WasteStream-specific properties: None for non-liquid waste streams + [1] ash + phase: 's', T: 298.15 K, P: 101325 Pa + flow (g/hr): X_Ig_ISS 2.37e+05 + WasteStream-specific properties: None for non-liquid waste streams + + References: + ---------- + .. [1] Khuriati, A., P. Purwanto, H. S. Huboyo, Suryono Sumariyah, S. Suryono, and A. B. Putranto. + "Numerical calculation based on mass and energy balance of waste incineration in the fixed bed reactor." + In Journal of Physics: Conference Series, vol. 1524, no. 1, p. 012002. IOP Publishing, 2020. + [2] Omari, Arthur, Karoli N. Njau, Geoffrey R. John, Joseph H. Kihedu, and Peter L. Mtui. + "Mass And Energy Balance For Fixed Bed Incinerators A case of a locally designed incinerator in Tanzania." + """ + + #These are class attributes + _N_ins = 3 + _N_outs = 2 + Cp_air = 1 #(Cp = 1 kJ/kg for air) + + def __init__(self, ID='', ins=None, outs=(), thermo=None, isdynamic=False, + init_with='WasteStream', F_BM_default=None, process_efficiency=0.90, + calorific_value_sludge= 12000, calorific_value_fuel=50000, + ash_component_ID = 'X_Ig_ISS', nitrogen_ID = 'S_N2', water_ID = 'H2O', + carbon_di_oxide_ID = 'S_CO2', **kwargs): + SanUnit.__init__(self, ID, ins, outs, thermo, isdynamic=isdynamic, + init_with=init_with, F_BM_default=F_BM_default) + + self.calorific_value_sludge = calorific_value_sludge #in KJ/kg + self.calorific_value_fuel = calorific_value_fuel #in KJ/kg (here the considered fuel is natural gas) + self.process_efficiency = process_efficiency + self.ash_component_ID = ash_component_ID + self.nitrogen_ID = nitrogen_ID + self.water_ID = water_ID + self.carbon_di_oxide_ID = carbon_di_oxide_ID + self.Heat_air = None + self.Heat_fuel = None + self.Heat_sludge = None + self.Heat_flue_gas = None + self.Heat_loss = None + + @property + def process_efficiency(self): + '''Process efficiency of incinerator.''' + return self._process_efficiency + + @process_efficiency.setter + def process_efficiency(self, process_efficiency): + if process_efficiency is not None: + if process_efficiency>=1 or process_efficiency<=0: + raise ValueError(f'should be between 0 and 1 not {process_efficiency}') + self._process_efficiency = process_efficiency + else: + raise ValueError('Process efficiency of incinerator expected from user') + + @property + def calorific_value_sludge(self): + '''Calorific value of sludge in KJ/kg.''' + return self._calorific_value_sludge + + @calorific_value_sludge.setter + def calorific_value_sludge(self, calorific_value_sludge): + if calorific_value_sludge is not None: + self._calorific_value_sludge = calorific_value_sludge + else: + raise ValueError('Calorific value of sludge expected from user') + + @property + def calorific_value_fuel(self): + '''Calorific value of fuel in KJ/kg.''' + return self._calorific_value_fuel + + @calorific_value_fuel.setter + def calorific_value_fuel(self, calorific_value_fuel): + if calorific_value_fuel is not None: + self._calorific_value_fuel = calorific_value_fuel + else: + raise ValueError('Calorific value of fuel expected from user') + + def _run(self): + + sludge, air, fuel = self.ins + flue_gas, ash = self.outs + flue_gas.phase = 'g' + ash.phase = 's' + cmps = self.components + nitrogen_ID = self.nitrogen_ID + water_ID = self.water_ID + carbon_di_oxide_ID = self.carbon_di_oxide_ID + + if sludge.phase != 'l': + raise ValueError(f'The phase of incoming sludge is expected to be liquid not {sludge.phase}') + if air.phase != 'g': + raise ValueError(f'The phase of air is expected to be gas not {air.phase}') + if fuel.phase != 'g': + raise ValueError(f'The phase of fuel is expected to be gas not {fuel.phase}') + + inf = np.asarray(sludge.mass + air.mass + fuel.mass) + idx_n2 = cmps.index(nitrogen_ID) + idx_h2o = cmps.index(water_ID) + + n2 = inf[idx_n2] + h2o = inf[idx_h2o] + + mass_ash = np.sum(inf*cmps.i_mass*(1-cmps.f_Vmass_Totmass)) \ + - h2o*cmps.H2O.i_mass*(1-cmps.H2O.f_Vmass_Totmass) - n2*cmps.N2.i_mass*(1-cmps.N2.f_Vmass_Totmass) + + # Conservation of mass + mass_flue_gas = np.sum(inf*cmps.i_mass) - mass_ash + mass_co2 = mass_flue_gas - n2*cmps.N2.i_mass - h2o*cmps.H2O.i_mass + + flue_gas.set_flow([n2, h2o, (mass_co2/cmps.S_CO2.i_mass)], + 'kg/hr', (nitrogen_ID, water_ID, carbon_di_oxide_ID)) + ash_cmp_ID = self.ash_component_ID + ash_idx = cmps.index(ash_cmp_ID) + ash.set_flow([mass_ash/cmps.i_mass[ash_idx]/(1-cmps.f_Vmass_Totmass[ash_idx])],'kg/hr', (ash_cmp_ID,)) + + # Energy balance + # self.Heat_sludge = sludge.dry_mass*sludge.F_vol*self.calorific_value_sludge/1000 #in KJ/hr (mg/L)*(m3/hr)*(KJ/kg)=KJ/hr*(1/1000) + # self.Heat_air = np.sum(air.mass*cmps.i_mass)*self.Cp_air #in KJ/hr + # self.Heat_fuel = np.sum(fuel.mass*cmps.i_mass)*self.calorific_value_fuel #in KJ/hr + # self.Heat_flue_gas = self.process_efficiency*(self.Heat_sludge + self.Heat_air + self.Heat_fuel) + + # # Conservation of energy + # self.Heat_loss = self.Heat_sludge + self.Heat_air + self.Heat_fuel - self.Heat_flue_gas + + def _init_state(self): + + sludge, air, fuel = self.ins + inf = np.asarray(sludge.mass + air.mass + fuel.mass) + self._state = (24*inf)/1000 + self._dstate = self._state * 0. + self._cached_state = self._state.copy() + self._cached_t = 0 + + # def _cost(self): + + # C = self.baseline_purchase_costs + + # sludge, air, fuel = self.ins + + # solids_load_treated = sludge.get_total_flow('m3/hr')*sludge.get_TSS('mg/L')/1000 # in kg/hr + + # C = self.baseline_purchase_costs + + # # Based on regression equations obtained from CapdetWorks + # C['Construction and equipment costs'] = 119629*(solids_load_treated)**0.9282 + # C['Installed incinerator costs'] = 114834*(solids_load_treated)**0.9284 + # C['Slab concrete costs'] = 782.28*(solids_load_treated)**0.9111 + # C['Building costs'] = 4429.8*(solids_load_treated)**0.911 + + # # Based on regression equations obtained from CapdetWorks + # add_OPEX = self.add_OPEX + # add_OPEX['Material and supply costs'] = 0.0614*(solids_load_treated)**0.9282 + # add_OPEX['Energy costs'] = 1.3079*(solids_load_treated)**0.9901 + + def _update_state(self): + cmps = self.components + for ws in self.outs: + if ws.state is None: + ws.state = np.zeros(len(self._state)+1) + + nitrogen_ID = self.nitrogen_ID + water_ID = self.water_ID + carbon_di_oxide_ID = self.carbon_di_oxide_ID + + idx_h2o = cmps.index(water_ID) + idx_n2 = cmps.index(nitrogen_ID) + idx_co2 = cmps.index(carbon_di_oxide_ID) + ash_idx = cmps.index(self.ash_component_ID) + cmps_i_mass = cmps.i_mass + cmps_v2tmass = cmps.f_Vmass_Totmass + + inf = self._state + mass_in_tot = np.sum(inf*cmps_i_mass) + self._outs[0].state[idx_h2o] = h2o = inf[idx_h2o] + self._outs[0].state[idx_n2] = n2 = inf[idx_n2] + + mass_ash = np.sum(inf*cmps_i_mass*(1-cmps_v2tmass)) \ + - h2o*cmps.H2O.i_mass*(1-cmps_v2tmass[idx_h2o]) - n2*cmps.N2.i_mass*(1-cmps_v2tmass[idx_n2]) + mass_flue_gas = mass_in_tot - mass_ash + mass_co2 = mass_flue_gas - n2 - h2o + + self._outs[0].state[idx_co2] = mass_co2/cmps_i_mass[idx_co2] + self._outs[1].state[ash_idx] = mass_ash/cmps_i_mass[ash_idx]/(1-cmps_v2tmass[ash_idx]) + + self._outs[0].state[-1] = 1 + self._outs[1].state[-1] = 1 + + def _update_dstate(self): + + cmps = self.components + nitrogen_ID = self.nitrogen_ID + water_ID = self.water_ID + carbon_di_oxide_ID = self.carbon_di_oxide_ID + + idx_h2o = cmps.index(water_ID) + idx_n2 = cmps.index(nitrogen_ID) + idx_co2 = cmps.index(carbon_di_oxide_ID) + ash_idx = cmps.index(self.ash_component_ID) + d_state = self._dstate + cmps_i_mass = cmps.i_mass + cmps_v2tmass = cmps.f_Vmass_Totmass + d_n2 = d_state[idx_n2] + d_h2o = d_state[idx_h2o] + + for ws in self.outs: + if ws.dstate is None: + ws.dstate = np.zeros(len(self._dstate)+1) + + self._outs[0].dstate[idx_n2] = d_n2 + self._outs[0].dstate[idx_h2o] = d_h2o + + d_mass_in_tot = np.sum(d_state*cmps_i_mass) + + d_mass_ash = np.sum(d_state*cmps_i_mass*(1-cmps_v2tmass)) \ + - d_h2o*cmps.H2O.i_mass*(1-cmps_v2tmass[idx_h2o]) - d_n2*cmps.N2.i_mass*(1-cmps_v2tmass[idx_n2]) + d_mass_flue_gas = d_mass_in_tot - d_mass_ash + d_mass_co2 = d_mass_flue_gas - d_n2 - d_h2o + + self._outs[0].dstate[idx_co2] = d_mass_co2/cmps_i_mass[idx_co2] + self._outs[1].dstate[ash_idx] = d_mass_ash/cmps_i_mass[ash_idx]/(1-cmps_v2tmass[ash_idx]) + + @property + def AE(self): + if self._AE is None: + self._compile_AE() + return self._AE + + def _compile_AE(self): + _state = self._state + _dstate = self._dstate + _update_state = self._update_state + _update_dstate = self._update_dstate + _cached_state = self._cached_state + + def yt(t, QC_ins, dQC_ins): + # Mass_in is basically the mass flowrate array where each row + # corresponds to the flowrates of individual components (in columns) + # This strcuture is achieved by multiplying the first (n-1) rows of + # Q_ins (which corresponds to concentration) to the nth row (which + # is the volumetric flowrate) + Mass_ins = np.diag(QC_ins[:,-1]) @ QC_ins[:,:-1] + # the _state array is formed by adding each column of the Mass_in + # array, thus providing the total massflowrate of each component + _state[:] = np.sum(Mass_ins, axis=0) + + if t > self._cached_t: + _dstate[:] = (_state - _cached_state)/(t - self._cached_t) + _cached_state[:] = _state + self._cached_t = t + _update_state() + _update_dstate() + self._AE = yt \ No newline at end of file diff --git a/qsdsan/sanunits/_suspended_growth_bioreactor.py b/qsdsan/sanunits/_suspended_growth_bioreactor.py index a2659d5a..f3e01f86 100644 --- a/qsdsan/sanunits/_suspended_growth_bioreactor.py +++ b/qsdsan/sanunits/_suspended_growth_bioreactor.py @@ -79,6 +79,14 @@ class CSTR(SanUnit): The default is None. V_max : float Designed volume, in [m^3]. The default is 1000. + + # W_tank : float + # The design width of the tank, in [m]. The default is 6.4 m (21 ft). [1, Yalin's adaptation of code] + # D_tank : float + # The design depth of the tank in [m]. The default is 3.65 m (12 ft). [1, Yalin's adaptation of code] + # freeboard : float + # Freeboard added to the depth of the reactor tank, [m]. The default is 0.61 m (2 ft). [1, Yalin's adaptation of code] + aeration : float or :class:`Process`, optional Aeration setting. Either specify a targeted dissolved oxygen concentration in [mg O2/L] or provide a :class:`Process` object to represent aeration, @@ -92,17 +100,25 @@ class CSTR(SanUnit): Any exogenous dynamic variables that affect the process mass balance, e.g., temperature, sunlight irradiance. Must be independent of state variables of the suspended_growth_model (if has one). + + References: + + [1] Shoener, B. D.; Zhong, C.; Greiner, A. D.; Khunjar, W. O.; Hong, P.-Y.; Guest, J. S. + Design of Anaerobic Membrane Bioreactors for the Valorization + of Dilute Organic Carbon Waste Streams. + Energy Environ. Sci. 2016, 9 (3), 1102–1112. + https://doi.org/10.1039/C5EE03715H. + ''' - _N_ins = 3 _N_outs = 1 _ins_size_is_fixed = False _outs_size_is_fixed = False def __init__(self, ID='', ins=None, outs=(), split=None, thermo=None, - init_with='WasteStream', V_max=1000, aeration=2.0, - DO_ID='S_O2', suspended_growth_model=None, - isdynamic=True, exogenous_vars=(), **kwargs): + init_with='WasteStream', V_max=1000, W_tank = 6.4, D_tank = 3.65, + freeboard = 0.61, t_wall = None, t_slab = None, aeration=2.0, + DO_ID='S_O2', suspended_growth_model=None, isdynamic=True, exogenous_vars=(), **kwargs): SanUnit.__init__(self, ID, ins, outs, thermo, init_with, isdynamic=isdynamic, exogenous_vars=exogenous_vars, **kwargs) self._V_max = V_max @@ -112,9 +128,17 @@ def __init__(self, ID='', ins=None, outs=(), split=None, thermo=None, self._concs = None self._mixed = WasteStream() self.split = split + + # # Design parameters + # self._W_tank = W_tank + # self._D_tank = D_tank + # self._freeboard = freeboard + # self._t_wall = t_wall + # self._t_slab = t_slab + # for attr, value in kwargs.items(): # setattr(self, attr, value) - + @property def V_max(self): '''[float] The designed maximum liquid volume, not accounting for increased volume due to aeration, in m^3.''' @@ -123,7 +147,59 @@ def V_max(self): @V_max.setter def V_max(self, Vm): self._V_max = Vm - + + # @property + # def W_tank(self): + # '''[float] The design width of the tank, in m.''' + # return self._W_tank + + # @W_tank.setter + # def W_tank(self, W_tank): + # self._W_tank = W_tank + + # @property + # def D_tank(self): + # '''[float] The design depth of the tank, in m.''' + # return self._D_tank + + # @D_tank.setter + # def D_tank(self, D_tank): + # self._D_tank = D_tank + + # @property + # def freeboard(self): + # '''[float] Freeboard added to the depth of the reactor tank, [m].''' + # return self._freeboard + + # @freeboard.setter + # def freeboard(self, i): + # self._freeboard = i + + # @property + # def t_wall(self): + # ''' + # [float] Thickness of the wall concrete, [m]. + # default to be minimum of 1 ft with 1 in added for every ft of depth over 12 ft. + # ''' + # D_tank = self.D_tank*39.37 # m to inches + # return self._t_wall or (1 + max(D_tank - 12, 0)/12)*0.3048 # from feet to m + + # @t_wall.setter + # def t_wall(self, i): + # self._t_wall = i + + # @property + # def t_slab(self): + # ''' + # [float] Concrete slab thickness, [m], + # default to be 2 in thicker than the wall thickness. + # ''' + # return self._t_slab or (self.t_wall + 2/12)*0.3048 # from feet to m + + # @t_slab.setter + # def t_slab(self, i): + # self._t_slab = i + @property def aeration(self): '''[:class:`Process` or float or NoneType] Aeration model.''' @@ -196,10 +272,7 @@ def state(self, QCs): def set_init_conc(self, **kwargs): '''set the initial concentrations [mg/L] of the CSTR.''' - Cs = np.zeros(len(self.components)) - cmpx = self.components.index - for k, v in kwargs.items(): Cs[cmpx(k)] = v - self._concs = Cs + self._concs = self.components.kwarray(kwargs) def _init_state(self): mixed = self._mixed @@ -287,9 +360,51 @@ def dy_dt(t, QC_ins, QC, dQC_ins): _update_dstate() self._ODE = dy_dt + + # _units = { + # 'Tank volume': 'm3', + # 'Tank width': 'm', + # 'Tank depth': 'm', + # 'Tank length': 'm', + # 'Volume of concrete wall': 'm3', + # 'Volume of concrete slab': 'm3' + # } def _design(self): pass + # self._mixed.mix_from(self.ins) + # # mixed = self._mixed + # D = self.design_results + + # D['Tank volume'] = V = self.V_max + # D['Tank width'] = W = self.W_tank + # D['Tank depth'] = depth = self.D_tank + # D['Tank length'] = L = V/(W*depth) + + # t_wall, t_slab = self.t_wall, self.t_slab + # t = t_wall + t_slab + # D_tot = depth + self.freeboard + + # # get volume of wall concrete + # VWC = 2*((L + 2*t_wall)*t_wall*D_tot) + 2*(W*t_wall*D_tot) + + # # get volume of slab concrete + # VSC = (L + 2*t_wall)*(W + 2*t_wall)*t + + # D['Volume of concrete wall'] = VWC + # D['Volume of concrete slab'] = VSC + + def _cost(self): + pass + # self._mixed.mix_from(self.ins) + + # D = self.design_results + # C = self.baseline_purchase_costs + + # # Construction of concrete and stainless steel walls + # C['Wall concrete'] = D['Volume of concrete wall']*self.wall_concrete_unit_cost + # C['Slab concrete'] = D['Volume of concrete slab']*self.slab_concrete_unit_cost + #%% class BatchExperiment(SanUnit): diff --git a/qsdsan/utils/construction.py b/qsdsan/utils/construction.py index c1a118a4..d4725167 100644 --- a/qsdsan/utils/construction.py +++ b/qsdsan/utils/construction.py @@ -535,7 +535,7 @@ def select_pipe(Q, v): Q : float Flow rate of the fluid, [ft3/s] (cfs). v : float - Minimum permissible velocity of the fluid, [ft/s]. + Minumum permissible velocity of the fluid, [ft/s]. Returns ------- diff --git a/qsdsan/utils/wwt_design.py b/qsdsan/utils/wwt_design.py index 6b38e87b..b19b09bf 100644 --- a/qsdsan/utils/wwt_design.py +++ b/qsdsan/utils/wwt_design.py @@ -5,21 +5,60 @@ This module is developed by: Joy Zhang + Saumitra Rai This module is under the University of Illinois/NCSA Open Source License. Please refer to https://github.com/QSD-Group/QSDsan/blob/main/LICENSE.txt for license details. ''' -__all__ = ('get_SRT',) +import numpy as np +from warnings import warn +__all__ = ('get_SRT', + 'get_oxygen_heterotrophs', + 'get_oxygen_autotrophs', + 'get_airflow', + 'get_P_blower', + 'get_power_utility', + 'get_cost_sludge_disposal', + 'get_normalized_energy', + 'get_daily_operational_cost', + 'get_total_operational_cost', + 'get_GHG_emissions_sec_treatment', + 'get_GHG_emissions_discharge', + 'get_GHG_emissions_electricity', + 'get_GHG_emissions_sludge_disposal', + 'get_CO2_eq_WRRF', + 'get_total_CO2_eq', + + 'get_aeration_cost', + 'get_pumping_cost', + 'get_sludge_disposal_costs', + 'get_CH4_CO2_eq_treatment', + 'get_N2O_CO2_eq_treatment', + 'get_CH4_CO2_eq_discharge', + 'get_N2O_CO2_eq_discharge', + 'get_CH4_emitted_during_pl', + 'get_CH4_emitted_after_pl', + 'get_CO2_eq_electricity', + 'get_eq_natural_gas_price', + + # Function for gates work (to be removed at a later date) + + 'estimate_ww_treatment_energy_demand', + 'estimate_N_removal_energy_demand', + 'estimate_P_removal_energy_demand') + +#%% + def get_SRT(system, biomass_IDs, wastage=None, active_unit_IDs=None): """ Estimate sludge residence time (SRT) of an activated sludge system. Parameters ---------- - system : obj + system : :class:`biosteam.System` The system whose SRT will be calculated for. biomass_IDs : tuple[str] Component IDs of active biomass. @@ -48,7 +87,1587 @@ def get_SRT(system, biomass_IDs, wastage=None, active_unit_IDs=None): if wastage is None: wastage = [ws for ws in system.products if ws.phase in ('l','s')] waste = sum([ws.composite('solids', subgroup=biomass_IDs)*ws.F_vol*24 \ for ws in wastage]) + if waste == 0: + warn('Wasted biomass calculated to be 0.') + return units = system.units if active_unit_IDs is None \ else [u for u in system.units if u.ID in active_unit_IDs] retain = sum([u.get_retained_mass(biomass_IDs) for u in units if u.isdynamic]) - return retain/waste \ No newline at end of file + return retain/waste + +# def get_oxygen_heterotrophs(system, influent=None, eff_COD_soluble = None, f_d = 0.1, b_H = 0.4, SRT = 10, Y_H = 0.625): +def get_oxygen_heterotrophs(flow, influent_COD, eff_COD_soluble, + f_d=0.1, b_H=0.4, SRT=10, Y_H=0.625): + """ + Parameters + ---------- + # system : :class:`biosteam.System` + # The system whose aeration airflow will be calculated. + flow : float + Volumetric flow rate through the system [m3/d]. + influent_COD : float + Influent COD concentration [mg/L]. The default is None. + eff_COD_soluble : float + Maximum effluent soluble COD concentration [mg/L]. + f_d : float, optional + Fraction of biomass that remains as cell debris after decay. Default value is + 0.1 gCOD/gCOD based on ASM2d 'f_XI_H'. + b_H : float, optional + Decay rate constant of heterotrophs [d^-1]. The default is 0.4 based on ASM2d. + SRT : float, optional + Estimated sludge retention time of the system. Default is 10 days. + Y_H : float, optional + Yield of heterotrophs [gCOD/gCOD]. The default is 0.625. + + Returns + ------- + float + Oxygen requirement for heterotrophs in kg/day. + + References + ---------- + [1] Adapted from Equation 10.10 in GDLF [Grady Jr, C.P.L.; Daigger, G.T.; Love, N.G.; Filipe, C.D.M. Biological + Wastewater Treatment. 3rd ed. Boca Raton, FL: CRC Press 2011.] + + """ + # if influent is None: + # influent = [inf for inf in system.feeds if inf.phase == 'l'] + + # influent_flow = np.array([inf.F_vol*24 for inf in influent]) # in m3/day + # influent_COD = np.array([inf.COD for inf in influent]) # in mg/L + + # if eff_COD_soluble is None: + # eff_COD_soluble = np.array([eff.composite('COD', particle_size='s', unit = 'mg/L') \ + # for eff in system.products if eff.phase == 'l']) + + # mass_influent_COD = np.sum(influent_flow*influent_COD/1000) # in kg/day + # mass_effluent_COD = np.sum(influent_flow*eff_COD_soluble/1000) # in kg/day + + # mass_COD_treated = mass_influent_COD - mass_effluent_COD # kg/day + + mass_COD_treated = flow * (influent_COD - eff_COD_soluble) * 1e-3 # kg/day + aeration_factor = 1 - (1 + f_d*b_H*SRT)*Y_H/(1 + b_H*SRT) + + return mass_COD_treated*aeration_factor + +# def get_oxygen_autotrophs(system, influent=None, eff_COD_soluble = None, f_d = 0.1, b_H = 0.4, b_AUT = 0.15, SRT = 10, +# Y_H = 0.625, Y_AUT = 0.24, K_NH = 1, U_AUT = 1, SF_DO = 1.375, ammonia_component_ID = 'S_NH4'): +def get_oxygen_autotrophs(flow, influent_COD, eff_COD_soluble, influent_TKN, + f_d=0.1, b_H=0.4, b_AUT=0.15, SRT=10, + Y_H=0.625, Y_AUT=0.24, K_NH=1, U_AUT=1, SF_DO=1.375): + """ + + Parameters + ---------- + # system : :class:`biosteam.System` + # The system whose aeration airflow will be calculated. + # influent : iterable[:class:`WasteStream`], optional + # Streams incoming to activated sludge process. Default is None. + flow : float + Volumetric flow rate through the system [m3/d]. + influent_COD : float + Influent COD concentration [mg/L]. + eff_COD_soluble : float + Maximum effluent soluble COD concentration [mg/L]. + influent_TKN : float + Influent TKN concentration [mg-N/L]. The default is None. + f_d : float + fraction of biomass that remains as cell debris. Default value is + 0.1 gCOD/gCOD based on ASM2d 'f_XI_A'. + b_H : float + Decay rate constant of heterotrophs [d^-1]. The default is 0.4 based on ASM2d. + b_AUT : float + Decay rate constant of autotrophs [d^-1]. The default is 0.15 based on ASM2d. + SRT : float + Estimated sludge retention time of the system. Default is 10 days. + Y_H : float + Yield of heterotrophs [gCOD/gCOD]. The default is 0.625. + Y_AUT : float + Yield of autotrophs [g COD/g N]. The default is 0.24. + K_NH: float + Substrate (Ammonium (nutrient)) half saturation coefficient for autotrophs, in [g N/m^3]. + The default is 1.0. + U_AUT: float + Autotrophic maximum specific growth rate, in [d^(-1)]. The default is 1.0. + SF_DO: float + Safety factor for dissolved oxygen. The default is 1.375. + ammonia_component_ID : str + Component ID for ammonia. The default is 'S_NH4'. + + Returns + ------- + float + Oxygen requirement for autotrophs in kg/day. + + References + ---------- + [1] Adapted from Equation 11.16-11.19 in GDLF [Grady Jr, C.P.L.; Daigger, G.T.; Love, N.G.; Filipe, C.D.M. + Biological Wastewater Treatment. 3rd ed. Boca Raton, FL: CRC Press 2011.] + + """ + + # if influent is None: + # influent = [inf for inf in system.feeds if inf.phase == 'l'] + + # influent_flow = np.array([inf.F_vol*24 for inf in influent]) # in m3/day + # influent_COD = np.array([inf.COD for inf in influent]) # in mg/L + + # if eff_COD_soluble is None: + # eff_COD_soluble = np.array([eff.composite('COD', particle_size='s', unit='mg/L') \ + # for eff in system.products if eff.phase == 'l']) + + NR = 0.087*(1 + f_d*b_H*SRT)*Y_H/(1 + b_H*SRT) + # TKN = np.array([inf.composite('N', organic=True) + + # inf.composite('N', subgroup=(ammonia_component_ID,)) \ + # for inf in influent]) + TKN = influent_TKN + S_N_a = TKN - NR*(influent_COD - eff_COD_soluble) + S_NH = K_NH*(1/SRT + b_AUT)/(U_AUT/SF_DO - (1 + b_AUT/SRT)) + aeration_factor = 4.57 - (1 + f_d*b_AUT*SRT)*Y_AUT/(1 + b_AUT*SRT) + # mass_N_removed = np.sum(influent_flow*(S_N_a - S_NH)/1000) # kg/day + mass_N_removed = flow*(S_N_a - S_NH)/1000 # kg/day + + return mass_N_removed*aeration_factor + +def get_airflow(oxygen_heterotrophs, oxygen_autotrophs, oxygen_transfer_efficiency=12): + """ + + Parameters + ---------- + oxygen_heterotrophs : float + In kg/day. + oxygen_autotrophs : float + In kg/day. + oxygen_transfer_efficiency : float + Field oxygen transfer efficiency in percentage. The default is 12. + + Returns + ------- + Airflow in m3/min. + + References + ---------- + [1] Adapted from Equation 11.2 in GDLF [Grady Jr, C.P.L.; Daigger, G.T.; Love, N.G.; Filipe, C.D.M. + Biological Wastewater Treatment. 3rd ed. Boca Raton, FL: CRC Press 2011.] + + """ + + required_oxygen = (oxygen_heterotrophs + oxygen_autotrophs)/24 # in kg/hr + Q_air = 6*required_oxygen/oxygen_transfer_efficiency + + return Q_air + +def get_P_blower(q_air, T=20, p_atm=101.325, P_inlet_loss=1, + P_diffuser_loss=7, h_submergance=5.18, efficiency=0.7, + K=0.283): + """ + + Parameters + ---------- + q_air : float + Air volumetric flow rate [m3/min]. + T : float + Air temperature [degree Celsius]. + p_atm : float + Atmostpheric pressure [kPa] + P_inlet_loss : float + Head loss at inlet [kPa]. + P_diffuser_loss : float + Head loss due to piping and diffuser [kPa]. + h_submergance : float + Diffuser submergance depth in m. The default is 17 feet (5.18 m) + efficiency : float + Blower efficiency. Default is 0.7. + K : float, optional + Equal to (kappa - 1)/kappa, where kappa is the adiabatic exponent of air. + Default is 0.283, equivalent to kappa = 1.3947. + + Returns + ------- + Power of blower [kW]. + + References + ---------- + [1] Eq.(5-77) in Metcalf & Eddy, Wastewater Engineering: Treatment and + Resource Recovery. 5th ed. New York: McGraw-Hill Education. 2014. + [2] Eq.(4.27) in Mueller, J.; William C.B.; and Popel, H.J. Aeration: + Principles and Practice, Volume 11. CRC press, 2002. + """ + + T += 273.15 + air_molar_vol = 22.4e-3 * (T/273.15)/(p_atm/101.325) # m3/mol + R = 8.31446261815324 # J/mol/K, universal gas constant + + p_in = p_atm - P_inlet_loss + p_out = p_atm + 9.81*h_submergance + P_diffuser_loss + + # Q_air = q_air*(24*60) # m3/min to m3/day + # P_blower = 1.4161e-5*(T + 273.15)*Q_air*((p_out/p_in)**0.283 - 1)/efficiency + # return P_blower + + Q_air = q_air/60 # m3/min to m3/s + WP = (Q_air / air_molar_vol) * R * T / K * ((p_out/p_in)**K - 1) / efficiency # in W + return WP/1000 + +def get_power_utility(system, active_unit_IDs=None): + ''' + Parameters + ---------- + system : :class:`biosteam.System` + The system whose power will be calculated. + active_unit_IDs : tuple[str], optional + IDs of all units whose power needs to be accounted for. The default is None. + + Returns + ------- + Cumulative power of sludge pumps [kW]. + ''' + + power_consumption = 0 + + for y in system.flowsheet.unit: + if y.ID in active_unit_IDs: + power_consumption += y.power_utility.power + + return power_consumption + +def get_cost_sludge_disposal(sludge, unit_weight_disposal_cost = 375): + ''' + Parameters + ---------- + sludge : : iterable[:class:`WasteStream`], optional + Effluent sludge from the system for which treatment and disposal costs are being calculated. + The default is None. + unit_weight_disposal_cost : float + The sludge treatment and disposal cost per unit weight (USD/ton). + Feasible range for this value lies between 100-800 USD/ton [1]. + + Land application: 300 - 800 USD/US ton. [2] + Landfill: 100 - 650 USD/US ton. [2] + Incineration: 300 - 500 USD/US ton. [2] + + The default is 375 USD/US ton, which is the close to average of lanfill. + + Returns + ------- + Cost of sludge treatment and disposal (USD/day). + + References + ------- + [1] Feng, J., Li, Y., Strathmann, T. J., & Guest, J. S. (2024b). + Characterizing the opportunity space for sustainable hydrothermal valorization + of Wet Organic Wastes. Environmental Science &; Technology. + https://doi.org/10.1021/acs.est.3c07394 + + [2] Peccia, J., & Westerhoff, P. (2015). We should expect more out of our sewage + sludge. Environmental Science & Technology, 49(14), 8271–8276. + https://doi.org/10.1021/acs.est.5b01931 + + ''' + + sludge_prod = np.array([sludge.composite('solids', True, particle_size='x', unit='ton/d') \ + for sludge in sludge]) # in ton/day + + cost_sludge_disposal = np.sum(sludge_prod)*unit_weight_disposal_cost #in USD/day + + return cost_sludge_disposal + +def get_normalized_energy(system, aeration_power, pumping_power, miscellaneous_power): + ''' + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized energy consumption is being determined. + aeration_power : float, optional + Power of blower [kW]. + pumping_power : float, optional + Power rquired for sludge pumping and other equipments [kW]. + miscellaneous_power : float, optional + Any other power requirement in the system [kW]. + + Returns + ------- + Normalized energy consumption associated with WRRF (kWh/m3). [numpy array] + + ''' + + normalized_aeration_energy = aeration_power/sum([s.F_vol for s in system.feeds]) + normalized_pumping_energy = pumping_power/sum([s.F_vol for s in system.feeds]) + normalized_miscellaneous_energy = miscellaneous_power/sum([s.F_vol for s in system.feeds]) + + normalized_energy_WRRF = np.array([normalized_aeration_energy, normalized_pumping_energy, \ + normalized_miscellaneous_energy]) + + return normalized_energy_WRRF + +def get_daily_operational_cost(system, aeration_power, pumping_power, miscellaneous_power, \ + sludge_disposal_cost, unit_electricity_cost = 0.161): + ''' + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized energy consumption is being determined. + aeration_power : float, optional + Power of blower [kW]. + pumping_power : float, optional + Power rquired for sludge pumping and other equipments [kW]. + miscellaneous_power : float, optional + Any other power requirement in the system [kW]. + sludge_disposal_cost : float + Cost of sludge treatment and disposal (USD/day). + unit_electricity_cost : float + Unit cost of electricity. Default value is taken as $0.161/kWh [1]. + + Returns + ------- + Normalized operational cost associated with WRRF (USD/day). [numpy array] + + [1] https://www.bls.gov/regions/midwest/data/averageenergyprices_selectedareas_table.htm + + ''' + aeration_cost = aeration_power*24*unit_electricity_cost # in (kWh/day)*(USD/kWh) = USD/day + pumping_cost = pumping_power*24*unit_electricity_cost # in (kWh/day)*(USD/kWh) = USD/day + sludge_disposal_costs = sludge_disposal_cost + miscellaneous_cost = miscellaneous_power*24*unit_electricity_cost # in (kWh/day)*(USD/kWh) = USD/day + + operational_costs_WRRF = np.array([aeration_cost, pumping_cost, sludge_disposal_costs, miscellaneous_cost]) + + operational_costs_WRRF = operational_costs_WRRF/sum([s.F_vol*24 for s in system.feeds]) + + return operational_costs_WRRF + +def get_aeration_cost(q_air, # aeration (blower) power + system, # sludge pumping power + T=20, p_atm=101.325, P_inlet_loss=1, P_diffuser_loss=7, + h_submergance=5.18, efficiency=0.7, K=0.283, # aeration (blower) power + unit_electricity_cost = 0.161): + ''' + Parameters + ---------- + + q_air : float + Air volumetric flow rate [m3/min]. + T : float + Air temperature [degree Celsius]. + p_atm : float + Atmostpheric pressure [kPa] + P_inlet_loss : float + Head loss at inlet [kPa]. The default is 1 kPa. + P_diffuser_loss : float + Head loss due to piping and diffuser [kPa]. The default is 7 kPa. + h_submergance : float + Diffuser submergance depth in m. The default is 17 feet (5.18 m) + efficiency : float + Blower efficiency. Default is 0.7. + K : float, optional + Equal to (kappa - 1)/kappa, where kappa is the adiabatic exponent of air. + Default is 0.283, equivalent to kappa = 1.3947. + + ------------------------------------------------------------------------------ + + system : :class:`biosteam.System` + The system whose power will be calculated. + + unit_electricity_cost : float + Unit cost of electricity. Default value is taken as $0.161/kWh [1]. + + Returns + ------- + Normalized cost associated with aeration (USD/m3). [int] + + ''' + + T += 273.15 + air_molar_vol = 22.4e-3 * (T/273.15)/(p_atm/101.325) # m3/mol + R = 8.31446261815324 # J/mol/K, universal gas constant + + p_in = p_atm - P_inlet_loss + p_out = p_atm + 9.81*h_submergance + P_diffuser_loss + + # Q_air = q_air*(24*60) # m3/min to m3/day + # P_blower = 1.4161e-5*(T + 273.15)*Q_air*((p_out/p_in)**0.283 - 1)/efficiency + # return P_blower + + Q_air = q_air/60 # m3/min to m3/s + WP = (Q_air / air_molar_vol) * R * T / K * ((p_out/p_in)**K - 1) / efficiency # in W + + aeration_power = WP/1000 # in kW + + aeration_cost = aeration_power*24*unit_electricity_cost # in (kWh/day)*(USD/kWh) = USD/day + + operational_costs_WRRF = aeration_cost/sum([s.F_vol*24 for s in system.feeds]) + + return operational_costs_WRRF + +def get_pumping_cost(system, active_unit_IDs=None, # sludge pumping power + unit_electricity_cost = 0.161): + ''' + Parameters + ---------- + system : :class:`biosteam.System` + The system whose power will be calculated. + active_unit_IDs : tuple[str], optional + IDs of all units whose power needs to be accounted for. The default is None. + + ------------------------------------------------------------------------------ + + unit_electricity_cost : float + Unit cost of electricity. Default value is taken as $0.161/kWh [1]. + + Returns + ------- + Normalized operational cost associated with sludge pumping (USD/m3). [int] + + ''' + power_consumption = 0 + + for y in system.flowsheet.unit: + if y.ID in active_unit_IDs: + power_consumption += y.power_utility.power + + pumping_power = power_consumption + + pumping_cost = pumping_power*24*unit_electricity_cost # in (kWh/day)*(USD/kWh) = USD/day + + operational_costs_WRRF = pumping_cost/sum([s.F_vol*24 for s in system.feeds]) + + return operational_costs_WRRF + +def get_sludge_disposal_costs(sludge, # sludge disposal costs + system, unit_weight_disposal_cost = 350, # sludge disposal costs + ): + ''' + Parameters + ---------- + + system : :class:`biosteam.System` + The system whose power will be calculated. + + ------------------------------------------------------------------------------ + + sludge : : iterable[:class:`WasteStream`], optional + Effluent sludge from the system for which treatment and disposal costs are being calculated. + The default is None. + unit_weight_disposal_cost : float + The sludge treatment and disposal cost per unit weight (USD/ton). + Feasible range for this value lies between 100-800 USD/ton [1]. + + Land application: 300 - 800 USD/US ton. [2] + Landfill: 100 - 650 USD/US ton. [2] + Incineration: 300 - 500 USD/US ton. [2] + + The default is 375 USD/US ton, which is the close to average of lanfill. + + Returns + ------- + Normalized operational cost associated with WRRF (USD/m3). [int] + + ''' + + sludge_prod = np.array([sludge.composite('solids', True, particle_size='x', unit='ton/d') \ + for sludge in sludge]) # in ton/day + sludge_disposal_costs = np.sum(sludge_prod)*unit_weight_disposal_cost #in USD/day + operational_costs_WRRF = sludge_disposal_costs/sum([s.F_vol*24 for s in system.feeds]) + + return operational_costs_WRRF + +def get_eq_natural_gas_price(system, gas, natural_gas_price = 0.0041, CH4_nat_gas = 0.95): + ''' + Parameters + ---------- + + system : :class:`biosteam.System` + The system whose power will be calculated. + + ------------------------------------------------------------------------------ + + gas : : iterable[:class:`WasteStream`], optional + Effluent gas from anaerobic digestor. + The default is None. + natural_gas_price : float + Price of Natural gas in USD/m3 (eia.gov). + The default is 0.0041 USD/m3. + CH4_nat_gas: Percentage of natural gas that is methane. + The default is 0.95. + + Returns + ------- + Normalized natural gas equivalent cost (USD/m3). [int] + + ''' + + # Use mass based calculation -> use mass flowrate of methane fs.gas.imass['S_ch4'] + + # 100% methane can also be assumed + + # Estimating calorific value of methane, and accordingly translating the price could be a way too + + CH4_produced = gas.F_vol*24 # m3/day + + eq_nat_gas_produced = CH4_produced/CH4_nat_gas # m3/day + + price_nat_gas = eq_nat_gas_produced*natural_gas_price # USD/day + + operational_costs_WRRF = price_nat_gas/sum([s.F_vol*24 for s in system.feeds]) + + return operational_costs_WRRF + +def get_total_operational_cost(q_air, # aeration (blower) power + sludge, # sludge disposal costs + system, active_unit_IDs=None, # sludge pumping power + T=20, p_atm=101.325, P_inlet_loss=1, P_diffuser_loss=7, + h_submergance=5.18, efficiency=0.7, K=0.283, # aeration (blower) power + miscellaneous_power = 0, + unit_weight_disposal_cost = 350, # sludge disposal costs + unit_electricity_cost = 0.161): + ''' + Parameters + ---------- + + q_air : float + Air volumetric flow rate [m3/min]. + T : float + Air temperature [degree Celsius]. + p_atm : float + Atmostpheric pressure [kPa] + P_inlet_loss : float + Head loss at inlet [kPa]. The default is 1 kPa. + P_diffuser_loss : float + Head loss due to piping and diffuser [kPa]. The default is 7 kPa. + h_submergance : float + Diffuser submergance depth in m. The default is 17 feet (5.18 m) + efficiency : float + Blower efficiency. Default is 0.7. + K : float, optional + Equal to (kappa - 1)/kappa, where kappa is the adiabatic exponent of air. + Default is 0.283, equivalent to kappa = 1.3947. + + ------------------------------------------------------------------------------ + + system : :class:`biosteam.System` + The system whose power will be calculated. + active_unit_IDs : tuple[str], optional + IDs of all units whose power needs to be accounted for. The default is None. + + ------------------------------------------------------------------------------ + + sludge : : iterable[:class:`WasteStream`], optional + Effluent sludge from the system for which treatment and disposal costs are being calculated. + The default is None. + unit_weight_disposal_cost : float + The sludge treatment and disposal cost per unit weight (USD/ton). + Feasible range for this value lies between 100-800 USD/ton [1]. + + Land application: 300 - 800 USD/US ton. [2] + Landfill: 100 - 650 USD/US ton. [2] + Incineration: 300 - 500 USD/US ton. [2] + + The default is 375 USD/US ton, which is the close to average of lanfill. + + ------------------------------------------------------------------------------ + + miscellaneous_power : float, optional + Any other power requirement in the system [kW]. + + unit_electricity_cost : float + Unit cost of electricity. Default value is taken as $0.161/kWh [1]. + + Returns + ------- + Normalized operational cost associated with WRRF (USD/m3). [int] + + ''' + + T += 273.15 + air_molar_vol = 22.4e-3 * (T/273.15)/(p_atm/101.325) # m3/mol + R = 8.31446261815324 # J/mol/K, universal gas constant + + p_in = p_atm - P_inlet_loss + p_out = p_atm + 9.81*h_submergance + P_diffuser_loss + + # Q_air = q_air*(24*60) # m3/min to m3/day + # P_blower = 1.4161e-5*(T + 273.15)*Q_air*((p_out/p_in)**0.283 - 1)/efficiency + # return P_blower + + Q_air = q_air/60 # m3/min to m3/s + WP = (Q_air / air_molar_vol) * R * T / K * ((p_out/p_in)**K - 1) / efficiency # in W + + aeration_power = WP/1000 # in kW + + power_consumption = 0 + + for y in system.flowsheet.unit: + if y.ID in active_unit_IDs: + power_consumption += y.power_utility.power + + pumping_power = power_consumption + + aeration_cost = aeration_power*24*unit_electricity_cost # in (kWh/day)*(USD/kWh) = USD/day + pumping_cost = pumping_power*24*unit_electricity_cost # in (kWh/day)*(USD/kWh) = USD/day + + sludge_prod = np.array([sludge.composite('solids', True, particle_size='x', unit='ton/d') \ + for sludge in sludge]) # in ton/day + sludge_disposal_costs = np.sum(sludge_prod)*unit_weight_disposal_cost #in USD/day + + miscellaneous_cost = miscellaneous_power*24*unit_electricity_cost # in (kWh/day)*(USD/kWh) = USD/day + + operational_costs_WRRF = np.array([aeration_cost, pumping_cost, sludge_disposal_costs, miscellaneous_cost]) + + operational_costs_WRRF = operational_costs_WRRF/sum([s.F_vol*24 for s in system.feeds]) + + total_operational_cost = np.sum(operational_costs_WRRF) #5 + + return total_operational_cost + +def get_GHG_emissions_sec_treatment(system = None, influent=None, effluent = None, + CH4_EF=0.0075, N2O_EF=0.016): + ''' + Parameters + ---------- + system : :class:`biosteam.System` + The system for which emissions during secondary treatment are being calculated. The default is None. + influent : : iterable[:class:`WasteStream`], optional + Influent wastestreams to the system whose wastewater composition determine the potential for GHG emissions. The default is None. + effluent : : iterable[:class:`WasteStream`], optional + The wastestreams which represents the effluent from the treatment process. The default is None. + CH4_EF : float, optional. + The emission factor used to calculate methane emissions in secondary treatment. The default is 0.0075 kg CH4/ kg rCOD. [1] + N2O_EF : float, optional + The emission factor used to calculate nitrous oxide emissions in secondary treatment. The default is 0.016 kg N2O-N/ kg N. [1] + + Returns + ------- + CH4_emitted : float + The amount of methane emitted during secondary treatment (kg/day). + N2O_emitted : float + The amount of nitrous oxide emitted during secondary treatment (kg/day). + + References + ---------- + [1] Chapter - 6, IPCC. (2019). In 2019 Refinement to the 2006 IPCC Guidelines for National Greenhouse Gas Inventories. + + ''' + + if influent is None: + influent = [inf for inf in system.feeds if inf.phase == 'l'] + + influent_flow = np.array([inf.F_vol*24 for inf in influent]) # in m3/day + influent_COD = np.array([inf.COD for inf in influent]) # in mg/L + mass_influent_COD = np.sum(influent_flow*influent_COD/1000) # in kg/day + + effluent_flow = np.array([eff.F_vol*24 for eff in effluent]) + effluent_COD = np.array([eff.COD for eff in effluent]) # in mg/L + mass_effluent_COD = np.sum(effluent_flow*effluent_COD/1000) # in kg/day + + mass_removed_COD = mass_influent_COD - mass_effluent_COD + CH4_emitted = CH4_EF*mass_removed_COD + + influent_N = np.array([inf.TN for inf in influent]) # in mg/L + mass_influent_N = np.sum(influent_flow*influent_N/1000) # in kg/day + + N2O_emitted = N2O_EF*mass_influent_N + + return CH4_emitted, N2O_emitted + +def get_GHG_emissions_discharge(effluent=None, CH4_EF=0.009, N2O_EF=0.005): + ''' + Parameters + ---------- + effluent : : iterable[:class:`WasteStream`], optional + Effluent wastestreams from the system whose wastewater composition determine the potential for GHG emissions at discharge. The default is None. + CH4_EF_discharge : float, optional. + The emission factor used to calculate methane emissions in discharge. The default is 0.009 kg CH4/ kg effluent COD. [1] + N2O_EF_discharge : float, optional + The emission factor used to calculate nitrous oxide emissions in discharge. The default is 0.005 kg N2O-N/ kg effluent N. [1] + + Returns + ------- + CH4_emitted : float + The amount of methane emitted at discharge (kg/day). + N2O_emitted : float + The amount of nitrous oxide emitted at discharge (kg/day). + + References + ---------- + [1] Chapter - 6, IPCC. (2019). In 2019 Refinement to the 2006 IPCC Guidelines + for National Greenhouse Gas Inventories. + + ''' + + effluent_flow = np.array([eff.F_vol*24 for eff in effluent]) # in m3/day + effluent_COD = np.array([eff.COD for eff in effluent]) # in mg/L + mass_effluent_COD = np.sum(effluent_flow*effluent_COD/1000) # in kg/day + + CH4_emitted = CH4_EF*mass_effluent_COD + + effluent_N = np.array([eff.TN for eff in effluent]) # in mg/L + mass_effluent_N = np.sum(effluent_flow*effluent_N/1000) # in kg/day + + N2O_emitted = N2O_EF*mass_effluent_N + + return CH4_emitted, N2O_emitted + +def get_GHG_emissions_electricity(system, power_blower, power_pump, CO2_EF=0.675): + ''' + Parameters + ---------- + system : :class:`biosteam.System` + The system for which tier-2 GHG emissions due to electricity consumption are being calculated. + power_blower : float + Power of blower [kW]. + power_pump : float + Power required for pumping and other utilities at treatment facility [kW]. + CO2_EF : float + The emission factor used to calculate tier-2 CO2 emissions due to electricity consumption. + The default is 0.675 kg-CO2-Eq/kWh. [1] + The emission factor is dependent on the region, and is as follows for USA: + + {SERC Reliability Corporation (SERC): 0.618 kg-CO2-Eq/kWh + ReliabilityFirst (RFC): 0.619 kg-CO2-Eq/kWh + Western Electricity Coordinating Council (WECC): 0.436 kg-CO2-Eq/kWh + Texas Reliability Entity (TRE): 0.574 kg-CO2-Eq/kWh + Southwest Power Pool (SPP): 0.733 kg-CO2-Eq/kWh + Midwest Reliability Organization (MRO): 0.675 kg-CO2-Eq/kWh + Florida Reliability Coordinating Council (FRCC): 0.531 kg-CO2-Eq/kWh + Northeast Power Coordinating Council (NPCC): 0.244 kg-CO2-Eq/kWh} + (HICC): 0.814 kg-CO2-Eq/kWh} + (ASCC): 0.599 kg-CO2-Eq/kWh} + + + Returns + ------- + CO2_emitted : float + The amount of eq. CO2 emitted due to electrity consumption (kg-CO2-Eq/day). + + ''' + + total_energy_consumed = (power_blower + power_pump)*24 # in kWh/day + CO2_emissions = total_energy_consumed*CO2_EF # in kg-CO2-Eq/day + + return CO2_emissions + +def get_GHG_emissions_sludge_disposal(sludge=None, DOC_f = 0.45, MCF = 0.8, k = 0.06, F=0.5, pl=30): + ''' + Parameters + ---------- + sludge : : iterable[:class:`WasteStream`], optional + Effluent sludge from the system for which GHG emissions are being calculated. The default is None. + DOC_f : float, optional + fraction of DOC that can decompose (fraction). The default value is 0.5. + MCF : float, optional + CH4 correction factor for aerobic decomposition in the year of deposition (fraction). The default is 0.8. + k : TYPE, optional + Methane generation rate (k). The default is 0.185. (1/year) + + The decomposition of carbon is assumed to follow first-order kinetics + (with rate constant k), and methane generation is dependent on the amount of remaining decomposable carbon + in the waste. For North America (boreal and temperate climate) the default values for wet and dry climate + are: + k (dry climate) = 0.06 + k (wet climate) = 0.185 + F : float, optional + Fraction of methane in generated landfill gas (volume fraction). The default is 0.5. + pl : float, optional + The project lifetime over which methane emissions would be calculated. (years) + The default is 30 years. + + Returns + ------- + CH4_emitted : float + The average amount of methane emitted during sludge disposal (kg/day). + + References + ---------- + [1] Chapter - 3: Solid Waste Disposal, IPCC. (2019). In 2019 Refinement to + the 2006 IPCC Guidelines for National Greenhouse Gas Inventories. + + ''' + # sludge_flow = np.array([slg.F_vol*24 for slg in sludge]) # in m3/day + # sludge_COD = np.array([slg.COD for slg in sludge]) # in mg/L + DOC_mass_flow = 0 + + for slg in sludge: + DOC_mass_flow += slg.composite("C", flow=True, exclude_gas=True, + subgroup=None, particle_size=None, + degradability="b", organic=True, volatile=None, + specification=None, unit="kg/day") + + annual_DOC_mass = 365*DOC_mass_flow # in kg/year + + annual_DDOC = annual_DOC_mass*DOC_f*MCF + + # decomposed_DOC = 0 + + # # sum of sum of geometric series + # for t in range(pl + 1): + # # sum of a geometric series where acc_DOC + # acc_DOC = annual_DDOC * (1 - np.exp(-1 * k * t)) / (1 - np.exp(-1 * k)) + # # all the acc_DOC at the start of the year is the only one contributing + # # to decomposition in that one year + # decomposed_DOC += acc_DOC*(1 - np.exp(-1*k)) + + # # make annumpy array from 0 to pl + 1 outside the for loop + + # # replace t with DOC_ARRAY + + t_vary = np.arange(pl) + decomposed_DOC = annual_DDOC * (1 - np.exp(-1 * k * t_vary)) + total_decomposed_DOC = np.sum(decomposed_DOC) + CH4_emitted_during_pl = total_decomposed_DOC*F*16/12 + + accumulated_DOC_at_pl = annual_DDOC* (1 - np.exp(-1 * k * (pl-1))) / (1 - np.exp(-1 * k)) + CH4_emitted_after_pl = accumulated_DOC_at_pl*F*16/12 + + days_in_year = 365 + + return CH4_emitted_during_pl/(pl*days_in_year), CH4_emitted_after_pl/(pl*days_in_year) + +def get_CO2_eq_WRRF (system, GHG_treatment, GHG_discharge, GHG_electricity, + GHG_sludge_disposal, CH4_CO2eq=29.8, N2O_CO2eq=273): + ''' + + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized GHG emission is being determined. + GHG_treatment : tuple[int], optional + The amount of methane and nitrous oxide emitted during secondary treatment (kg/day). + GHG_discharge : tuple[int], optional + The amount of methane and nitrous oxide emitted during effluent discharge (kg/day). + GHG_electricity : float + The amount of eq. CO2 emitted due to electrity consumption (kg-CO2-Eq/day). + GHG_sludge_disposal : int + The average amount of methane emitted during sludge disposal (kg/day). + CH4_CO2eq : TYPE, optional + DESCRIPTION. The default is 29.8 kg CO2eq/kg CH4 [1]. + N2O_CO2eq : TYPE, optional + DESCRIPTION. The default is 273 kg CO2eq/kg CH4 [1]. + + Returns + ------- + Normalized GHG emissions from onsite and offsite operations associated with WRRF (kg CO2 eq./m3). [numpy array] + + References + ---------- + [1] IPCC 2021 – 6th Assessment Report Values. + + ''' + + # source 1 (on-site) + CH4_CO2_eq_treatment = GHG_treatment[0]*CH4_CO2eq + N2O_CO2_eq_treatment = GHG_treatment[1]*N2O_CO2eq + + + # source 3 (off-site) + CH4_CO2_eq_discharge = GHG_discharge[0]*CH4_CO2eq + N2O_CO2_eq_discharge = GHG_discharge[1]*N2O_CO2eq + + # source 4 (off-site) + CH4_CO2_eq_sludge_disposal_pl = GHG_sludge_disposal[0]*CH4_CO2eq + CH4_CO2_eq_sludge_disposal_after_pl = GHG_sludge_disposal[1]*CH4_CO2eq + + # source 5 (off-site) + CO2_eq_electricity = GHG_electricity*1 + + CO2_eq_WRRF = np.array([CH4_CO2_eq_treatment, N2O_CO2_eq_treatment, #1 + CH4_CO2_eq_discharge, N2O_CO2_eq_discharge, #3 + CH4_CO2_eq_sludge_disposal_pl, #4 + CH4_CO2_eq_sludge_disposal_after_pl, #4 + CO2_eq_electricity]) #5 + + normalized_CO2_eq_WRRF = CO2_eq_WRRF/sum([24*s.F_vol for s in system.feeds]) + + return normalized_CO2_eq_WRRF + +def get_CH4_CO2_eq_treatment(system, influent_sc =None, effluent_sc = None, + CH4_CO2eq=29.8, CH4_EF_sc =0.0075): + + ''' + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized GHG emission is being determined. + + ----Secondary treatment---- + + influent_sc : : iterable[:class:`WasteStream`], optional + Influent wastestreams to secondary treatment whose wastewater composition determine the potential for GHG emissions. The default is None. + effluent_sc : : iterable[:class:`WasteStream`], optional + The wastestreams which represents the effluent from the secondary treatment process. The default is None. + CH4_EF_sc : float, optional. + The emission factor used to calculate methane emissions in secondary treatment. The default is 0.0075 kg CH4/ kg rCOD. [1] + + --------- Eq CO2 EFs --------- + + CH4_CO2eq : TYPE, optional + DESCRIPTION. The default is 29.8 kg CO2eq/kg CH4 [1]. + + Returns + ------- + Total Normalized CH4 emissions from secondary treatment (kg CO2 eq./m3) [int]. + ''' + + # source 1 (on-site) + if influent_sc is None: + influent_sc = [inf for inf in system.feeds if inf.phase == 'l'] + + influent_sc_flow = np.array([inf.F_vol*24 for inf in influent_sc]) # in m3/day + influent_sc_COD = np.array([inf.COD for inf in influent_sc]) # in mg/L + mass_influent_COD_sc = np.sum(influent_sc_flow*influent_sc_COD/1000) # in kg/day + + effluent_sc_flow = np.array([eff.F_vol*24 for eff in effluent_sc]) + effluent_sc_COD = np.array([eff.COD for eff in effluent_sc]) # in mg/L + mass_effluent_COD_sc = np.sum(effluent_sc_flow*effluent_sc_COD/1000) # in kg/day + + mass_removed_COD_sc = mass_influent_COD_sc - mass_effluent_COD_sc + CH4_emitted_sc = CH4_EF_sc*mass_removed_COD_sc + + # source 1 (on-site) + CH4_CO2_eq_treatment = CH4_emitted_sc*CH4_CO2eq + + normalized_CO2_eq_WRRF = CH4_CO2_eq_treatment/sum([24*s.F_vol for s in system.feeds]) + + return normalized_CO2_eq_WRRF + +def get_N2O_CO2_eq_treatment(system, influent_sc =None, N2O_CO2eq=273, F=0.5, N2O_EF_sc =0.016): + + ''' + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized GHG emission is being determined. + + ----Secondary treatment---- + + influent_sc : : iterable[:class:`WasteStream`], optional + Influent wastestreams to secondary treatment whose wastewater composition determine the potential for GHG emissions. The default is None. + N2O_EF_sc : float, optional + The emission factor used to calculate nitrous oxide emissions in secondary treatment. The default is 0.016 kg N2O-N/ kg N. [1] + + --------- Eq CO2 EFs --------- + + N2O_CO2eq : TYPE, optional + DESCRIPTION. The default is 273 kg CO2eq/kg CH4 [1]. + + Returns + ------- + Total Normalized N2O emissions from secondary treatment (kg CO2 eq./m3) [int]. + ''' + + # source 1 (on-site) + if influent_sc is None: + influent_sc = [inf for inf in system.feeds if inf.phase == 'l'] + + influent_sc_flow = np.array([inf.F_vol*24 for inf in influent_sc]) # in m3/day + + influent_N_sc = np.array([inf.TN for inf in influent_sc]) # in mg/L + mass_influent_N = np.sum(influent_sc_flow*influent_N_sc/1000) # in kg/day + + N2O_emitted_sc = N2O_EF_sc*mass_influent_N + + N2O_CO2_eq_treatment = N2O_emitted_sc*N2O_CO2eq + + normalized_CO2_eq_WRRF = N2O_CO2_eq_treatment/sum([24*s.F_vol for s in system.feeds]) + + return normalized_CO2_eq_WRRF + +def get_CH4_CO2_eq_discharge(system, effluent_sys =None, CH4_CO2eq=29.8, CH4_EF_discharge=0.009): + + ''' + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized GHG emission is being determined. + + ----Discharge---- + + effluent_sys : : iterable[:class:`WasteStream`], optional + Effluent wastestreams from the system whose wastewater composition determine the potential for GHG emissions at discharge. The default is None. + CH4_EF_discharge : float, optional. + The emission factor used to calculate methane emissions in discharge. The default is 0.009 kg CH4/ kg effluent COD. [1] + + --------- Eq CO2 EFs --------- + + CH4_CO2eq : TYPE, optional + DESCRIPTION. The default is 29.8 kg CO2eq/kg CH4 [1]. + + Returns + ------- + Total Normalized GHG emissions from onsite and offsite operations associated with WRRF (kg CO2 eq./m3) [int]. + + ''' + + # source 3 (off-site) + effluent_flow = np.array([eff.F_vol*24 for eff in effluent_sys]) # in m3/day + effluent_COD = np.array([eff.COD for eff in effluent_sys]) # in mg/L + mass_effluent_COD = np.sum(effluent_flow*effluent_COD/1000) # in kg/day + + CH4_emitted_discharge = CH4_EF_discharge*mass_effluent_COD + + # source 3 (off-site) + CH4_CO2_eq_discharge = CH4_emitted_discharge*CH4_CO2eq + + normalized_total_CO2_eq_WRRF = CH4_CO2_eq_discharge/sum([24*s.F_vol for s in system.feeds]) + + return normalized_total_CO2_eq_WRRF + +def get_N2O_CO2_eq_discharge(system, effluent_sys =None, N2O_CO2eq=273, F=0.5, N2O_EF_discharge=0.005): + ''' + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized GHG emission is being determined. + + ----Discharge---- + + effluent_sys : : iterable[:class:`WasteStream`], optional + Effluent wastestreams from the system whose wastewater composition determine the potential for GHG emissions at discharge. The default is None. + N2O_EF_discharge : float, optional + The emission factor used to calculate nitrous oxide emissions in discharge. The default is 0.005 kg N2O-N/ kg effluent N. [1] + + --------- Eq CO2 EFs --------- + + N2O_CO2eq : TYPE, optional + DESCRIPTION. The default is 273 kg CO2eq/kg CH4 [1]. + + Returns + ------- + Total Normalized GHG emissions from onsite and offsite operations associated with WRRF (kg CO2 eq./m3) [int]. + + + ''' + + # source 3 (off-site) + effluent_flow = np.array([eff.F_vol*24 for eff in effluent_sys]) # in m3/day + effluent_N = np.array([eff.TN for eff in effluent_sys]) # in mg/L + mass_effluent_N = np.sum(effluent_flow*effluent_N/1000) # in kg/day + + N2O_emitted_discharge = N2O_EF_discharge*mass_effluent_N + N2O_CO2_eq_discharge = N2O_emitted_discharge*N2O_CO2eq + normalized_total_CO2_eq_WRRF = N2O_CO2_eq_discharge/sum([24*s.F_vol for s in system.feeds]) + + return normalized_total_CO2_eq_WRRF + +def get_CH4_emitted_during_pl(system, sludge=None, CH4_CO2eq=29.8, F=0.5, DOC_f = 0.45, MCF = 0.8, k = 0.06, pl=30): + ''' + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized GHG emission is being determined. + + ----Sludge disposal--- + + sludge : : iterable[:class:`WasteStream`], optional + Effluent sludge from the system for which GHG emissions are being calculated. The default is None. + DOC_f : float, optional + fraction of DOC that can decompose (fraction). The default value is 0.5. + MCF : float, optional + CH4 correction factor for aerobic decomposition in the year of deposition (fraction). The default is 0.8. + k : TYPE, optional + Methane generation rate (k). The default is 0.185. (1/year) + + The decomposition of carbon is assumed to follow first-order kinetics + (with rate constant k), and methane generation is dependent on the amount of remaining decomposable carbon + in the waste. For North America (boreal and temperate climate) the default values for wet and dry climate + are: + k (dry climate) = 0.06 + k (wet climate) = 0.185 + F : float, optional + Fraction of methane in generated landfill gas (volume fraction). The default is 0.5. + pl : float, optional + The project lifetime over which methane emissions would be calculated. (years) + The default is 30 years. + + --------- Eq CO2 EFs --------- + + CH4_CO2eq : TYPE, optional + DESCRIPTION. The default is 29.8 kg CO2eq/kg CH4 [1]. + + Returns + ------- + Total Normalized CH4 emissions from sludge disposal during project lifetime (kg CO2 eq./m3) [int]. + + ''' + DOC_mass_flow = 0 + + for slg in sludge: + DOC_mass_flow += slg.composite("C", flow=True, exclude_gas=True, + subgroup=None, particle_size=None, + degradability="b", organic=True, volatile=None, + specification=None, unit="kg/day") + + annual_DOC_mass = 365*DOC_mass_flow # in kg/year + + annual_DDOC = annual_DOC_mass*DOC_f*MCF + + t_vary = np.arange(pl) + decomposed_DOC = annual_DDOC * (1 - np.exp(-1 * k * t_vary)) + total_decomposed_DOC = np.sum(decomposed_DOC) + CH4_emitted_during_pl = total_decomposed_DOC*F*16/12 + + days_in_year = 365 + + CH4_CO2_eq_sludge_disposal_pl = (CH4_emitted_during_pl/(pl*days_in_year))*CH4_CO2eq + + normalized_total_CO2_eq_WRRF = CH4_CO2_eq_sludge_disposal_pl/sum([24*s.F_vol for s in system.feeds]) + + return normalized_total_CO2_eq_WRRF + +def get_CH4_emitted_after_pl(system, sludge=None, CH4_CO2eq=29.8, N2O_CO2eq=273, F=0.5, + # uncertain parameters + DOC_f = 0.45, MCF = 0.8, k = 0.06, pl=30 + ): + + ''' + + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized GHG emission is being determined. + + ----Sludge disposal--- + + sludge : : iterable[:class:`WasteStream`], optional + Effluent sludge from the system for which GHG emissions are being calculated. The default is None. + DOC_f : float, optional + fraction of DOC that can decompose (fraction). The default value is 0.5. + MCF : float, optional + CH4 correction factor for aerobic decomposition in the year of deposition (fraction). The default is 0.8. + k : TYPE, optional + Methane generation rate (k). The default is 0.185. (1/year) + + The decomposition of carbon is assumed to follow first-order kinetics + (with rate constant k), and methane generation is dependent on the amount of remaining decomposable carbon + in the waste. For North America (boreal and temperate climate) the default values for wet and dry climate + are: + k (dry climate) = 0.06 + k (wet climate) = 0.185 + F : float, optional + Fraction of methane in generated landfill gas (volume fraction). The default is 0.5. + pl : float, optional + The project lifetime over which methane emissions would be calculated. (years) + The default is 30 years. + + + --------- Eq CO2 EFs --------- + + CH4_CO2eq : TYPE, optional + DESCRIPTION. The default is 29.8 kg CO2eq/kg CH4 [1]. + + Returns + ------- + Total Normalized CH4 emissions from acculumulated sludge disposal after project lifetime (kg CO2 eq./m3) [int]. + + + ''' + # source 4 (off-site) + + DOC_mass_flow = 0 + + for slg in sludge: + DOC_mass_flow += slg.composite("C", flow=True, exclude_gas=True, + subgroup=None, particle_size=None, + degradability="b", organic=True, volatile=None, + specification=None, unit="kg/day") + + annual_DOC_mass = 365*DOC_mass_flow # in kg/year + + annual_DDOC = annual_DOC_mass*DOC_f*MCF + + accumulated_DOC_at_pl = annual_DDOC* (1 - np.exp(-1 * k * (pl-1))) / (1 - np.exp(-1 * k)) + CH4_emitted_after_pl = accumulated_DOC_at_pl*F*16/12 + + days_in_year = 365 + + CH4_CO2_eq_sludge_disposal_pl = (CH4_emitted_after_pl/(pl*days_in_year))*CH4_CO2eq + + normalized_total_CO2_eq_WRRF = CH4_CO2_eq_sludge_disposal_pl/sum([24*s.F_vol for s in system.feeds]) + + return normalized_total_CO2_eq_WRRF + +def get_CO2_eq_electricity(system, q_air, active_unit_IDs=None, p_atm=101.325, K=0.283, T=20, + # uncertain parameters + P_inlet_loss=1, P_diffuser_loss=7, h_submergance=5.18, efficiency=0.7, CO2_EF=0.675): + ''' + + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized GHG emission is being determined. + + ----Electricity--- + + --blower power- + q_air : float + Air volumetric flow rate [m3/min]. + T : float + Air temperature [degree Celsius]. + p_atm : float + Atmostpheric pressure [kPa] + P_inlet_loss : float + Head loss at inlet [kPa]. + P_diffuser_loss : float + Head loss due to piping and diffuser [kPa]. + h_submergance : float + Diffuser submergance depth in m. The default is 17 feet (5.18 m) + efficiency : float + Blower efficiency. Default is 0.7. + K : float, optional + Equal to (kappa - 1)/kappa, where kappa is the adiabatic exponent of air. + Default is 0.283, equivalent to kappa = 1.3947. + + --pump power-- + active_unit_IDs : tuple[str], optional + IDs of all units whose power needs to be accounted for. The default is None. + + + Returns + ------- + Total Normalized CO2 emissions from electricity consumption (kg CO2 eq./m3) [int]. + + + ''' + # source 5 (off-site) + T += 273.15 + air_molar_vol = 22.4e-3 * (T/273.15)/(p_atm/101.325) # m3/mol + R = 8.31446261815324 # J/mol/K, universal gas constant + + p_in = p_atm - P_inlet_loss + p_out = p_atm + 9.81*h_submergance + P_diffuser_loss + + # Q_air = q_air*(24*60) # m3/min to m3/day + # P_blower = 1.4161e-5*(T + 273.15)*Q_air*((p_out/p_in)**0.283 - 1)/efficiency + # return P_blower + + Q_air = q_air/60 # m3/min to m3/s + WP = (Q_air / air_molar_vol) * R * T / K * ((p_out/p_in)**K - 1) / efficiency # in W + + blower_power = WP/1000 + + pumping_power = 0 + + for y in system.flowsheet.unit: + if y.ID in active_unit_IDs: + pumping_power += y.power_utility.power # in kW + + total_energy_consumed = (blower_power + pumping_power)*24 # in kWh/day + + GHG_electricity = total_energy_consumed*CO2_EF # in kg-CO2-Eq/day + + # source 5 (off-site) + CO2_eq_electricity = GHG_electricity*1 + + normalized_total_CO2_eq_WRRF = CO2_eq_electricity/sum([24*s.F_vol for s in system.feeds]) + + return normalized_total_CO2_eq_WRRF + +def get_total_CO2_eq(system, q_air, influent_sc =None, effluent_sc = None, effluent_sys =None, active_unit_IDs=None, sludge=None, + p_atm=101.325, K=0.283, CH4_CO2eq=29.8, N2O_CO2eq=273, F=0.5, + + CH4_EF_sc =0.0075, N2O_EF_sc =0.016, CH4_EF_discharge=0.009, N2O_EF_discharge=0.005, + T=20, + + # uncertain parameters + P_inlet_loss=1, P_diffuser_loss=7, h_submergance=5.18, efficiency=0.7, + + CO2_EF=0.675, DOC_f = 0.45, MCF = 0.8, k = 0.06, pl=30 + ): + + ''' + + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized GHG emission is being determined. + + ----Secondary treatment---- + + influent_sc : : iterable[:class:`WasteStream`], optional + Influent wastestreams to secondary treatment whose wastewater composition determine the potential for GHG emissions. The default is None. + effluent_sc : : iterable[:class:`WasteStream`], optional + The wastestreams which represents the effluent from the secondary treatment process. The default is None. + CH4_EF_sc : float, optional. + The emission factor used to calculate methane emissions in secondary treatment. The default is 0.0075 kg CH4/ kg rCOD. [1] + N2O_EF_sc : float, optional + The emission factor used to calculate nitrous oxide emissions in secondary treatment. The default is 0.016 kg N2O-N/ kg N. [1] + + ----Discharge---- + + effluent_sys : : iterable[:class:`WasteStream`], optional + Effluent wastestreams from the system whose wastewater composition determine the potential for GHG emissions at discharge. The default is None. + CH4_EF_discharge : float, optional. + The emission factor used to calculate methane emissions in discharge. The default is 0.009 kg CH4/ kg effluent COD. [1] + N2O_EF_discharge : float, optional + The emission factor used to calculate nitrous oxide emissions in discharge. The default is 0.005 kg N2O-N/ kg effluent N. [1] + + + ----Electricity--- + + --blower power- + q_air : float + Air volumetric flow rate [m3/min]. + T : float + Air temperature [degree Celsius]. + p_atm : float + Atmostpheric pressure [kPa] + P_inlet_loss : float + Head loss at inlet [kPa]. + P_diffuser_loss : float + Head loss due to piping and diffuser [kPa]. + h_submergance : float + Diffuser submergance depth in m. The default is 17 feet (5.18 m) + efficiency : float + Blower efficiency. Default is 0.7. + K : float, optional + Equal to (kappa - 1)/kappa, where kappa is the adiabatic exponent of air. + Default is 0.283, equivalent to kappa = 1.3947. + + --pump power-- + active_unit_IDs : tuple[str], optional + IDs of all units whose power needs to be accounted for. The default is None. + + + ----Sludge disposal--- + + sludge : : iterable[:class:`WasteStream`], optional + Effluent sludge from the system for which GHG emissions are being calculated. The default is None. + DOC_f : float, optional + fraction of DOC that can decompose (fraction). The default value is 0.5. + MCF : float, optional + CH4 correction factor for aerobic decomposition in the year of deposition (fraction). The default is 0.8. + k : TYPE, optional + Methane generation rate (k). The default is 0.185. (1/year) + + The decomposition of carbon is assumed to follow first-order kinetics + (with rate constant k), and methane generation is dependent on the amount of remaining decomposable carbon + in the waste. For North America (boreal and temperate climate) the default values for wet and dry climate + are: + k (dry climate) = 0.06 + k (wet climate) = 0.185 + F : float, optional + Fraction of methane in generated landfill gas (volume fraction). The default is 0.5. + pl : float, optional + The project lifetime over which methane emissions would be calculated. (years) + The default is 30 years. + + + --------- Eq CO2 EFs --------- + + CH4_CO2eq : TYPE, optional + DESCRIPTION. The default is 29.8 kg CO2eq/kg CH4 [1]. + N2O_CO2eq : TYPE, optional + DESCRIPTION. The default is 273 kg CO2eq/kg CH4 [1]. + + Returns + ------- + Total Normalized GHG emissions from onsite and offsite operations associated with WRRF (kg CO2 eq./m3) [int]. + + + ''' + + # source 1 (on-site) + if influent_sc is None: + influent_sc = [inf for inf in system.feeds if inf.phase == 'l'] + + influent_sc_flow = np.array([inf.F_vol*24 for inf in influent_sc]) # in m3/day + influent_sc_COD = np.array([inf.COD for inf in influent_sc]) # in mg/L + mass_influent_COD_sc = np.sum(influent_sc_flow*influent_sc_COD/1000) # in kg/day + + effluent_sc_flow = np.array([eff.F_vol*24 for eff in effluent_sc]) + effluent_sc_COD = np.array([eff.COD for eff in effluent_sc]) # in mg/L + mass_effluent_COD_sc = np.sum(effluent_sc_flow*effluent_sc_COD/1000) # in kg/day + + mass_removed_COD_sc = mass_influent_COD_sc - mass_effluent_COD_sc + CH4_emitted_sc = CH4_EF_sc*mass_removed_COD_sc + + influent_N_sc = np.array([inf.TN for inf in influent_sc]) # in mg/L + mass_influent_N = np.sum(influent_sc_flow*influent_N_sc/1000) # in kg/day + + N2O_emitted_sc = N2O_EF_sc*mass_influent_N + + # source 1 (on-site) + CH4_CO2_eq_treatment = CH4_emitted_sc*CH4_CO2eq + N2O_CO2_eq_treatment = N2O_emitted_sc*N2O_CO2eq + + # source 3 (off-site) + effluent_flow = np.array([eff.F_vol*24 for eff in effluent_sys]) # in m3/day + effluent_COD = np.array([eff.COD for eff in effluent_sys]) # in mg/L + mass_effluent_COD = np.sum(effluent_flow*effluent_COD/1000) # in kg/day + + CH4_emitted_discharge = CH4_EF_discharge*mass_effluent_COD + + effluent_N = np.array([eff.TN for eff in effluent_sys]) # in mg/L + mass_effluent_N = np.sum(effluent_flow*effluent_N/1000) # in kg/day + + N2O_emitted_discharge = N2O_EF_discharge*mass_effluent_N + + # source 3 (off-site) + CH4_CO2_eq_discharge = CH4_emitted_discharge*CH4_CO2eq + N2O_CO2_eq_discharge = N2O_emitted_discharge*N2O_CO2eq + + + # source 5 (off-site) + T += 273.15 + air_molar_vol = 22.4e-3 * (T/273.15)/(p_atm/101.325) # m3/mol + R = 8.31446261815324 # J/mol/K, universal gas constant + + p_in = p_atm - P_inlet_loss + p_out = p_atm + 9.81*h_submergance + P_diffuser_loss + + # Q_air = q_air*(24*60) # m3/min to m3/day + # P_blower = 1.4161e-5*(T + 273.15)*Q_air*((p_out/p_in)**0.283 - 1)/efficiency + # return P_blower + + Q_air = q_air/60 # m3/min to m3/s + WP = (Q_air / air_molar_vol) * R * T / K * ((p_out/p_in)**K - 1) / efficiency # in W + + blower_power = WP/1000 + + pumping_power = 0 + + for y in system.flowsheet.unit: + if y.ID in active_unit_IDs: + pumping_power += y.power_utility.power # in kW + + total_energy_consumed = (blower_power + pumping_power)*24 # in kWh/day + + GHG_electricity = total_energy_consumed*CO2_EF # in kg-CO2-Eq/day + + # source 5 (off-site) + CO2_eq_electricity = GHG_electricity*1 + + # source 4 (off-site) + + if sludge == None: + CH4_CO2_eq_sludge_disposal_pl = 0 + else: + DOC_mass_flow = 0 + + for slg in sludge: + DOC_mass_flow += slg.composite("C", flow=True, exclude_gas=True, + subgroup=None, particle_size=None, + degradability="b", organic=True, volatile=None, + specification=None, unit="kg/day") + + annual_DOC_mass = 365*DOC_mass_flow # in kg/year + + annual_DDOC = annual_DOC_mass*DOC_f*MCF + + t_vary = np.arange(pl) + decomposed_DOC = annual_DDOC * (1 - np.exp(-1 * k * t_vary)) + total_decomposed_DOC = np.sum(decomposed_DOC) + CH4_emitted_during_pl = total_decomposed_DOC*F*16/12 + + accumulated_DOC_at_pl = annual_DDOC* (1 - np.exp(-1 * k * (pl-1))) / (1 - np.exp(-1 * k)) + CH4_emitted_after_pl = accumulated_DOC_at_pl*F*16/12 + + days_in_year = 365 + + GHG_sludge_disposal = (CH4_emitted_during_pl + CH4_emitted_after_pl)/(pl*days_in_year) + + CH4_CO2_eq_sludge_disposal_pl = GHG_sludge_disposal*CH4_CO2eq + + CO2_eq_WRRF = np.array([CH4_CO2_eq_treatment, N2O_CO2_eq_treatment, #1 + CH4_CO2_eq_discharge, N2O_CO2_eq_discharge, #3 + CH4_CO2_eq_sludge_disposal_pl, #4 + CO2_eq_electricity]) #5 + + normalized_total_CO2_eq_WRRF = np.sum(CO2_eq_WRRF)/sum([24*s.F_vol for s in system.feeds]) + + return normalized_total_CO2_eq_WRRF + +def estimate_ww_treatment_energy_demand(daily_energy_demand, daily_flow = 37854, ww_pcpd = 0.175): + ''' + Parameters + ---------- + daily_energy_demand : float + Energy consumption at a centralized WRRF. (kWh/day) + daily_flow : TYPE + Daily wastewater flow. (m3/day) + ww_pcpd : TYPE, float + Average wastewater generated per capita per day. The default is 0.175. [1] + + Returns + ------- + None. + + [1] Jones, E. R., Van Vliet, M. T., Qadir, M., & Bierkens, M. F. (2021). Country-level and + gridded estimates of wastewater production, collection, treatment and reuse. Earth System Science Data, + 13(2), 237-254. + + ''' + ed_pcpd = (daily_energy_demand/daily_flow)*ww_pcpd + + return ed_pcpd + + +def estimate_N_removal_energy_demand(daily_energy_demand, effluent_N_conc, daily_flow = 37854, influent_N_conc = 40, + per_capita_protein_intake = 68.6, N_in_pro = 0.13, N_excreted = 1): + ''' + Parameters + ---------- + daily_energy_demand : float + Energy consumption at a centralized WRRF. (kWh/day) + daily_flow : TYPE + Daily wastewater flow. (m3/day) + influent_N_conc : float + TN concentration in the influent. (mg/L) + effluent_N_conc : float + N concentration in the effluent. (mg/L) + per_capita_protein_intake : float + Per capita protein intake. (g/cap/day) + N_in_pro : float + % of N in protein. (%) + N_excreted : float + % of N intake that is excreted. (%) + + Returns + ------- + None. + + ''' + daily_ed_N_removal = daily_energy_demand/(daily_flow*(influent_N_conc - effluent_N_conc)) + + per_capita_per_day_N = per_capita_protein_intake*N_in_pro*N_excreted + + return daily_ed_N_removal*per_capita_per_day_N + +def estimate_P_removal_energy_demand(daily_energy_demand, effluent_TP_conc, + daily_flow = 37854, influent_TP_conc = 7, + pc_ani_protein_intake = 12.39, + pc_plant_protein_intake = 40.29, + P_ani_pro = 0.011, + P_plant_pro = 0.022, + P_excreted = 1): + ''' + Parameters + ---------- + daily_energy_demand : float + Energy consumption at a centralized WRRF. (kWh/day) + effluent_TP_conc : float + TN concentration in the effluent (mg/L). + daily_flow : TYPE, optional + Daily wastewater flow (m3/day) The default is 37854. + influent_TP_conc : float, optional + TN concentration in the influent (mg/L). The default is 7. + pc_ani_protein_intake : float, optional + DESCRIPTION. The default is 12.39. + pc_plant_protein_intake : TYPE, optional + DESCRIPTION. The default is 40.29. + P_ani_pro : TYPE, optional + DESCRIPTION. The default is 0.011. + P_plant_pro : TYPE, optional + DESCRIPTION. The default is 0.022. + P_excreted : TYPE, optional + DESCRIPTION. The default is 1. + + Returns + ------- + None. + + ''' + daily_ed_P_removal = daily_energy_demand/(daily_flow*(influent_TP_conc - effluent_TP_conc)) + per_capita_per_day_P = (pc_ani_protein_intake*P_ani_pro + pc_plant_protein_intake*P_plant_pro)*P_excreted + + return daily_ed_P_removal*per_capita_per_day_P + \ No newline at end of file