diff --git a/.github/workflows/build-dev.yml b/.github/workflows/build-dev.yml new file mode 100644 index 00000000..15f950fc --- /dev/null +++ b/.github/workflows/build-dev.yml @@ -0,0 +1,37 @@ +# This workflow will install Python dependencies, run tests with a variety of Python versions, and report coverage + +name: build-dev + +on: + push: + branches: [dev] + pull_request: + branches: [dev] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # os: [ubuntu-latest, macos-latest, windows-latest] if want multiple os + python-version: ["3.9", "3.10"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest nbval + pip install --no-cache-dir git+https://github.com/BioSTEAMDevelopmentGroup/thermosteam.git@qsdsan + pip install --no-cache-dir git+https://github.com/BioSTEAMDevelopmentGroup/biosteam.git@qsdsan + pip install --no-cache-dir git+https://github.com/QSD-Group/EXPOsan.git@bsm2 + pip install --no-cache-dir -r requirements.txt + - name: Test with pytest + run: | + pytest \ No newline at end of file diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c19ba3e8..e448f495 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,32 @@ Change Log This document records notable changes to `QSDsan `_. We aim to follow `Semantic Versioning `_. +`1.4.0`_ +-------- +- A lot of the updates have been focused on the dynamic simulation, now the open-loop Benchmark Simulation Model No. 2 (`BSM2 `_) configuration has been implemented with new process models and unit operation including + + - :class:`qsdsan.processes.ADM1p` + - :class:`qsdsan.processes.ADM1_p_extension` + - :class:`qsdsan.processes.ModifiedADM1` + - :class:`qsdsan.processes.mASM2d` + - :class:`qsdsan.sanunits.IdealClarifier` + - :class:`qsdsan.sanunits.PrimaryClarifier` + - :class:`qsdsan.sanunits.PrimaryClarifierBSM2` + - :class:`qsdsan.sanunits.GasExtractionMembrane` + - :class:`qsdsan.sanunits.Thickener` + - :class:`qsdsan.sanunits.Centrifuge` + - :class:`qsdsan.sanunits.Incinerator` + - :class:`qsdsan.sanunits.BatchExperiment` + - :class:`qsdsan.sanunits.PFR` + - :class:`qsdsan.sanunits.BeltThickener` + - :class:`qsdsan.sanunits.SludgeCentrifuge` + - :class:`qsdsan.sanunits.SludgeThickener` + +- New publications + + - Feng et al., *Environmental Science & Technology*, on the sustainability of `hydrothermal liquefaction (HTL) `_ for resource recovery from a range of wet organic wastes. + + `1.3.0`_ -------- - Enhance and use QSDsan's capacity for dynamic simulation for emerging technologies and benchmark configurations (see EXPOsan METAB and PM2 (on the algae branch, still under development) modules). @@ -185,6 +211,7 @@ Official release of ``QSDsan`` v1.0.0! .. _Trimmer et al.: https://doi.org/10.1021/acs.est.0c03296 .. Commit links +.. _1.4.0: https://github.com/QSD-Group/QSDsan/releases/tag/v1.4.0 .. _1.3.0: https://github.com/QSD-Group/QSDsan/releases/tag/v1.3.0 .. _1.2.0: https://github.com/QSD-Group/QSDsan/releases/tag/v1.2.0 .. _1.1.0: https://github.com/QSD-Group/QSDsan/releases/tag/v1.1.0 diff --git a/docs/source/api/processes/ADM1p.rst b/docs/source/api/processes/ADM1p.rst new file mode 100644 index 00000000..3b6cb324 --- /dev/null +++ b/docs/source/api/processes/ADM1p.rst @@ -0,0 +1,7 @@ +Anaerobic Digestion Model No.1 with P extension (ADM1 P/Extension) +================================================================== +.. autoclass:: qsdsan.processes.ADM1_p_extension + :members: + +.. autoclass:: qsdsan.processes.ADM1p +:members: \ No newline at end of file diff --git a/docs/source/api/processes/ASM2d.rst b/docs/source/api/processes/ASM2d.rst index d75c961c..af2d9c64 100644 --- a/docs/source/api/processes/ASM2d.rst +++ b/docs/source/api/processes/ASM2d.rst @@ -1,4 +1,7 @@ Activated Sludge Model No.2d (ASM2d) ==================================== .. autoclass:: qsdsan.processes.ASM2d + :members: + +.. autoclass:: qsdsan.processes.mASM2d :members: \ No newline at end of file diff --git a/docs/source/api/processes/_index.rst b/docs/source/api/processes/_index.rst index f807e3da..f4ed6405 100644 --- a/docs/source/api/processes/_index.rst +++ b/docs/source/api/processes/_index.rst @@ -11,12 +11,18 @@ List of Biological Kinetic Models | ADM1 | `adm`_ | `Batstone`_ et al., 2002 | | | | `Rosen and Jeppsson`_, 2006 | +----------+------------------+-----------------------------+ +| ADM1p | `bsm2`_ | `Alex`_ et al., 2008 | ++----------+------------------+-----------------------------+ +| mADM1 | | | ++----------+------------------+-----------------------------+ | ASM1 | `asm`_ & `bsm1`_ | `Henze`_ et al., 2006 | +----------+------------------+-----------------------------+ | ASM2d | `asm`_ & `bsm1`_ | `Henze`_ et al., 2006 | +----------+------------------+-----------------------------+ -| PM2 | `pm2_ecorecover`_| N/A | -| | & `pm2_batch`_ | | +| mASM2d | `bsm2`_ | `Alex`_ et al., 2008 | ++----------+------------------+-----------------------------+ +| PM2 | `pm2_ecorecover`_| | +| | & `pm2_batch`_ | | +----------+------------------+-----------------------------+ @@ -41,10 +47,12 @@ List of Other Kinetic Modules .. _adm: https://github.com/QSD-Group/EXPOsan/tree/main/exposan/adm .. _asm: https://github.com/QSD-Group/EXPOsan/tree/main/exposan/asm .. _bsm1: https://github.com/QSD-Group/EXPOsan/tree/main/exposan/bsm1 +.. _bsm2: https://github.com/QSD-Group/EXPOsan/tree/main/exposan/bsm2 .. _bwaise: https://github.com/QSD-Group/EXPOsan/tree/main/exposan/bwaise .. _pm2_batch: https://github.com/QSD-Group/EXPOsan/tree/main/exposan/pm2_batch .. _pm2_ecorecover: https://github.com/QSD-Group/EXPOsan/tree/main/exposan/pm2_ecorecover +.. _Alex: http://iwa-mia.org/wp-content/uploads/2022/09/TR3_BSM_TG_Tech_Report_no_3_BSM2_General_Description.pdf .. _Batstone: https://iwaponline.com/ebooks/book/152/Anaerobic-Digestion-Model-No-1-ADM1 .. _EPA design manual: https://nepis.epa.gov/Exe/ZyPURL.cgi?Dockey=3000464S.TXT .. _Henze: https://iwaponline.com/ebooks/book/96/ @@ -59,9 +67,11 @@ Links to docs :maxdepth: 1 ADM1 + ADM1p Aeration ASM1 ASM2d Decay KineticReaction + mADM1 PM2 \ No newline at end of file diff --git a/docs/source/api/processes/mADM1.rst b/docs/source/api/processes/mADM1.rst new file mode 100644 index 00000000..ef36aa9f --- /dev/null +++ b/docs/source/api/processes/mADM1.rst @@ -0,0 +1,4 @@ +Modified Anaerobic Digestion Model No.1 (MADM1) +=============================================== +.. autoclass:: qsdsan.processes.ModifiedADM1 + :members: \ No newline at end of file diff --git a/docs/source/api/sanunits/MembraneGasExtraction.rst b/docs/source/api/sanunits/MembraneGasExtraction.rst new file mode 100644 index 00000000..1b5c4a04 --- /dev/null +++ b/docs/source/api/sanunits/MembraneGasExtraction.rst @@ -0,0 +1,4 @@ +Membrane Gas Extraction +======================= +.. automodule:: qsdsan.sanunits._membrane_gas_extraction + :members: \ No newline at end of file diff --git a/docs/source/api/sanunits/_index.csv b/docs/source/api/sanunits/_index.csv index 1d815ca3..6169cefb 100644 --- a/docs/source/api/sanunits/_index.csv +++ b/docs/source/api/sanunits/_index.csv @@ -38,12 +38,15 @@ Lagoon,No,No,Completed LiquidTreatmentBed,No,No,Completed LumpedCost,No,No,Completed MembraneDistillation,No,No,Completed +MembraneGasExtraction,No,No,Completed MixTank,No,No,Completed Mixer,Yes,Yes,Completed MURT (multi-unit reinvented toilet),No,No,Completed +PFR (plug flow reactor),Yes,No,Under development PhaseChanger,No,Yes,Completed PitLatrine,No,No,Completed PolishingFilter,No,No,Completed +PrimaryClarifier,Yes,No,Completed Pump,Yes,No,Completed Reactor,No,No,Completed ReversedSplitter,No,Yes,Completed diff --git a/docs/source/api/sanunits/_index.rst b/docs/source/api/sanunits/_index.rst index 9723d21c..40fe67db 100644 --- a/docs/source/api/sanunits/_index.rst +++ b/docs/source/api/sanunits/_index.rst @@ -49,6 +49,7 @@ Individual Unit Operations Lagoon membrane_bioreactor MembraneDistillation + MembraneGasExtraction non_reactive PolishingFilter pumping @@ -56,8 +57,9 @@ Individual Unit Operations Screening Sedimentation SepticTank - sludge_thickening SludgePasteurization + sludge_thickening + sludge_treatment suspended_growth_bioreactor tank toilet diff --git a/docs/source/api/sanunits/sludge_treatment.rst b/docs/source/api/sanunits/sludge_treatment.rst new file mode 100644 index 00000000..f7747678 --- /dev/null +++ b/docs/source/api/sanunits/sludge_treatment.rst @@ -0,0 +1,4 @@ +Sludge Treatment +================ +.. automodule:: qsdsan.sanunits._sludge_treatment + :members: \ No newline at end of file diff --git a/qsdsan/_component.py b/qsdsan/_component.py index 98f43b0a..345c413e 100644 --- a/qsdsan/_component.py +++ b/qsdsan/_component.py @@ -561,6 +561,8 @@ def i_NOD(self, i): if i == None: if self.degradability in ('Readily', 'Slowly') or self.formula in ('H3N', 'NH4', 'NH3', 'NH4+', 'H4N+'): i = self.i_N * molecular_weight({'O':4}) / molecular_weight({'N':1}) + elif self.formula == 'N2': + i = self.i_N * molecular_weight({'O':5}) / molecular_weight({'N':2}) elif self.formula in ('NO2-', 'HNO2'): i = self.i_N * molecular_weight({'O':1}) / molecular_weight({'N':1}) else: diff --git a/qsdsan/_components.py b/qsdsan/_components.py index 12322974..61beeb13 100644 --- a/qsdsan/_components.py +++ b/qsdsan/_components.py @@ -154,8 +154,10 @@ def extend(self, components): def compile(self, skip_checks=False): '''Cast as a :class:`CompiledComponents` object.''' components = tuple(self) + tmo._chemicals.prepare(components, skip_checks) setattr(self, '__class__', CompiledComponents) - try: self._compile(components, skip_checks) + + try: self._compile(components) except Exception as error: setattr(self, '__class__', Components) setattr(self, '__dict__', {i.ID: i for i in components}) @@ -614,11 +616,11 @@ def compile(self, skip_checks=False): pass - def _compile(self, components, skip_checks=False): + def _compile(self, components): dct = self.__dict__ tuple_ = tuple # this speeds up the code components = tuple_(dct.values()) - CompiledChemicals._compile(self, components, skip_checks) + CompiledChemicals._compile(self, components) for component in components: missing_properties = component.get_missing_properties(_key_component_properties) if not missing_properties: continue diff --git a/qsdsan/_process.py b/qsdsan/_process.py index 835d650e..5a5e768b 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): @@ -975,13 +976,14 @@ def __repr__(self): _default_data = None @classmethod - def load_from_file(cls, path='', components=None, + def load_from_file(cls, path='', components=None, data=None, conserved_for=('COD', 'N', 'P', 'charge'), parameters=(), use_default_data=False, store_data=False, compile=True, **compile_kwargs): """ Create :class:`CompiledProcesses` object from a table of process IDs, stoichiometric - coefficients, and rate equations stored in a .tsv, .csv, or Excel file. + coefficients, and rate equations stored in a .tsv, .csv, or Excel file, or as + a `DataFrame`. Parameters ---------- @@ -990,6 +992,8 @@ def load_from_file(cls, path='', components=None, components : :class:`CompiledComponents`, optional Components corresponding to the columns in the stoichiometry matrix, to all components set in the system (i.e., through :func:`set_thermo`). + data : :class:`pandas.DataFrame`, optional + Data frame of the Petersen matrix. conserved_for : tuple[str], optional Materials subject to conservation rules, must have corresponding 'i\_' attributes for the components. Applied to all processes. @@ -1022,16 +1026,18 @@ def load_from_file(cls, path='', components=None, """ if use_default_data and cls._default_data is not None: data = cls._default_data + elif path: + data = load_data(path=path, index_col=0, na_values=0) else: - data = load_data(path=path, index_col=None, na_values=0) - + if data is None: return None + cmps = _load_components(components) cmp_IDs = [i for i in data.columns if i in cmps.IDs] data.dropna(how='all', subset=cmp_IDs, inplace=True) new = cls(()) for i, proc in data.iterrows(): - ID = proc[0] + ID = i stoichio = proc[cmp_IDs] if data.columns[-1] in cmp_IDs: rate_eq = None else: @@ -1223,7 +1229,7 @@ def f(): stoichio_arr = self.stoichiometry.to_numpy(dtype=float) except TypeError: isa = isinstance - undefined = [k for k, v in dct_vals if not isa(v, (float, int))] + undefined = [k for k, v in dct_vals.items() if not isa(v, (float, int))] raise TypeError(f'Undefined static parameters: {undefined}') self.__dict__['_stoichio_lambdified'] = lambda : stoichio_arr diff --git a/qsdsan/_sanunit.py b/qsdsan/_sanunit.py index ff08fc21..0cca88b1 100644 --- a/qsdsan/_sanunit.py +++ b/qsdsan/_sanunit.py @@ -215,7 +215,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 ##### @@ -274,7 +274,8 @@ def _convert_stream(self, strm_inputs, streams, init_with, ins_or_outs): elif v == 'ss': converted.append(SanStream.from_stream(stream=s)) else: - converted.append(WasteStream.from_stream(stream=s)) + if isa(s, WasteStream): converted.append(s) + else: converted.append(WasteStream.from_stream(stream=s)) diff = len(converted) + len(missing) - len(streams) if diff != 0: diff --git a/qsdsan/_waste_stream.py b/qsdsan/_waste_stream.py index ceeb6296..ca737b98 100644 --- a/qsdsan/_waste_stream.py +++ b/qsdsan/_waste_stream.py @@ -452,15 +452,16 @@ def _wastestream_info(self, details=True, concentrations=None, N=15): _ws_info += int(bool(self.pH))*f' pH : {self.pH:.1f}\n' _ws_info += int(bool(self.SAlk))*f' Alkalinity : {self.SAlk:.1f} mg/L\n' if details: - _ws_info += int(bool(self.COD)) *f' COD : {self.COD:.1f} mg/L\n' - _ws_info += int(bool(self.BOD)) *f' BOD : {self.BOD:.1f} mg/L\n' - _ws_info += int(bool(self.TC)) *f' TC : {self.TC:.1f} mg/L\n' - _ws_info += int(bool(self.TOC)) *f' TOC : {self.TOC:.1f} mg/L\n' - _ws_info += int(bool(self.TN)) *f' TN : {self.TN:.1f} mg/L\n' + _ws_info += int(bool(self.COD)) *f' COD : {self.COD:.1f} mg/L\n' + _ws_info += int(bool(self.BOD)) *f' BOD : {self.BOD:.1f} mg/L\n' + _ws_info += int(bool(self.TC)) *f' TC : {self.TC:.1f} mg/L\n' + _ws_info += int(bool(self.TOC)) *f' TOC : {self.TOC:.1f} mg/L\n' + _ws_info += int(bool(self.TN)) *f' TN : {self.TN:.1f} mg/L\n' # `TKN` not included as the users need to define that to include in TKN calculation # _ws_info += int(bool(self.TKN)) *f' TKN : {self.TKN:.1f} mg/L\n' - _ws_info += int(bool(self.TP)) *f' TP : {self.TP:.1f} mg/L\n' - _ws_info += int(bool(self.TK)) *f' TK : {self.TK:.1f} mg/L\n' + _ws_info += int(bool(self.TP)) *f' TP : {self.TP:.1f} mg/L\n' + _ws_info += int(bool(self.TK)) *f' TK : {self.TK:.1f} mg/L\n' + _ws_info += int(bool(self.get_TSS())) *f' TSS : {self.get_TSS():.1f} mg/L\n' # _ws_info += int(bool(self.charge))*f' charge : {self.charge:.1f} mmol/L\n' else: _ws_info += ' ...\n' @@ -633,7 +634,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 @@ -729,6 +730,10 @@ def _liq_sol_properties(self, prop, value): def pH(self): '''[float] pH, unitless.''' return self._liq_sol_properties('pH', 7.) + @pH.setter + def pH(self, ph): + if self.phase != 'g': + self._pH = ph @property def SAlk(self): @@ -1073,7 +1078,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 +1224,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): @@ -1271,23 +1280,23 @@ def codstates_inf_model(cls, ID='', flow_tot=0., units = ('L/hr', 'mg/L'), >>> ws = WasteStream.codstates_inf_model('ws_codstates', flow_tot=100) >>> ws.show() WasteStream: ws_codstates - phase: 'l', T: 298.15 K, P: 101325 Pa - flow (g/hr): S_F 8.6 - S_U_Inf 2.15 - C_B_Subst 4 - X_B_Subst 22.7 - X_U_Inf 5.59 - X_Ig_ISS 5.23 - S_NH4 2.5 - S_PO4 0.8 - S_K 2.8 - S_Ca 14 - S_Mg 5 - S_CO3 12 - S_N2 1.8 - S_CAT 0.3 - S_AN 1.2 - ... + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 8.6 + S_U_Inf 2.15 + C_B_Subst 4 + X_B_Subst 22.7 + X_U_Inf 5.59 + X_Ig_ISS 5.23 + S_NH4 2.5 + S_PO4 0.8 + S_K 2.8 + S_Ca 14 + S_Mg 5 + S_CO3 12 + S_N2 1.8 + S_CAT 0.3 + S_AN 1.2 + ... 9.96e+04 WasteStream-specific properties: pH : 7.0 Alkalinity : 10.0 mg/L @@ -1298,6 +1307,7 @@ def codstates_inf_model(cls, ID='', flow_tot=0., units = ('L/hr', 'mg/L'), TN : 40.0 mg/L TP : 10.0 mg/L TK : 28.0 mg/L + TSS : 209.3 mg/L Component concentrations (mg/L): S_F 86.0 S_U_Inf 21.5 @@ -1493,23 +1503,23 @@ def codbased_inf_model(cls, ID='', flow_tot=0., units = ('L/hr', 'mg/L'), >>> ws = WasteStream.codbased_inf_model('ws_codinf', flow_tot=100) >>> ws.show() WasteStream: ws_codinf - phase: 'l', T: 298.15 K, P: 101325 Pa - flow (g/hr): S_F 7.5 - S_U_Inf 3.25 - C_B_Subst 4 - X_B_Subst 22.7 - X_U_Inf 5.58 - X_Ig_ISS 5.23 - S_NH4 2.5 - S_PO4 0.8 - S_K 2.8 - S_Ca 14 - S_Mg 5 - S_CO3 12 - S_N2 1.8 - S_CAT 0.3 - S_AN 1.2 - ... + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 7.5 + S_U_Inf 3.25 + C_B_Subst 4 + X_B_Subst 22.7 + X_U_Inf 5.58 + X_Ig_ISS 5.23 + S_NH4 2.5 + S_PO4 0.8 + S_K 2.8 + S_Ca 14 + S_Mg 5 + S_CO3 12 + S_N2 1.8 + S_CAT 0.3 + S_AN 1.2 + ... 9.96e+04 WasteStream-specific properties: pH : 7.0 Alkalinity : 10.0 mg/L @@ -1520,6 +1530,7 @@ def codbased_inf_model(cls, ID='', flow_tot=0., units = ('L/hr', 'mg/L'), TN : 40.0 mg/L TP : 10.0 mg/L TK : 28.0 mg/L + TSS : 209.3 mg/L Component concentrations (mg/L): S_F 75.0 S_U_Inf 32.5 @@ -1721,23 +1732,23 @@ def bodbased_inf_model(cls, ID='', flow_tot=0., units = ('L/hr', 'mg/L'), >>> ws = WasteStream.bodbased_inf_model('ws_bodinf', flow_tot=100) >>> ws.show() WasteStream: ws_bodinf - phase: 'l', T: 298.15 K, P: 101325 Pa - flow (g/hr): S_F 8.72 - S_U_Inf 3.78 - C_B_Subst 4 - X_B_Subst 22.7 - X_U_Inf 3.94 - X_Ig_ISS 4.93 - S_NH4 2.5 - S_PO4 0.8 - S_K 2.8 - S_Ca 14 - S_Mg 5 - S_CO3 12 - S_N2 1.8 - S_CAT 0.3 - S_AN 1.2 - ... + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 8.72 + S_U_Inf 3.78 + C_B_Subst 4 + X_B_Subst 22.7 + X_U_Inf 3.94 + X_Ig_ISS 4.93 + S_NH4 2.5 + S_PO4 0.8 + S_K 2.8 + S_Ca 14 + S_Mg 5 + S_CO3 12 + S_N2 1.8 + S_CAT 0.3 + S_AN 1.2 + ... 9.96e+04 WasteStream-specific properties: pH : 7.0 Alkalinity : 10.0 mg/L @@ -1748,6 +1759,7 @@ def bodbased_inf_model(cls, ID='', flow_tot=0., units = ('L/hr', 'mg/L'), TN : 40.0 mg/L TP : 10.0 mg/L TK : 28.0 mg/L + TSS : 197.1 mg/L Component concentrations (mg/L): S_F 87.2 S_U_Inf 37.8 @@ -1952,23 +1964,23 @@ def sludge_inf_model(cls, ID='', flow_tot=0., units = ('L/hr', 'mg/L'), >>> ws = WasteStream.sludge_inf_model('ws_sludgeinf', flow_tot=100) >>> ws.show() WasteStream: ws_sludgeinf - phase: 'l', T: 298.15 K, P: 101325 Pa - flow (g/hr): S_F 1.08 - S_U_Inf 8.65 - C_B_Subst 1.08 - X_B_Subst 81.9 - X_OHO 192 - X_AOO 9.62 - X_NOO 9.62 - X_PAO 9.62 - X_U_Inf 468 - X_U_OHO_E 285 - X_U_PAO_E 14.3 - X_Ig_ISS 298 - S_NH4 10 - S_PO4 5 - S_K 2.8 - ... + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 1.08 + S_U_Inf 8.65 + C_B_Subst 1.08 + X_B_Subst 81.9 + X_OHO 192 + X_AOO 9.62 + X_NOO 9.62 + X_PAO 9.62 + X_U_Inf 468 + X_U_OHO_E 285 + X_U_PAO_E 14.3 + X_Ig_ISS 298 + S_NH4 10 + S_PO4 5 + S_K 2.8 + ... 9.88e+04 WasteStream-specific properties: pH : 7.0 Alkalinity : 10.0 mg/L @@ -1979,6 +1991,7 @@ def sludge_inf_model(cls, ID='', flow_tot=0., units = ('L/hr', 'mg/L'), TN : 750.0 mg/L TP : 250.0 mg/L TK : 52.9 mg/L + TSS : 10000.0 mg/L Component concentrations (mg/L): S_F 10.8 S_U_Inf 86.5 diff --git a/qsdsan/data/_masm2d.xlsx b/qsdsan/data/_masm2d.xlsx new file mode 100644 index 00000000..8d642e8f Binary files /dev/null and b/qsdsan/data/_masm2d.xlsx differ 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..5b91bb98 --- /dev/null +++ b/qsdsan/data/process_data/_adm1_p_extension.tsv @@ -0,0 +1,26 @@ + 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 +storage_Sva_in_XPHA -1 ? ? ? 1 -Y_PO4 Y_PO4*K_XPP Y_PO4*Mg_XPP +storage_Sbu_in_XPHA -1 ? ? ? 1 -Y_PO4 Y_PO4*K_XPP Y_PO4*Mg_XPP +storage_Spro_in_XPHA -1 ? ? ? 1 -Y_PO4 Y_PO4*K_XPP Y_PO4*Mg_XPP +storage_Sac_in_XPHA -1 ? ? ? 1 -Y_PO4 Y_PO4*K_XPP Y_PO4*Mg_XPP +lysis_XPAO ? ? ? f_sI_xb f_ch_xb f_pr_xb f_li_xb f_xI_xb -1 +lysis_XPP ? ? ? -1 K_XPP Mg_XPP +lysis_XPHA f_va_PHA f_bu_PHA f_pro_PHA f_ac_PHA ? ? ? -1 diff --git a/qsdsan/data/process_data/_asm1.tsv b/qsdsan/data/process_data/_asm1.tsv index b0d89d52..9297ecff 100644 --- a/qsdsan/data/process_data/_asm1.tsv +++ b/qsdsan/data/process_data/_asm1.tsv @@ -6,4 +6,4 @@ decay_hetero 1-f_P -1 f_P ? b_H*X_BH decay_auto 1-f_P -1 f_P ? b_A*X_BA ammonification 1 -1 ? k_a*S_ND*X_BH hydrolysis 1 -1 k_h*X_S/(K_X*X_BH+X_S)*(S_O/(K_O_H+S_O)+eta_h*K_O_H/(K_O_H+S_O)*S_NO/(K_NO+S_NO))*X_BH -hydrolysis_N 1 -1 k_h*X_S/(K_X*X_BH+X_S)*(S_O/(K_O_H+S_O)+eta_h*K_O_H/(K_O_H+S_O)*S_NO/(K_NO+S_NO))*X_BH*X_ND/X_S +hydrolysis_N 1 -1 k_h*X_ND/(K_X*X_BH+X_S)*(S_O/(K_O_H+S_O)+eta_h*K_O_H/(K_O_H+S_O)*S_NO/(K_NO+S_NO))*X_BH diff --git a/qsdsan/data/process_data/_asm2d.tsv b/qsdsan/data/process_data/_asm2d.tsv index 4a8fdd8b..bc909809 100644 --- a/qsdsan/data/process_data/_asm2d.tsv +++ b/qsdsan/data/process_data/_asm2d.tsv @@ -4,17 +4,19 @@ anox_hydrolysis 1-f_SI f_SI ? ? ? -1 K_h*eta_NO3*K_O2/(K_O2+S_O2)*S_ anae_hydrolysis 1-f_SI f_SI ? ? ? -1 K_h*eta_fe*K_O2/(K_O2+S_O2)*K_NO3/(K_NO3+S_NO3)*(X_S/X_H)/(K_X+X_S/X_H)*X_H hetero_growth_S_F 1-1/Y_H (-1)/Y_H ? ? ? 1 mu_H*S_O2/(K_O2_H+S_O2)*S_F/(K_F+S_F)*S_F/(S_F+S_A)*S_NH4/(K_NH4_H+S_NH4)*S_PO4/(K_P_H+S_PO4)*S_ALK/(K_ALK_H+S_ALK)*X_H hetero_growth_S_A 1-1/Y_H (-1)/Y_H ? ? ? 1 mu_H*S_O2/(K_O2_H+S_O2)*S_A/(K_A_H+S_A)*S_A/(S_F+S_A)*S_NH4/(K_NH4_H+S_NH4)*S_PO4/(K_P_H+S_PO4)*S_ALK/(K_ALK_H+S_ALK)*X_H -denitri_S_F (-1)/Y_H ? (1-Y_H)/(20/7*Y_H) (Y_H-1)/(20/7*Y_H) ? ? 1 mu_H*eta_NO3_H*K_O2_H/(K_O2_H+S_O2)*S_NO3/(K_NO3_H+S_NO3)*S_F/(K_F+S_F)*S_F/(S_F+S_A)*S_NH4/(K_NH4_H+S_NH4)*S_PO4/(K_P_H+S_PO4)*S_ALK/(K_ALK_H+S_ALK)*X_H -denitri_S_A (-1)/Y_H ? (1-Y_H)/(20/7*Y_H) (Y_H-1)/(20/7*Y_H) ? ? 1 mu_H*eta_NO3_H*K_O2_H/(K_O2_H+S_O2)*S_NO3/(K_NO3_H+S_NO3)*S_A/(K_A_H+S_A)*S_A/(S_F+S_A)*S_NH4/(K_NH4_H+S_NH4)*S_PO4/(K_P_H+S_PO4)*S_ALK/(K_ALK_H+S_ALK)*X_H +denitri_S_F (-1)/Y_H ? (1-Y_H)/(COD_deN*Y_H) (Y_H-1)/(COD_deN*Y_H) ? ? 1 mu_H*eta_NO3_H*K_O2_H/(K_O2_H+S_O2)*S_NO3/(K_NO3_H+S_NO3)*S_F/(K_F+S_F)*S_F/(S_F+S_A)*S_NH4/(K_NH4_H+S_NH4)*S_PO4/(K_P_H+S_PO4)*S_ALK/(K_ALK_H+S_ALK)*X_H +denitri_S_A (-1)/Y_H ? (1-Y_H)/(COD_deN*Y_H) (Y_H-1)/(COD_deN*Y_H) ? ? 1 mu_H*eta_NO3_H*K_O2_H/(K_O2_H+S_O2)*S_NO3/(K_NO3_H+S_NO3)*S_A/(K_A_H+S_A)*S_A/(S_F+S_A)*S_NH4/(K_NH4_H+S_NH4)*S_PO4/(K_P_H+S_PO4)*S_ALK/(K_ALK_H+S_ALK)*X_H ferment -1 1 ? ? ? q_fe*K_O2_H/(K_O2_H+S_O2)*K_NO3_H/(K_NO3_H+S_NO3)*S_F/(K_fe+S_F)*S_ALK/(K_ALK_H+S_ALK)*X_H hetero_lysis ? ? ? f_XI_H 1-f_XI_H -1 b_H*X_H PAO_storage_PHA -1 ? Y_PO4 ? (-Y_PO4) 1 q_PHA*S_A/(K_A_PAO+S_A)*S_ALK/(K_ALK_PAO+S_ALK)*(X_PP/X_PAO)/(K_PP+X_PP/X_PAO)*X_PAO aero_storage_PP (-Y_PHA) ? -1 ? 1 (-Y_PHA) q_PP*S_O2/(K_O2_PAO+S_O2)*S_PO4/(K_PS+S_PO4)*S_ALK/(K_ALK_PAO+S_ALK)*(X_PHA/X_PAO)/(K_PHA+X_PHA/X_PAO)*(K_MAX-X_PP/X_PAO)/(K_IPP+K_MAX-X_PP/X_PAO)*X_PAO +anox_storage_PP ? Y_PHA/COD_deN (-Y_PHA)/COD_deN -1 ? 1 (-Y_PHA) q_PP*S_O2/(K_O2_PAO+S_O2)*S_PO4/(K_PS+S_PO4)*S_ALK/(K_ALK_PAO+S_ALK)*(X_PHA/X_PAO)/(K_PHA+X_PHA/X_PAO)*(K_MAX-X_PP/X_PAO)/(K_IPP+K_MAX-X_PP/X_PAO)*X_PAO*eta_NO3_PAO*K_O2_PAO/S_O2*S_NO3/(K_NO3_PAO+S_NO3) PAO_aero_growth_PHA ? ? ? ? 1 (-1)/Y_PAO mu_PAO*S_O2/(K_O2_PAO+S_O2)*S_NH4/(K_NH4_PAO+S_NH4)*S_PO4/(K_P_PAO+S_PO4)*S_ALK/(K_ALK_PAO+S_ALK)*(X_PHA/X_PAO)/(K_PHA+X_PHA/X_PAO)*X_PAO +PAO_anox_growth ? (1-Y_PAO)/(COD_deN*Y_PAO) (Y_PAO-1)/(COD_deN*Y_PAO) ? ? 1 (-1)/Y_PAO mu_PAO*S_O2/(K_O2_PAO+S_O2)*S_NH4/(K_NH4_PAO+S_NH4)*S_PO4/(K_P_PAO+S_PO4)*S_ALK/(K_ALK_PAO+S_ALK)*(X_PHA/X_PAO)/(K_PHA+X_PHA/X_PAO)*X_PAO* eta_NO3_PAO*K_O2_PAO/S_O2*S_NO3/(K_NO3_PAO + S_NO3) PAO_lysis ? ? ? f_XI_PAO 1-f_XI_PAO -1 b_PAO*X_PAO*S_ALK/(K_ALK_PAO+S_ALK) PP_lysis ? 1 ? -1 b_PP*X_PP*S_ALK/(K_ALK_PAO+S_ALK) PHA_lysis 1 ? ? -1 b_PHA*X_PHA*S_ALK/(K_ALK_PAO+S_ALK) -auto_aero_growth (Y_A-32/7)/Y_A ? 1/Y_A ? ? 1 mu_AUT*S_O2/(K_O2_AUT+S_O2)*S_NH4/(K_NH4_AUT+S_NH4)*S_PO4/(K_P_AUT+S_PO4)*S_ALK/(K_ALK_AUT+S_ALK)*X_AUT +auto_aero_growth ? ? 1/Y_A ? ? 1 mu_AUT*S_O2/(K_O2_AUT+S_O2)*S_NH4/(K_NH4_AUT+S_NH4)*S_PO4/(K_P_AUT+S_PO4)*S_ALK/(K_ALK_AUT+S_ALK)*X_AUT auto_lysis ? ? ? f_XI_AUT 1-f_XI_AUT -1 b_AUT*X_AUT precipitation -1 ? -3.45 ? k_PRE*S_PO4*X_MeOH redissolution 1 ? 3.45 ? k_RED*X_MeP*S_ALK/(K_ALK_PRE+S_ALK) diff --git a/qsdsan/data/process_data/_masm2d.tsv b/qsdsan/data/process_data/_masm2d.tsv new file mode 100644 index 00000000..2fa8baee --- /dev/null +++ b/qsdsan/data/process_data/_masm2d.tsv @@ -0,0 +1,20 @@ + S_O2 S_F S_A S_I S_NH4 S_N2 S_NO3 S_PO4 S_IC S_K S_Mg X_I X_S X_H X_PAO X_PP X_PHA X_AUT +aero_hydrolysis 1-f_SI f_SI ? ? ? -1 +anox_hydrolysis 1-f_SI f_SI ? ? ? -1 +anae_hydrolysis 1-f_SI f_SI ? ? ? -1 +hetero_growth_S_F 1-1/Y_H (-1)/Y_H ? ? ? 1 +hetero_growth_S_A 1-1/Y_H (-1)/Y_H ? ? ? 1 +denitri_S_F (-1)/Y_H ? (1-Y_H)/(COD_deN*Y_H) (Y_H-1)/(COD_deN*Y_H) ? ? 1 +denitri_S_A (-1)/Y_H ? (1-Y_H)/(COD_deN*Y_H) (Y_H-1)/(COD_deN*Y_H) ? ? 1 +ferment -1 1 ? ? ? +hetero_lysis ? ? ? f_XI_H 1-f_XI_H -1 +storage_PHA -1 ? Y_PO4 ? Y_PO4*K_XPP Y_PO4*Mg_XPP (-Y_PO4) 1 +aero_storage_PP (-Y_PHA) -1 ? (-K_XPP) (-Mg_XPP) 1 (-Y_PHA) +anox_storage_PP Y_PHA/COD_deN (-Y_PHA)/COD_deN -1 ? (-K_XPP) (-Mg_XPP) 1 (-Y_PHA) +PAO_aero_growth_PHA ? ? ? ? 1 (-1)/Y_PAO +PAO_anox_growth ? (1-Y_PAO)/(COD_deN*Y_PAO) (Y_PAO-1)/(COD_deN*Y_PAO) ? ? 1 (-1)/Y_PAO +PAO_lysis ? ? ? f_XI_PAO 1-f_XI_PAO -1 +PP_lysis ? 1 ? K_XPP Mg_XPP -1 +PHA_lysis 1 ? ? -1 +auto_aero_growth ? ? 1/Y_A ? ? 1 +auto_lysis ? ? ? f_XI_AUT 1-f_XI_AUT -1 diff --git a/qsdsan/data/process_data/_mmp.tsv b/qsdsan/data/process_data/_mmp.tsv new file mode 100644 index 00000000..1714dc96 --- /dev/null +++ b/qsdsan/data/process_data/_mmp.tsv @@ -0,0 +1,8 @@ + S_Mg S_Ca X_AlOH X_FeOH S_IC S_NH4 S_PO4 X_CaCO3 X_struv X_newb X_ACP X_MgCO3 X_AlPO4 X_FePO4 +CaCO3_precipitation_dissolution -1 -1 1 +struvite_precipitation_dissolution -1 -1 -1 1 +newberyite_precipitation_dissolution -1 -1 1 +ACP_precipitation_dissolution -3 -2 1 +MgCO3_precipitation_dissolution -1 -1 1 +AlPO4_precipitation_dissolution -1 -1 1 +FePO4_precipitation_dissolution -1 -1 1 diff --git a/qsdsan/processes/__init__.py b/qsdsan/processes/__init__.py index 9e0c5e9d..5aacbc5a 100644 --- a/qsdsan/processes/__init__.py +++ b/qsdsan/processes/__init__.py @@ -7,16 +7,62 @@ This module is developed by: Yalin Li + + 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 numpy import arange, cumprod, exp + +def ion_speciation(h_ion, *Kas): + n = len(Kas) + out = h_ion ** arange(n, -1, -1) * cumprod([1.0, *Kas]) + return out/sum(out) + +substr_inhibit = Monod = lambda S, K: S/(S+K) + +non_compet_inhibit = lambda S, K: K/(K+S) + +grad_non_compet_inhibit = lambda S, K: -K/(K+S)**2 + +grad_substr_inhibit = lambda S, K: K/(K+S)**2 + +def mass2mol_conversion(cmps): + '''conversion factor from kg[measured_as]/m3 to mol[component]/L''' + return cmps.i_mass / cmps.chem_MW + +R = 8.3145e-2 # Universal gas constant, [bar/M/K] + +def T_correction_factor(T1, T2, delta_H): + """ + Returns temperature correction factor for equilibrium constants based on + the Van't Holf equation. + + Parameters + ---------- + T1 : float + Base temperature, in K. + T2 : float + Actual temperature, in K. + delta_H : float + Heat of reaction, in J/mol. + """ + if T1 == T2: return 1 + return exp(delta_H/(R*100) * (1/T1 - 1/T2)) # R converted to SI + +class TempState: + def __init__(self): + self.data = {} + +#%% from ._aeration import * 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,9 +73,11 @@ _asm1, _asm2d, _adm1, + _adm1_p_extension, _madm1, _decay, _kinetic_reaction, + _pm2 ) __all__ = ( @@ -37,8 +85,9 @@ *_asm1.__all__, *_asm2d.__all__, *_adm1.__all__, + *_adm1_p_extension.__all__, *_madm1.__all__, *_decay.__all__, *_kinetic_reaction.__all__, *_pm2.__all__, - ) \ No newline at end of file + ) diff --git a/qsdsan/processes/_adm1.py b/qsdsan/processes/_adm1.py index 779bba30..c6c88505 100644 --- a/qsdsan/processes/_adm1.py +++ b/qsdsan/processes/_adm1.py @@ -15,18 +15,23 @@ from thermosteam.utils import chemicals_user from thermosteam import settings -from chemicals.elements import molecular_weight as get_mw +# 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 +from . import ( + non_compet_inhibit, grad_non_compet_inhibit, + substr_inhibit, grad_substr_inhibit, + mass2mol_conversion, + T_correction_factor, R, + TempState + ) __all__ = ('create_adm1_cmps', 'ADM1', - 'non_compet_inhibit', 'substr_inhibit', - 'T_correction_factor', 'pH_inhibit', 'Hill_inhibit', - 'rhos_adm1') + 'rhos_adm1', ) _path = ospath.join(data_path, 'process_data/_adm1.tsv') _load_components = settings.get_default_chemicals @@ -36,8 +41,10 @@ # ADM1-specific components # ============================================================================= -C_mw = get_mw({'C':1}) -N_mw = get_mw({'N':1}) +# C_mw = get_mw({'C':1}) +# N_mw = get_mw({'N':1}) +C_mw = 12 +N_mw = 14 def create_adm1_cmps(set_thermo=True): cmps_all = Components.load_default() @@ -174,31 +181,6 @@ def create_adm1_cmps(set_thermo=True): # 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): - """compute temperature correction factor for equilibrium constants based on - the Van't Holf equation.""" - if T1 == T2: return 1 - 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_an, S_IN, S_IC, S_ac, S_pro, S_bu, S_va = weak_acids_tot # in M @@ -218,8 +200,6 @@ def fprime_abr(h_ion, weak_acids_tot, Kas): 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: @@ -233,11 +213,19 @@ def Hill_inhibit(H_ion, ul, ll): rhos = np.zeros(22) # 22 kinetic processes Cs = np.empty(19) -def rhos_adm1(state_arr, params): +def solve_pH(state_arr, Ka, unit_conversion): + cmps_in_M = state_arr[:27] * unit_conversion + weak_acids = cmps_in_M[[24, 25, 10, 9, 6, 5, 4, 3]] + h = brenth(acid_base_rxn, 1e-14, 1.0, + args=(weak_acids, Ka), + xtol=1e-12, maxiter=100) + return h +rhos_adm1 = lambda state_arr, params: _rhos_adm1(state_arr, params, h=None) + +def _rhos_adm1(state_arr, params, h=None): 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'] @@ -250,26 +238,20 @@ def rhos_adm1(state_arr, params): kLa = params['kLa'] T_base = params['T_base'] root = params['root'] + if 'unit_conv' in params: + unit_conversion = params['unit_conv'] + else: + unit_conversion = params['unit_conv'] = mass2mol_conversion(cmps) - # Cs_ids = cmps.indices(['X_c', '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']) - # Cs = state_arr[Cs_ids] Cs[:8] = state_arr[12:20] Cs[8:12] = state_arr[19:23] Cs[12:] = state_arr[16:23] - # 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 = state_arr[[3,4,7,10]] - unit_conversion = mass2mol_conversion(cmps) - cmps_in_M = state_arr[:27] * unit_conversion - weak_acids = cmps_in_M[[24, 25, 10, 9, 6, 5, 4, 3]] T_op = state_arr[-1] + # Ka, KH = T_corrected_params(T_op, params) if T_op == T_base: Ka = Kab KH = KHb / unit_conversion[7:10] @@ -286,8 +268,6 @@ def rhos_adm1(state_arr, params): biogas_S = state_arr[7:10].copy() biogas_p = R * T_op * state_arr[27:30] - # 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] rhos[:-3] = ks * Cs Monod = substr_inhibit(substrates, Ks) @@ -295,14 +275,10 @@ def rhos_adm1(state_arr, params): if S_va > 0: rhos[7] *= 1/(1+S_bu/S_va) if S_bu > 0: rhos[8] *= 1/(1+S_va/S_bu) - h = brenth(acid_base_rxn, 1e-14, 1.0, - args=(weak_acids, Ka), - xtol=1e-12, maxiter=100) - # h = 10**(-7.46) - - nh3 = Ka[1] * weak_acids[2] / (Ka[1] + h) - co2 = weak_acids[3] - Ka[2] * weak_acids[3] / (Ka[2] + h) - biogas_S[-1] = co2 / unit_conversion[9] + if h is None: h = solve_pH(state_arr, Ka, unit_conversion) + nh3 = S_IN * unit_conversion[10] * Ka[1] / (Ka[1] + h) + co2 = state_arr[9] * h / (Ka[2] + h) + biogas_S[-1] = co2 Iph = Hill_inhibit(h, pH_ULs, pH_LLs) Iin = substr_inhibit(S_IN, KS_IN) @@ -310,8 +286,6 @@ def rhos_adm1(state_arr, params): Inh3 = non_compet_inhibit(nh3, KI_nh3) rhos[4:12] *= Iph * Iin rhos[6:10] *= 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[10] *= Inh3 root.data = { 'pH':-np.log10(h), @@ -323,19 +297,51 @@ def rhos_adm1(state_arr, params): 'rhos':rhos[4:12].copy() } rhos[-3:] = kLa * (biogas_S - KH * biogas_p) - # print(rhos) return rhos +def dydt_Sh2_AD(S_h2, state_arr, h, params, f_stoichio, V_liq, S_h2_in): + state_arr[7] = S_h2 + Q = state_arr[30] + rxn = _rhos_adm1(state_arr, params, h=h) + stoichio = f_stoichio(state_arr) # should return the stoichiometric coefficients of S_h2 for all processes + return Q/V_liq*(S_h2_in - S_h2) + np.dot(rxn, stoichio) + +grad_rhos = np.zeros(5) +X_bio = np.zeros(5) +def grad_dydt_Sh2_AD(S_h2, state_arr, h, params, f_stoichio, V_liq, S_h2_in): + state_arr[7] = S_h2 + ks = params['rate_constants'][[6,7,8,9,11]] + Ks = params['half_sat_coeffs'][2:6] + K_h2 = params['half_sat_coeffs'][7] + pH_ULs = params['pH_ULs'] + pH_LLs = params['pH_LLs'] + KS_IN = params['KS_IN'] + KIs_h2 = params['KIs_h2'] + kLa = params['kLa'] + + X_bio[:] = state_arr[[18,19,19,20,22]] + substrates = state_arr[2:6] + S_va, S_bu, S_IN = state_arr[[3,4,10]] + Iph = Hill_inhibit(h, pH_ULs, pH_LLs)[[2,3,4,5,7]] + Iin = substr_inhibit(S_IN, KS_IN) + grad_Ih2 = grad_non_compet_inhibit(S_h2, KIs_h2) + + grad_rhos[:] = ks * X_bio * Iph * Iin + grad_rhos[:-1] *= substr_inhibit(substrates, Ks) * grad_Ih2 + if S_va > 0: grad_rhos[1] *= 1/(1+S_bu/S_va) + if S_bu > 0: grad_rhos[2] *= 1/(1+S_va/S_bu) + + grad_rhos[-1] *= grad_substr_inhibit(S_h2, K_h2) + stoichio = f_stoichio(state_arr) + + Q = state_arr[30] + return -Q/V_liq + np.dot(grad_rhos, stoichio[[6,7,8,9,11]]) + kLa*stoichio[-3] + + #%% # ============================================================================= # ADM1 class # ============================================================================= -class TempState: - def __init__(self): - self.data = {} - - # def append(self, value): - # self.data += [value] @chemicals_user class ADM1(CompiledProcesses): @@ -623,6 +629,10 @@ def __new__(cls, components=None, path=None, N_xc=2.686e-3, N_I=4.286e-3, N_aa=7 K_H_base, K_H_dH, kLa, T_base, self._components, root])) + dct['flex_rhos'] = _rhos_adm1 + dct['solve_pH'] = solve_pH + dct['dydt_Sh2_AD'] = dydt_Sh2_AD + dct['grad_dydt_Sh2_AD'] = grad_dydt_Sh2_AD return self def set_pKas(self, pKas): diff --git a/qsdsan/processes/_adm1_p_extension.py b/qsdsan/processes/_adm1_p_extension.py new file mode 100644 index 00000000..5c2b2aae --- /dev/null +++ b/qsdsan/processes/_adm1_p_extension.py @@ -0,0 +1,1111 @@ +# -*- 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 Components, Process, Processes +from qsdsan.processes import ( + create_adm1_cmps, + create_asm2d_cmps, + create_masm2d_cmps, + T_correction_factor, R, + ion_speciation, + non_compet_inhibit, + grad_non_compet_inhibit, + Monod, + substr_inhibit, + grad_substr_inhibit, + mass2mol_conversion, + Hill_inhibit, + ADM1, + TempState + ) +from qsdsan.utils import ospath, data_path, load_data +from scipy.optimize import brenth +import numpy as np + +__all__ = ('create_adm1_p_extension_cmps', + 'ADM1_p_extension', + 'rhos_adm1_p_extension', + 'create_adm1p_cmps', + 'ADM1p', + 'rhos_adm1p') + +_path = ospath.join(data_path, 'process_data/_adm1_p_extension.tsv') +_mmp = ospath.join(data_path, 'process_data/_mmp.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): + c1 = create_adm1_cmps(False) + c2d = create_masm2d_cmps(False) + _c2 = create_asm2d_cmps(False) + + S_IP = c2d.S_PO4.copy('S_IP') + + c = [*c1] + Ss = c[:11] + Xs = c[13:-3] # X_c is excluded + others = c[-3:] + + cmps_adm1p = Components([*Ss, S_IP, c1.S_I, *Xs, + c2d.X_PHA, c2d.X_PP, c2d.X_PAO, + c2d.S_K, c2d.S_Mg, _c2.X_MeOH, _c2.X_MeP, + *others]) + cmps_adm1p.default_compile() + if set_thermo: settings.set_thermo(cmps_adm1p) + return cmps_adm1p + + +#%% +# ============================================================================= +# kinetic rate functions +# ============================================================================= + +def acid_base_rxn(h_ion, weak_acids_tot, Kas): + # 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, hpo4, 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 - 2*hpo4 - (S_IP - hpo4) + +# 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, dhpo4, dhco3, dac, dpro, dbu, dva = - Kas[1:] * weak_acids_tot[4:] / (Kas[1:] + h_ion)**2 + return 1 + (-dnh3) - doh_ion - dhco3 - dac - dpro - dbu - dva - dhpo4 + + +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 solve_pH(state_arr, Ka, unit_conversion): + cmps_in_M = state_arr[:34] * unit_conversion + # S_cat, S_K, S_Mg, S_an, S_IN, S_IP, S_IC, S_ac, S_pro, S_bu, S_va + # Kw, Ka_nh, Ka_h2po4, Ka_co2, Ka_ac, Ka_pr, Ka_bu, Ka_va = Kas + weak_acids = cmps_in_M[[31, 27, 28, 32, 10, 11, 9, 6, 5, 4, 3]] + h = brenth(acid_base_rxn, 1e-14, 1.0, + args=(weak_acids, Ka), + xtol=1e-12, maxiter=100) + return h + +rhos_adm1_p_extension = lambda state_arr, params: _rhos_adm1_p_extension(state_arr, params, h=None) + +def _rhos_adm1_p_extension(state_arr, params, h=None): + ks = params['rate_constants'] + Ks = params['half_sat_coeffs'] + + cmps = params['components'] + 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'] + + if 'unit_conv' not in params: params['unit_conv'] = mass2mol_conversion(cmps) + unit_conversion = params['unit_conv'] + + # state_arr_cmps stated just for readability of code + # {0: 'S_su', + # 1: 'S_aa', + # 2: 'S_fa', + # 3: 'S_va', + # 4: 'S_bu', + # 5: 'S_pro', + # 6: 'S_ac', + # 7: 'S_h2', + # 8: 'S_ch4', + # 9: 'S_IC', + # 10: 'S_IN', + # 11: 'S_IP', + # 12: 'S_I', + # 13: 'X_ch', + # 14: 'X_pr', + # 15: 'X_li', + # 16: 'X_su', + # 17: 'X_aa', + # 18: 'X_fa', + # 19: 'X_c4', + # 20: 'X_pro', + # 21: 'X_ac', + # 22: 'X_h2', + # 23: 'X_I', + # 24: 'X_PHA', + # 25: 'X_PP', + # 26: 'X_PAO', + # 27: 'S_K', + # 28: 'S_Mg', + # 29: 'X_MeOH', + # 30: 'X_MeP', + # 31: 'S_cat', + # 32: 'S_an', + # 33: 'H2O'} + + Cs[:7] = state_arr[13:20] + Cs[7:11] = state_arr[19:23] + Cs[11:18] = state_arr[16:23] + Cs[18:23] = X_PAO = state_arr[26] + Cs[23] = X_PP = state_arr[25] + Cs[24] = state_arr[24] + + substrates = state_arr[:8] + + S_va, S_bu, S_h2, S_IN, S_IP = state_arr[[3,4,7,10,11]] + + T_op = state_arr[-1] + if T_op == T_base: + Ka = Kab + KH = KHb / unit_conversion[7:10] + else: + T_temp = params.pop('T_op', None) + if T_op != T_temp: + params['Ka'] = Kab * T_correction_factor(T_base, T_op, Ka_dH) + params['KH'] = KHb * T_correction_factor(T_base, T_op, KH_dH) / unit_conversion[7:10] + params['T_op'] = T_op + Ka = params['Ka'] + KH = params['KH'] + + rhos[:-3] = ks * Cs + rhos[3:11] *= substr_inhibit(substrates, Ks[: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) + + vfas = state_arr[3:7] + + if X_PAO > 0: + K_A, K_PP = Ks[-2:] + rhos[18:22] *= substr_inhibit(vfas, K_A) * substr_inhibit(X_PP/X_PAO, K_PP) + + if sum(vfas) > 0: + rhos[18:22] *= vfas/sum(vfas) + + biogas_S = state_arr[7:10].copy() + biogas_p = R * T_op * state_arr[34:37] + + if h is None: h = solve_pH(state_arr, Ka, unit_conversion) + nh3 = S_IN * unit_conversion[10] * Ka[1] / (Ka[1] + h) + co2 = state_arr[9] * h / (Ka[3] + h) + biogas_S[-1] = co2 + + Iph = Hill_inhibit(h, pH_ULs, pH_LLs) + Iin = substr_inhibit(S_IN, KS_IN) + Iip = substr_inhibit(S_IP, KS_IP) + Ih2 = non_compet_inhibit(S_h2, KIs_h2) + Inh3 = non_compet_inhibit(nh3, KI_nh3) + root.data = { + 'pH':-np.log10(h), + 'Iph':Iph, + 'Ih2':Ih2, + 'Iin':Iin, + 'Inh3':Inh3, + } + rhos[3:11] *= Iph * Iin * Iip + rhos[5:9] *= Ih2 + rhos[9] *= Inh3 + rhos[-3:] = kLa * (biogas_S - KH * biogas_p) + + # print(rhos) + return rhos + +def dydt_Sh2_AD(S_h2, state_arr, h, params, f_stoichio, V_liq, S_h2_in): + state_arr[7] = S_h2 + Q = state_arr[37] + rxn = _rhos_adm1_p_extension(state_arr, params, h=h) + stoichio = f_stoichio(state_arr) # should return the stoichiometric coefficients of S_h2 for all processes + return Q/V_liq*(S_h2_in - S_h2) + np.dot(rxn, stoichio) + +grad_rhos = np.zeros(5) +X_bio = np.zeros(5) +def grad_dydt_Sh2_AD(S_h2, state_arr, h, params, f_stoichio, V_liq, S_h2_in): + state_arr[7] = S_h2 + ks = params['rate_constants'][[5,6,7,8,10]] + Ks = params['half_sat_coeffs'][2:6] + K_h2 = params['half_sat_coeffs'][7] + pH_ULs = params['pH_ULs'] + pH_LLs = params['pH_LLs'] + KS_IN = params['KS_IN'] + KIs_h2 = params['KIs_h2'] + kLa = params['kLa'] + + X_bio[:] = state_arr[[18,19,19,20,22]] + substrates = state_arr[2:6] + S_va, S_bu, S_IN = state_arr[[3,4,10]] + Iph = Hill_inhibit(h, pH_ULs, pH_LLs)[[2,3,4,5,7]] + Iin = substr_inhibit(S_IN, KS_IN) + grad_Ih2 = grad_non_compet_inhibit(S_h2, KIs_h2) + + grad_rhos[:] = ks * X_bio * Iph * Iin + grad_rhos[:-1] *= substr_inhibit(substrates, Ks) * grad_Ih2 + if S_va > 0: grad_rhos[1] *= 1/(1+S_bu/S_va) + if S_bu > 0: grad_rhos[2] *= 1/(1+S_va/S_bu) + + grad_rhos[-1] *= grad_substr_inhibit(S_h2, K_h2) + stoichio = f_stoichio(state_arr) + + Q = state_arr[37] + return -Q/V_liq + np.dot(grad_rhos, stoichio[[5,6,7,8,10]]) + kLa*stoichio[-3] + +#%% +# ============================================================================= +# ADM1_p_extension class +# ============================================================================= + +@chemicals_user +class ADM1_p_extension(ADM1): + """ + Anaerobic Digestion Model No.1 with P extension. [1]_, [2]_, [3]_. + Compatible with the original `ASM2d`. + + 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. + 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_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_PO4 : float, optional + Yield of biomass on phosphate [kmol P/kg COD]. The default is 0.013. + K_A : float, optional + VFAs half saturation coefficient for PHA storage [kg COD/m3]. The default is 0.004. + K_PP : float, optional + Half saturation coefficient for polyphosphate [kmol PP/kg PAO COD]. + The default is 0.00032. + 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. + KS_IP : float, optional + P limitation for inorganic phosphorous [kmol P/m3]. + The default is 2e-5. + pKa_base : iterable[float], optional + pKa (equilibrium coefficient) values of acid-base pairs at the base + temperature, unitless, following the order of `ADM1_p_extension._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_p_extension._acid_base_pairs`. The default is + [55900, 51965, 3600, 7646, 0, 0, 0, 0]. + + See Also + -------- + :class:`qsdsan.processes.ADM1` + + 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 = (*ADM1._stoichio_params[5:], + 'f_sI_xb', 'f_ch_xb', 'f_pr_xb', 'f_li_xb', 'f_xI_xb', + 'f_ac_PHA', 'f_bu_PHA', 'f_pro_PHA', 'f_va_PHA', + 'Y_PO4', 'K_XPP', 'Mg_XPP') + + _kinetic_params = (*ADM1._kinetic_params, 'KS_IP', ) + + _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, + 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) + + if not path: path = _path + self = Processes.load_from_file(path, + components=cmps, + conserved_for=('C', 'N', 'P'), + parameters=cls._stoichio_params, + compile=False) + + 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_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, + Y_su, Y_aa, Y_fa, Y_c4, Y_pro, Y_ac, Y_h2, + # new parameters + 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_ac_PHA, f_bu_PHA, f_pro_PHA, 1-f_ac_PHA-f_bu_PHA-f_pro_PHA, + Y_PO4*P_mw, 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, + #!!! new + 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() + 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, + KI_nh3, KIs_h2, Ka_base, Ka_dH, + K_H_base, K_H_dH, kLa, + T_base, self._components, root, + #!!! new parameter + KS_IP*P_mw])) + dct['flex_rhos'] = _rhos_adm1_p_extension + dct['solve_pH'] = solve_pH + dct['dydt_Sh2_AD'] = dydt_Sh2_AD + dct['grad_dydt_Sh2_AD'] = grad_dydt_Sh2_AD + return self + + 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) + if i < 11: + self.rate_function._params['half_sat_coeffs'][i-3] = K + else: + ValueError('To set "K_A", specify process = -2; to set "K_PP", specify process = -1,' + f'not {process}') + + 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) - 3 + dct = self.rate_function._params + if lower is None: lower = dct['pH_LLs'][i] + else: dct['pH_LLs'][i] = lower + if upper is None: upper = dct['pH_ULs'][i] + else: dct['pH_ULs'][i] = 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-5] = KI + + + 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 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") + +#%% +# ============================================================================= +# ADM1p components, compatible with `mASM2d` +# ============================================================================= + + +def create_adm1p_cmps(set_thermo=True): + c1 = create_adm1_cmps(False) + c2d = create_masm2d_cmps(False) + + S_IP = c2d.S_PO4.copy('S_IP') + + # c1.S_su.i_mass = c1.X_ch.i_mass = 0.9375 + # c1.S_su.f_Vmass_Totmass = c1.X_ch.f_Vmass_Totmass = 0.68 + # c1.X_li.i_mass = 0.6375 + # c1.X_li.f_Vmass_Totmass = 1. + + c1.S_aa.i_C = c1.X_pr.i_C = 0.36890 + c1.S_aa.i_N = c1.X_pr.i_N = 0.11065 + c1.S_aa.i_P = c1.X_pr.i_P = 0. + c1.S_aa.i_mass = c1.X_pr.i_mass = 0.737648 + c1.S_aa.f_Vmass_Totmass = c1.X_pr.f_Vmass_Totmass = 0.864 + + c1.S_fa._formula = None + c1.S_fa.chem_MW = 1. + c1.S_fa.i_C = 0.25685 + # c1.S_fa.i_mass = 1/2.9200 + + c1.S_I.i_C = c1.X_I.i_C = 0.36178 + c1.S_I.i_N = c1.X_I.i_N = 0.06003 + c1.S_I.i_P = c1.X_I.i_P = 6.49e-3 + c1.S_I.i_mass = c1.X_I.i_mass = 0.75 + c1.S_I.f_Vmass_Totmass = c1.X_I.f_Vmass_Totmass = 0.85 + + c1.X_li._formula = None + c1.X_li.chem_MW = 1. + c1.X_li.i_C = 0.263112 + c1.X_li.i_N = 0. + c1.X_li.i_P = 0.010664 + for cmp in (c1.S_aa, c1.X_pr, c1.S_fa, c1.S_I, c1.X_I): + cmp.i_NOD = None + + for cmp in (c1.X_su, c1.X_aa, c1.X_fa, c1.X_c4, c1.X_pro, c1.X_ac, c1.X_h2,): + cmp.i_C = 0.36612 + cmp.i_N = 0.08615 + cmp.i_P = 0.02154 + cmp.i_K = cmp.i_Mg = cmp.i_Ca = 0. + cmp.i_mass = 0.90 + cmp.f_Vmass_Totmass = 0.85 + cmp.i_NOD = None + + c1.refresh_constants() + c = [*c1] + Ss = c[:11] + Xs = c[13:-3] # X_c is excluded + + cmps_adm1p = Components([*Ss, S_IP, c1.S_I, *Xs, + c2d.X_PHA, c2d.X_PP, c2d.X_PAO, + c2d.S_K, c2d.S_Mg, c2d.S_Ca, c2d.X_CaCO3, + c2d.X_struv, c2d.X_newb, c2d.X_ACP, c2d.X_MgCO3, + c2d.X_AlOH, c2d.X_AlPO4, c2d.X_FeOH, c2d.X_FePO4, + c2d.S_Na, c2d.S_Cl, c2d.H2O]) + cmps_adm1p.default_compile() + if set_thermo: settings.set_thermo(cmps_adm1p) + return cmps_adm1p + + +#%% +# ============================================================================= +# kinetic rate functions +# ============================================================================= +def adm1p_acid_base_rxn(h_ion, ionic_states, Ka): + K, Mg, Ca, Na, Cl, IC, IN, IP = ionic_states[:8] # in M + Kw, Knh, Kc1, Kc2, Kp1, Kp2, Kp3 = Ka[:7] + oh_ion = Kw/h_ion + nh4 = IN * h_ion/(Knh + h_ion) + vfas = ionic_states[-4:] * Ka[-4:]/(Ka[-4:] + h_ion) + co2, hco3, co3 = ion_speciation(h_ion, Kc1, Kc2) * IC + h3po4, h2po4, hpo4, po4 = ion_speciation(h_ion, Kp1, Kp2, Kp3) * IP + return K + 2*Mg + 2*Ca + Na + h_ion + nh4 - Cl - oh_ion - sum(vfas) - hco3 - 2*co3 - h2po4 - 2*hpo4 - 3*po4 + +def adm1p_solve_pH(state_arr, Ka, unit_conversion): + cmps_in_M = state_arr[:42] * unit_conversion + # K, Mg, Ca, Na, Cl, IC, IN, IP, Ac, Pr, Bu, Va + ions = cmps_in_M[[27, 28, 29, 39, 40, 9, 10, 11, 6, 5, 4, 3]] + h = brenth(adm1p_acid_base_rxn, 1e-14, 1.0, + args=(ions, Ka), + xtol=1e-12, maxiter=100) + return h + +rhos_p = np.zeros(35) # 28 kinetic processes (25 as defined in modified ADM1 + 7 mmp + 3 for gases) +Cs_p = np.empty(25) # 25 processes as defined in modified ADM1 + +rhos_adm1p = lambda state_arr, params: _rhos_adm1p(state_arr, params, h=None) + +def _rhos_adm1p(state_arr, params, h=None): + ks = params['rate_constants'] + Ks = params['half_sat_coeffs'] + + cmps = params['components'] + 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'] + + if 'unit_conv' not in params: params['unit_conv'] = mass2mol_conversion(cmps) + unit_conversion = params['unit_conv'] + + # state_arr_cmps stated just for readability of code + # 0: S_su + # 1: S_aa + # 2: S_fa + # 3: S_va + # 4: S_bu + # 5: S_pro + # 6: S_ac + # 7: S_h2 + # 8: S_ch4 + # 9: S_IC + # 10: S_IN + # 11: S_IP + # 12: S_I + # 13: X_ch + # 14: X_pr + # 15: X_li + # 16: X_su + # 17: X_aa + # 18: X_fa + # 19: X_c4 + # 20: X_pro + # 21: X_ac + # 22: X_h2 + # 23: X_I + # 24: X_PHA + # 25: X_PP + # 26: X_PAO + # 27: S_K + # 28: S_Mg + # 29: S_Ca + # 30: X_CaCO3 + # 31: X_struv + # 32: X_newb + # 33: X_ACP + # 34: X_MgCO3 + # 35: X_AlOH + # 36: X_AlPO4 + # 37: X_FeOH + # 38: X_FePO4 + # 39: S_Na + # 40: S_Cl + # 41: H2O + + Cs_p[:7] = state_arr[13:20] + Cs_p[7:11] = state_arr[19:23] + Cs_p[11:18] = state_arr[16:23] + Cs_p[18:23] = X_PAO = state_arr[26] + Cs_p[23] = X_PP = state_arr[25] + Cs_p[24] = state_arr[24] + + substrates = state_arr[:8] + + S_va, S_bu, S_h2, S_IC, S_IN, S_IP = state_arr[[3,4,7,9,10,11]] + + T_op = state_arr[-1] + if T_op == T_base: + Ka = Kab + KH = KHb / unit_conversion[7:10] + else: + T_temp = params.pop('T_op', None) + if T_op != T_temp: + params['Ka'] = Kab * T_correction_factor(T_base, T_op, Ka_dH) + params['KH'] = KHb * T_correction_factor(T_base, T_op, KH_dH) / unit_conversion[7:10] + params['T_op'] = T_op + Ka = params['Ka'] + KH = params['KH'] + + rhos_p[:25] = ks * Cs_p + rhos_p[3:11] *= substr_inhibit(substrates, Ks[:8]) + if S_va > 0: rhos_p[6] *= 1/(1+S_bu/S_va) + if S_bu > 0: rhos_p[7] *= 1/(1+S_va/S_bu) + + vfas = state_arr[3:7] + + if X_PAO > 0: + K_A, K_PP = Ks[-2:] + rhos_p[18:22] *= substr_inhibit(vfas, K_A) * substr_inhibit(X_PP/X_PAO, K_PP) + + if sum(vfas) > 0: + rhos_p[18:22] *= vfas/sum(vfas) + + if h is None: h = adm1p_solve_pH(state_arr, Ka, unit_conversion) + Knh, Kc1, Kc2, Kp1, Kp2, Kp3 = Ka[1:7] + nh3 = Knh / (Knh + h) * S_IN * unit_conversion[10] # in mol/L + nh4 = h / (Knh + h) * S_IN # in kg-N/m3 + co2, hco3, co3 = S_IC * ion_speciation(h, Kc1, Kc2) + h3po4, h2po4, hpo4, po4 = S_IP * ion_speciation(h, Kp1, Kp2, Kp3) + + Iph = Hill_inhibit(h, pH_ULs, pH_LLs) + Iin = substr_inhibit(S_IN, KS_IN) + Iip = substr_inhibit(S_IP, KS_IP) + Ih2 = non_compet_inhibit(S_h2, KIs_h2) + Inh3 = non_compet_inhibit(nh3, KI_nh3) + root.data = { + 'pH':-np.log10(h), + 'Iph':Iph, + 'Ih2':Ih2, + 'Iin':Iin, + 'Inh3':Inh3, + } + rhos_p[3:11] *= Iph * Iin * Iip + rhos_p[5:9] *= Ih2 + rhos_p[9] *= Inh3 + + ########## precipitation-dissolution ############# + k_mmp = params['k_mmp'] + Ksp = params['Ksp'] + # K_dis = params['K_dis'] + K_AlOH = params['K_AlOH'] + K_FeOH = params['K_FeOH'] + S_Mg, S_Ca, X_CaCO3, X_struv, X_newb, X_ACP, X_MgCO3 = state_arr[28:35] + X_AlOH, X_FeOH = state_arr[[35,37]] + # f_dis = Monod(state_arr[30:35], K_dis[:5]) + # if X_CaCO3 > 0: rhos_p[25] = (S_Ca * co3 - Ksp[0]) * f_dis[0] + # else: rhos_p[25] = S_Ca * co3 + # if X_struv > 0: rhos_p[26] = (S_Mg * nh4 * po4 - Ksp[1]) * f_dis[1] + # else: rhos_p[26] = S_Mg * nh4 * po4 + # if X_newb > 0: rhos_p[27] = (S_Mg * hpo4 - Ksp[2]) * f_dis[2] + # else: rhos_p[27] = S_Mg * hpo4 + # if X_ACP > 0: rhos_p[28] = (S_Ca**3 * po4**2 - Ksp[3]) * f_dis[3] + # else: rhos_p[28] = S_Ca**3 * po4**2 + # if X_MgCO3 > 0: rhos_p[29] = (S_Mg * co3 - Ksp[4]) * f_dis[4] + # else: rhos_p[29] = S_Mg * co3 + + rhos_p[25:32] = 0 + if po4 > 0: + if X_AlOH > 0: + rhos_p[30] = X_AlOH * po4 * Monod(X_AlOH, K_AlOH) + if X_FeOH > 0: + rhos_p[31] = X_FeOH * po4 * Monod(X_FeOH, K_FeOH) + + if S_Ca > 0 and co3 > 0: + SI = (S_Ca * co3 / Ksp[0])**(1/2) + if SI > 1: rhos_p[25] = X_CaCO3 * (SI-1)**2 + + if S_Mg > 0 and nh4 > 0 and po4 > 0: + SI = (S_Mg * nh4 * po4 / Ksp[1])**(1/3) + if SI > 1: rhos_p[26] = X_struv * (SI-1)**3 + + if S_Mg > 0 and hpo4 > 0: + SI = (S_Mg * hpo4 / Ksp[2])**(1/2) + if SI > 1: rhos_p[27] = X_newb * (SI-1)**2 + + if S_Ca > 0 and po4 > 0: + SI = (S_Ca**3 * po4**2 / Ksp[3])**(1/5) + if SI > 1: rhos_p[28] = X_ACP * (SI-1)**2 + + if S_Mg > 0 and co3 > 0: + SI = (S_Mg * co3 / Ksp[4])**(1/2) + if SI > 1: rhos_p[29] = X_MgCO3 * (SI-1)**2 + + rhos_p[25:32] *= k_mmp + + biogas_S = state_arr[7:10].copy() + biogas_p = R * T_op * state_arr[42:45] + biogas_S[-1] = co2 + rhos_p[-3:] = kLa * (biogas_S - KH * biogas_p) + + return rhos_p + +#%% +# ============================================================================= +# ADM1p class +# ============================================================================= + +@chemicals_user +class ADM1p(ADM1): + """ + Anaerobic Digestion Model No.1 with P extension. [1]_, [2]_. + Compatible with `mASM2d`. + + 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. + 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_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_PO4 : float, optional + Yield of biomass on phosphate [kmol P/kg COD]. The default is 0.013. + K_A : float, optional + VFAs half saturation coefficient for PHA storage [kg COD/m3]. The default is 0.004. + K_PP : float, optional + Half saturation coefficient for polyphosphate [kmol PP/kg PAO COD]. + The default is 0.00032. + 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. + KS_IP : float, optional + P limitation for inorganic phosphorous [kmol P/m3]. + The default is 2e-5. + pKa_base : iterable[float], optional + pKa (equilibrium coefficient) values of acid-base pairs at the base + temperature, unitless, following the order of `ADM1p._acid_base_pairs`. + The default is [14, 9.25, 6.37, 10.32, 2.12, 7.21, 12.32, 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_p_extension._acid_base_pairs`. The default is + [55900, 51965, 17400, 14600, -7500, 3000, 15000, 0, 0, 0, 0]. + + See Also + -------- + :class:`qsdsan.processes.ADM1` + :class:`qsdsan.processes.mASM2d` + + Examples + -------- + >>> import qsdsan.processes as pc + >>> cmps = pc.create_adm1p_cmps() + >>> adm = pc.ADM1p() + >>> adm.show() + ADM1p([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, CaCO3_precipitation_dissolution, struvite_precipitation_dissolution, newberyite_precipitation_dissolution, ACP_precipitation_dissolution, MgCO3_precipitation_dissolution, AlPO4_precipitation_dissolution, FePO4_precipitation_dissolution, h2_transfer, ch4_transfer, IC_transfer]) + + + References + ---------- + .. [1] 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. + .. [2] Solon, K., Flores-Alsina, X., Kazadi Mbamba, C., Ikumi, D., Volcke, + E. I. P., Vaneeckhaute, C., Ekama, G., Vanrolleghem, P. A., Batstone, + D. J., Gernaey, K. V., & Jeppsson, U. (2017). Plant-wide modelling + of phosphorus transformations in wastewater treatment systems: + Impacts of control and operational strategies. Water Research, 113, + 97–110. + """ + + _stoichio_params = (*ADM1._stoichio_params[5:], + 'f_sI_xb', 'f_ch_xb', 'f_pr_xb', 'f_li_xb', 'f_xI_xb', + 'f_ac_PHA', 'f_bu_PHA', 'f_pro_PHA', 'f_va_PHA', + 'Y_PO4', 'K_XPP', 'Mg_XPP') + + _kinetic_params = (*ADM1._kinetic_params, 'KS_IP', + 'k_mmp', 'Ksp', 'K_dis', 'K_AlOH', 'K_FeOH') + + _acid_base_pairs = (('H+', 'OH-'), ('NH4+', 'NH3'), + ('CO2', 'HCO3-'), ('HCO3-', 'CO3-2'), + ('H3PO4', 'H2PO4-'), ('H2PO4-', 'HPO4-2'), ('HPO4-2', 'PO4-3'), + ('HAc', 'Ac-'),('HPr', 'Pr-'), + ('HBu', 'Bu-'), ('HVa', 'Va-')) + + _precipitates = ('X_CaCO3', 'X_struv', 'X_newb', 'X_ACP', 'X_MgCO3', 'X_AlPO4', 'X_FePO4') + + _biomass_IDs = (*ADM1._biomass_IDs, 'X_PAO') + + def __new__(cls, components=None, path=None, + 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, 6.37, 10.32, 2.12, 7.21, 12.32, 4.76, 4.88, 4.82, 4.86], + Ka_dH=[55900, 51965, 17400, 14600, -7500, 3000, 15000, 0, 0, 0, 0], + kLa=200, K_H_base=[7.8e-4, 1.4e-3, 3.5e-2], + K_H_dH=[-4180, -14240, -19410], + # k_mmp=(5.0, 300, 0.05, 150, 50, 1.0, 1.0), + # pKsp=(6.45, 13.16, 5.8, 23, 7, 21, 26), + # k_mmp=(0.024, 120, 0.024, 72, 0.024, 0.024, 0.024), # Flores-Alsina 2016 + # pKsp=(8.3, 13.6, 18.175, 28.92, 7.46, 18.2, 37.76), # Flores-Alsina 2016 + k_mmp=(8.4, 240, 1.0, 72, 1.0, 1.0, 1.0), # MATLAB + pKsp=(8.5, 13.7, 5.9, 28.6, 7.6, 18.2, 26.5), # MINTEQ (except newberyite), 35 C + K_dis=(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), + K_AlOH=1.0e-6, K_FeOH=1.0e-6, # kg/m3 + **kwargs): + + cmps = _load_components(components) + + if not path: path = _path + self = Processes.load_from_file(path, + components=cmps, + conserved_for=('C', 'N', 'P'), + parameters=cls._stoichio_params, + compile=False) + + mmp_stoichio = {} + df = load_data(_mmp) + df.rename(columns={'S_NH4':'S_IN', 'S_PO4':'S_IP'}, inplace=True) + mmp = Processes.load_from_file(data=df, components=cmps, + conserved_for=(), compile=False) + for i, j in df.iterrows(): + j.dropna(inplace=True) + key = j.index[j == 1][0] + j = j.to_dict() + j.pop(key) + mmp_stoichio[key] = j + mol_to_mass = cmps.chem_MW / cmps.i_mass + Ksp_mass = np.array([10**(-p) for p in pKsp]) # mass in kg/m3 + i = 0 + for pd, xid in zip(mmp, cls._precipitates): + for k,v in mmp_stoichio[xid].items(): + m2m = mol_to_mass[cmps.index(k)] + Ksp_mass[i] *= m2m**abs(v) + i += 1 + pd._stoichiometry *= mol_to_mass + pd.ref_component = xid + self.extend(mmp) + + for i in cls._biogas_IDs: + new_p = Process('%s_transfer' % i.lstrip('S_'), + reaction={i:-1}, + ref_component=i, + conserved_for=()) + self.append(new_p) + self.compile(to_class=cls) + + stoichio_vals = (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, + Y_su, Y_aa, Y_fa, Y_c4, Y_pro, Y_ac, Y_h2, + # new parameters + 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_ac_PHA, f_bu_PHA, f_pro_PHA, 1-f_ac_PHA-f_bu_PHA-f_pro_PHA, + Y_PO4*P_mw, 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, + #!!! new + 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() + dct = self.__dict__ + dct.update(kwargs) + dct['mmp_stoichio'] = mmp_stoichio + self.set_rate_function(rhos_adm1p) + 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, + KI_nh3, KIs_h2, Ka_base, Ka_dH, + K_H_base, K_H_dH, kLa, + T_base, self._components, root, + #!!! new parameter + KS_IP*P_mw, np.array(k_mmp), Ksp_mass, + np.array(K_dis), K_AlOH, K_FeOH])) + + def dydt_Sh2_AD(S_h2, state_arr, h, params, f_stoichio, V_liq, S_h2_in): + state_arr[7] = S_h2 + Q = state_arr[45] + rxn = _rhos_adm1p(state_arr, params, h=h) + stoichio = f_stoichio(state_arr) # should return the stoichiometric coefficients of S_h2 for all processes + return Q/V_liq*(S_h2_in - S_h2) + np.dot(rxn, stoichio) + + grad_rhosp = np.zeros(5) + X_biop = np.zeros(5) + def grad_dydt_Sh2_AD(S_h2, state_arr, h, params, f_stoichio, V_liq, S_h2_in): + state_arr[7] = S_h2 + ks = params['rate_constants'][[5,6,7,8,10]] + Ks = params['half_sat_coeffs'][2:6] + K_h2 = params['half_sat_coeffs'][7] + pH_ULs = params['pH_ULs'] + pH_LLs = params['pH_LLs'] + KS_IN = params['KS_IN'] + KIs_h2 = params['KIs_h2'] + kLa = params['kLa'] + + X_biop[:] = state_arr[[18,19,19,20,22]] + substrates = state_arr[2:6] + S_va, S_bu, S_IN = state_arr[[3,4,10]] + Iph = Hill_inhibit(h, pH_ULs, pH_LLs)[[2,3,4,5,7]] + Iin = substr_inhibit(S_IN, KS_IN) + grad_Ih2 = grad_non_compet_inhibit(S_h2, KIs_h2) + + grad_rhosp[:] = ks * X_biop * Iph * Iin + grad_rhosp[:-1] *= substr_inhibit(substrates, Ks) * grad_Ih2 + if S_va > 0: grad_rhosp[1] *= 1/(1+S_bu/S_va) + if S_bu > 0: grad_rhosp[2] *= 1/(1+S_va/S_bu) + + grad_rhosp[-1] *= grad_substr_inhibit(S_h2, K_h2) + stoichio = f_stoichio(state_arr) + + Q = state_arr[45] + return -Q/V_liq + np.dot(grad_rhos, stoichio[[5,6,7,8,10]]) + kLa*stoichio[-3] + + dct['flex_rhos'] = _rhos_adm1p + dct['solve_pH'] = adm1p_solve_pH + dct['dydt_Sh2_AD'] = dydt_Sh2_AD + dct['grad_dydt_Sh2_AD'] = grad_dydt_Sh2_AD + return self + + 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) + if i < 11: + self.rate_function._params['half_sat_coeffs'][i-3] = K + else: + ValueError('To set "K_A", specify process = -2; to set "K_PP", specify process = -1,' + f'not {process}') + + 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) - 3 + dct = self.rate_function._params + if lower is None: lower = dct['pH_LLs'][i] + else: dct['pH_LLs'][i] = lower + if upper is None: upper = dct['pH_ULs'][i] + else: dct['pH_ULs'][i] = 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-5] = KI + + + 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_pKsps(self, ps): + cmps = self.components + mol_to_mass = cmps.chem_MW / cmps.i_mass + idxer = cmps.index + stoichio = self.mmp_stoichio + Ksp_mass = [] # mass in kg/m3 + for xid, p in zip(self._precipitates, ps): + K = 10**(-p) + for cmp, v in stoichio[xid]: + m2m = mol_to_mass[idxer(cmp)] + K *= m2m**abs(v) + Ksp_mass.append(K) + self.rate_function._params['Ksp'] = np.array(Ksp_mass) + + 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/processes/_aeration.py b/qsdsan/processes/_aeration.py index 300a6573..d5a0ffec 100644 --- a/qsdsan/processes/_aeration.py +++ b/qsdsan/processes/_aeration.py @@ -183,6 +183,8 @@ def KLa_20(self, i): self._Q_air = None self.KLa = None + kLa_20 = KLa_20 + @property def Q_air(self): """[float] Airflow rate at field conditions, [m^3/d].""" @@ -344,6 +346,8 @@ def KLa(self, KLa): self._KLa = KLa or self._calc_KLa() self.set_parameters(KLa=self._KLa) + kLa = KLa + @property def DOsat(self): """ diff --git a/qsdsan/processes/_asm2d.py b/qsdsan/processes/_asm2d.py index efcd4695..2fba3f56 100644 --- a/qsdsan/processes/_asm2d.py +++ b/qsdsan/processes/_asm2d.py @@ -9,13 +9,18 @@ Please refer to https://github.com/QSD-Group/QSDsan/blob/main/LICENSE.txt for license details. ''' - +import numpy as np from thermosteam.utils import chemicals_user from thermosteam import settings -from qsdsan import Components, Process, Processes, CompiledProcesses -from ..utils import ospath, data_path +from qsdsan import Component, Components, Processes, CompiledProcesses +from ..utils import ospath, data_path, load_data +from . import Monod, ion_speciation +from scipy.optimize import brenth +# from math import log10 + -__all__ = ('create_asm2d_cmps', 'ASM2d') +__all__ = ('create_asm2d_cmps', 'ASM2d', + 'create_masm2d_cmps', 'mASM2d') _path = ospath.join(data_path, 'process_data/_asm2d.tsv') _load_components = settings.get_default_chemicals @@ -70,155 +75,180 @@ def create_asm2d_cmps(set_thermo=True): # create_asm2d_cmps() - -############ Processes in ASM2d ################# -# params = ('f_SI', 'Y_H', 'f_XI_H', 'Y_PAO', 'Y_PO4', 'Y_PHA', 'f_XI_PAO', 'Y_A', 'f_XI_AUT', -# 'K_h', 'eta_NO3', 'eta_fe', 'K_O2', 'K_NO3', 'K_X', -# 'mu_H', 'q_fe', 'eta_NO3_H', 'b_H', 'K_O2_H', 'K_F', 'K_fe', 'K_A_H', -# 'K_NO3_H', 'K_NH4_H', 'K_P_H', 'K_ALK_H', -# 'q_PHA', 'q_PP', 'mu_PAO', 'eta_NO3_PAO', 'b_PAO', 'b_PP', 'b_PHA', -# 'K_O2_PAO', 'K_NO3_PAO', 'K_A_PAO', 'K_NH4_PAO', 'K_PS',' K_P_PAO', -# 'K_ALK_PAO', 'K_PP', 'K_MAX', 'K_IPP', 'K_PHA', -# 'mu_AUT', 'b_AUT', 'K_O2_AUT', 'K_NH4_AUT', 'K_ALK_AUT', 'K_P_AUT', -# 'k_PRE', 'k_RED', 'K_ALK_PRE') - -# asm2d = Processes.load_from_file(data_path, -# conserved_for=('COD', 'N', 'P', 'charge'), -# parameters=params, -# compile=False) - -# p12 = Process('anox_storage_PP', -# 'S_PO4 + [Y_PHA]X_PHA + [?]S_NO3 -> X_PP + [?]S_N2 + [?]S_NH4 + [?]S_ALK', -# ref_component='X_PP', -# rate_equation='q_PP * S_O2/(K_O2_PAO+S_O2) * S_PO4/(K_PS+S_PO4) * S_ALK/(K_ALK_PAO+S_ALK) * (X_PHA/X_PAO)/(K_PHA+X_PHA/X_PAO) * (K_MAX-X_PP/X_PAO)/(K_IPP+K_MAX-X_PP/X_PAO) * X_PAO * eta_NO3_PAO * K_O2_PAO/S_O2 * S_NO3/(K_NO3_PAO+S_NO3)', -# parameters=('Y_PHA', 'q_PP', 'K_O2_PAO', 'K_PS', 'K_ALK_PAO', 'K_PHA', 'eta_NO3_PAO', 'K_IPP', 'K_NO3_PAO'), -# conserved_for=('COD', 'N', 'P', 'NOD', 'charge')) - -# p14 = Process('PAO_anox_growth', -# '[1/Y_PAO]X_PHA + [?]S_NO3 + [?]S_PO4 -> X_PAO + [?]S_N2 + [?]S_NH4 + [?]S_ALK', -# ref_component='X_PAO', -# rate_equation='mu_PAO * S_O2/(K_O2_PAO + S_O2) * S_NH4/(K_NH4_PAO + S_NH4) * S_PO4/(K_P_PAO + S_PO4) * S_ALK/(K_ALK_PAO + S_ALK) * (X_PHA/X_PAO)/(K_PHA + X_PHA/X_PAO) * X_PAO * eta_NO3_PAO * K_O2_PAO/S_O2 * S_NO3/(K_NO3_PAO + S_NO3)', -# parameters=('Y_PAO', 'mu_PAO', 'K_O2_PAO', 'K_NH4_PAO', 'K_P_PAO', 'K_ALK_PAO', 'K_PHA', 'eta_NO3_PAO', 'K_NO3_PAO'), -# conserved_for=('COD', 'N', 'P', 'NOD', 'charge')) - -# asm2d.extend([p12, p14]) -# asm2d.compile() - -# # ASM2d typical values at 20 degree C -# asm2d.set_parameters( -# f_SI = 0, # production of soluble inerts in hydrolysis = 0.0 gCOD/gCOD -# Y_H = 0.625, # heterotrophic yield = 0.625 gCOD/gCOD -# f_XI_H=0.1, # fraction of inert COD generated in heterotrophic biomass lysis = 0.1 gCOD/gCOD -# Y_PAO = 0.625, # PAO yield = 0.625 gCOD/gCOD -# Y_PO4 = 0.4, # PP requirement (PO4 release) per PHA stored = 0.4 gP/gCOD -# Y_PHA = 0.2, # PHA requirement for PP storage = 0.2 gCOD/gP -# f_XI_PAO=0.1, # fraction of inert COD generated in PAO biomass lysis = 0.1 gCOD/gCOD -# Y_A = 0.24, # autotrophic yield = 0.24 gCOD/gN -# f_XI_AUT=0.1, # fraction of inert COD generated in autotrophic biomass lysis = 0.1 gCOD/gCOD -# K_h = 3, # hydrolysis rate constant = 3.0 d^(-1) -# eta_NO3 = 0.6, # reduction factor for anoxic hydrolysis = 0.6 -# eta_fe = 0.4, # anaerobic hydrolysis reduction factor = 0.4 -# K_O2 = 0.2, # O2 half saturation coefficient of hydrolysis = 0.2 mgO2/L -# K_NO3 = 0.5, # nitrate half saturation coefficient of hydrolysis = 0.5 mgN/L -# K_X = 0.1, # slowly biodegradable substrate half saturation coefficient for hydrolysis = 0.1 gCOD/gCOD -# mu_H = 6, # heterotrophic maximum specific growth rate = 6.0 d^(-1) -# q_fe = 3, # fermentation maximum rate = 3.0 d^(-1) -# eta_NO3_H = 0.8, # denitrification reduction factor for heterotrophic growth = 0.8 -# b_H = 0.4, # lysis and decay rate constant = 0.4 d^(-1) -# K_O2_H=0.2, # O2 half saturation coefficient of heterotrophs = 0.2 mgO2/L -# K_F = 4, # fermentable substrate half saturation coefficient for heterotrophic growth = 4.0 mgCOD/L -# K_fe = 4, # fermentable substrate half saturation coefficient for fermentation = 4.0 mgCOD/L -# K_A_H = 4, # VFA half saturation coefficient for heterotrophs = 4.0 mgCOD/L -# K_NO3_H=0.5, # nitrate half saturation coefficient = 0.5 mgN/L -# K_NH4_H = 0.05, # ammonium (nutrient) half saturation coefficient for heterotrophs = 0.05 mgN/L -# K_P_H = 0.01, # phosphorus (nutrient) half saturation coefficient for heterotrophs = 0.01 mgP/L -# K_ALK_H = 0.1*12, # alkalinity half saturation coefficient for heterotrophs = 0.1 mol(HCO3-)/m^3 = 1.2 gC/m^3 -# q_PHA = 3, # rate constant for storage of PHA = 3.0 d^(-1) -# q_PP = 1.5, # rate constant for storage of PP = 1.5 d^(-1) -# mu_PAO = 1, # PAO maximum specific growth rate = 1.0 d^(-1) -# eta_NO3_PAO=0.6, # denitrification reduction factor for PAO growth = 0.8 -# b_PAO = 0.2, # PAO lysis rate = 0.2 d^(-1) -# b_PP = 0.2, # PP lysis rate = 0.2 d^(-1) -# b_PHA = 0.2, # PHA lysis rate = 0.2 d^(-1) -# K_O2_PAO=0.2, # O2 half saturation coefficient for PAOs = 0.2 mgO2/L -# K_NO3_PAO=0.5, # nitrate half saturation coefficient for PAOs = 0.5 mgN/L -# K_A_PAO=4.0, # VFA half saturation coefficient for PAOs = 4.0 mgCOD/L -# K_NH4_PAO=0.05, # ammonium (nutrient) half saturation coefficient for PAOs = 0.05 mgN/L -# K_PS = 0.2, # phosphorus half saturation coefficient for storage of PP = 0.2 mgP/L -# K_P_PAO=0.01, # phosphorus (nutrient) half saturation coefficient for heterotrophs = 0.01 mgP/L -# K_ALK_PAO=0.1*12, # alkalinity half saturation coefficient for PAOs = 0.1 mol(HCO3-)/m^3 = 1.2 gC/m^3 -# K_PP = 0.01, # PP half saturation coefficient for storage of PHA = 0.01 gP/gCOD (?). gCOD/gCOD in GPS-X -# K_MAX = 0.34, # maximum ratio of X_PP/X_PAO = 0.34 gX_PP/gX_PAO -# K_IPP = 0.02, # inhibition coefficient for PP storage = 0.02 gP/gCOD -# K_PHA = 0.01, # PHA half saturation coefficient = 0.01 gCOD/gCOD -# mu_AUT = 1, # autotrophic maximum specific growth rate = 1.0 d^(-1) -# b_AUT = 0.15, # autotrophic decay rate = 0.15 d^(-1) -# K_O2_AUT = 0.5, # O2 half saturation coefficient for autotrophic growth = 0.5 mgO2/L -# K_NH4_AUT = 1, # ammonium (substrate) half saturation coefficient for autotrophic growth = 1.0 mgN/L -# K_ALK_AUT=0.5*12, # alkalinity half saturation coefficient for autotrophic growth = 0.5 mol(HCO3-)/m^3 = 6.0 gC/m^3 -# K_P_AUT=0.01, # phosphorus (nutrient) half saturation coefficient for autotrophic growth = 0.01 mgP/L -# k_PRE = 1, # phosphorus precipitation with MeOH rate constant = 1.0 m^3/g/d -# k_RED = 0.6, # redissoluation of phosphates rate constant = 0.6 d^(-1) -# K_ALK_PRE=0.5*12 # alkalinity half saturation coefficient for phosphate precipitation = 0.5 mol(HCO3-)/m^3 = 6.0 gC/m^3 -# ) - -# ASM2d typical values at 10 degree C -# asm2d.set_parameters( -# f_SI = 0, # production of soluble inerts in hydrolysis = 0.0 gCOD/gCOD -# Y_H = 0.625, # heterotrophic yield = 0.625 gCOD/gCOD -# f_XI_H=0.1, # fraction of inert COD generated in heterotrophic biomass lysis = 0.1 gCOD/gCOD -# Y_PAO = 0.625, # PAO yield = 0.625 gCOD/gCOD -# Y_PO4 = 0.4, # PP requirement (PO4 release) per PHA stored = 0.4 gP/gCOD -# Y_PHA = 0.2, # PHA requirement for PP storage = 0.2 gCOD/gP -# f_XI_PAO=0.1, # fraction of inert COD generated in PAO biomass lysis = 0.1 gCOD/gCOD -# Y_A = 0.24, # autotrophic yield = 0.24 gCOD/gN -# f_XI_AUT=0.1, # fraction of inert COD generated in autotrophic biomass lysis = 0.1 gCOD/gCOD -# K_h = 2, # hydrolysis rate constant = 2.0 d^(-1) -# eta_NO3 = 0.6, # reduction factor for anoxic hydrolysis = 0.6 -# eta_fe = 0.4, # anaerobic hydrolysis reduction factor = 0.4 -# K_O2 = 0.2, # O2 half saturation coefficient of hydrolysis = 0.2 mgO2/L -# K_NO3 = 0.5, # nitrate half saturation coefficient of hydrolysis = 0.5 mgN/L -# K_X = 0.1, # slowly biodegradable substrate half saturation coefficient for hydrolysis = 0.1 gCOD/gCOD -# mu_H = 3, # heterotrophic maximum specific growth rate = 3.0 d^(-1) -# q_fe = 1.5, # fermentation maximum rate = 1.5 d^(-1) -# eta_NO3_H = 0.8, # denitrification reduction factor for heterotrophic growth = 0.8 -# b_H = 0.4, # lysis and decay rate constant = 0.4 d^(-1) -# K_O2_H=0.2, # O2 half saturation coefficient of heterotrophs = 0.2 mgO2/L -# K_F = 4, # fermentable substrate half saturation coefficient for heterotrophic growth = 4.0 mgCOD/L -# K_fe = 4, # fermentable substrate half saturation coefficient for fermentation = 4.0 mgCOD/L -# K_A_H = 4, # VFA half saturation coefficient for heterotrophs = 4.0 mgCOD/L -# K_NO3_H=0.5, # nitrate half saturation coefficient = 0.5 mgN/L -# K_NH4_H = 0.05, # ammonium (nutrient) half saturation coefficient for heterotrophs = 0.05 mgN/L -# K_P_H = 0.01, # phosphorus (nutrient) half saturation coefficient for heterotrophs = 0.01 mgP/L -# K_ALK_H = 0.1*12, # alkalinity half saturation coefficient for heterotrophs = 0.1 mol(HCO3-)/m^3 = 1.2 gC/m^3 -# q_PHA = 2, # rate constant for storage of PHA = 2.0 d^(-1) -# q_PP = 1.0, # rate constant for storage of PP = 1.0 d^(-1) -# mu_PAO = 0.67, # PAO maximum specific growth rate = 0.67 d^(-1) -# eta_NO3_PAO=0.6, # denitrification reduction factor for PAO growth = 0.8 -# b_PAO = 0.1, # PAO lysis rate = 0.1 d^(-1) -# b_PP = 0.1, # PP lysis rate = 0.1 d^(-1) -# b_PHA = 0.1, # PHA lysis rate = 0.1 d^(-1) -# K_O2_PAO=0.2, # O2 half saturation coefficient for PAOs = 0.2 mgO2/L -# K_NO3_PAO=0.5, # nitrate half saturation coefficient for PAOs = 0.5 mgN/L -# K_A_PAO=4.0, # VFA half saturation coefficient for PAOs = 4.0 mgCOD/L -# K_NH4_PAO=0.05, # ammonium (nutrient) half saturation coefficient for PAOs = 0.05 mgN/L -# K_PS = 0.2, # phosphorus half saturation coefficient for storage of PP = 0.2 mgP/L -# K_P_PAO=0.01, # phosphorus (nutrient) half saturation coefficient for heterotrophs = 0.01 mgP/L -# K_ALK_PAO=0.1*12, # alkalinity half saturation coefficient for PAOs = 0.1 mol(HCO3-)/m^3 = 1.2 gC/m^3 -# K_PP = 0.01, # PP half saturation coefficient for storage of PHA = 0.01 gP/gCOD (?). gCOD/gCOD in GPS-X -# K_MAX = 0.34, # maximum ratio of X_PP/X_PAO = 0.34 gX_PP/gX_PAO -# K_IPP = 0.02, # inhibition coefficient for PP storage = 0.02 gP/gCOD -# K_PHA = 0.01, # PHA half saturation coefficient = 0.01 gCOD/gCOD -# mu_AUT = 0.35, # autotrophic maximum specific growth rate = 0.35 d^(-1) -# b_AUT = 0.05, # autotrophic decay rate = 0.05 d^(-1) -# K_O2_AUT = 0.5, # O2 half saturation coefficient for autotrophic growth = 0.5 mgO2/L -# K_NH4_AUT = 1, # ammonium (substrate) half saturation coefficient for autotrophic growth = 1.0 mgN/L -# K_ALK_AUT=0.5*12, # alkalinity half saturation coefficient for autotrophic growth = 0.5 mol(HCO3-)/m^3 = 6.0 gC/m^3 -# K_P_AUT=0.01, # phosphorus (nutrient) half saturation coefficient for autotrophic growth = 0.01 mgP/L -# k_PRE = 1, # phosphorus precipitation with MeOH rate constant = 1.0 m^3/g/d -# k_RED = 0.6, # redissoluation of phosphates rate constant = 0.6 d^(-1) -# K_ALK_PRE=0.5*12 # alkalinity half saturation coefficient for phosphate precipitation = 0.5 mol(HCO3-)/m^3 = 6.0 gC/m^3 -# ) +def create_masm2d_cmps(set_thermo=True): + c2d = create_asm2d_cmps(False) + ion_kwargs = dict(particle_size='Soluble', + degradability='Undegradable', + organic=False) + mineral_kwargs = dict(particle_size='Particulate', + degradability='Undegradable', + organic=False) + S_K = Component.from_chemical('S_K', chemical='K+', measured_as='K', + description='Potassium', **ion_kwargs) + + S_Mg = Component.from_chemical('S_Mg', chemical='Mg+2', measured_as='Mg', + description='Magnesium', **ion_kwargs) + + S_IC = c2d.S_ALK.copy('S_IC') + c2d.S_PO4.formula = 'HPO4-2' + c2d.S_PO4.measured_as = 'P' + + c2d.S_F.i_C = c2d.X_S.i_C = 0.31843 + c2d.S_F.i_N = c2d.X_S.i_N = 0.03352 + c2d.S_F.i_P = c2d.X_S.i_P = 5.59e-3 + + c2d.S_I.i_C = c2d.X_I.i_C = 0.36178 + c2d.S_I.i_N = c2d.X_I.i_N = 0.06003 + c2d.S_I.i_P = c2d.X_I.i_P = 6.49e-3 + c2d.S_I.i_K = c2d.X_I.i_K = 0.0 + c2d.S_F.i_mass = c2d.X_S.i_mass = c2d.S_I.i_mass = c2d.X_I.i_mass = 0.75 + c2d.S_F.f_Vmass_Totmass = c2d.X_S.f_Vmass_Totmass = c2d.S_I.f_Vmass_Totmass = c2d.X_I.f_Vmass_Totmass = 0.85 + + c2d.X_H.i_C = c2d.X_AUT.i_C = c2d.X_PAO.i_C = 0.36612 + c2d.X_H.i_N = c2d.X_AUT.i_N = c2d.X_PAO.i_N = 0.08615 + c2d.X_H.i_P = c2d.X_AUT.i_P = c2d.X_PAO.i_P = 0.02154 + c2d.X_H.i_K = c2d.X_AUT.i_K = c2d.X_PAO.i_K = 0.0 + c2d.X_H.i_Mg = c2d.X_AUT.i_Mg = c2d.X_PAO.i_Mg = 0.0 + c2d.X_H.i_Ca = c2d.X_AUT.i_Ca = c2d.X_PAO.i_Ca = 0.0 + c2d.X_H.i_mass = c2d.X_AUT.i_mass = c2d.X_PAO.i_mass = 0.90 + c2d.X_H.f_Vmass_Totmass = c2d.X_AUT.f_Vmass_Totmass = c2d.X_PAO.f_Vmass_Totmass = 0.85 + + c2d.X_PHA.i_C = 0.3 + c2d.X_PHA.i_mass = 0.55 + c2d.X_PHA.f_Vmass_Totmass = 0.92727 + c2d.X_PP.i_charge = 0 + + S_Ca = Component.from_chemical('S_Ca', chemical='Ca+2', measured_as='Ca', + description='Calcium', **ion_kwargs) + + X_CaCO3 = Component.from_chemical('X_CaCO3', chemical='CaCO3', + description='Calcite', **mineral_kwargs) + X_struv = Component.from_chemical('X_struv', chemical='MgNH4PO4(H2O)6', + description='Struvite', **mineral_kwargs) + X_newb = Component.from_chemical('X_newb', chemical='MgHPO4(H2O)3', + description='Newberyite', **mineral_kwargs) + X_ACP = Component.from_chemical('X_ACP', chemical='Ca3P2O8', + description='Amorphous calcium phosphate', + **mineral_kwargs) + X_MgCO3 = Component.from_chemical('X_MgCO3', chemical='MgCO3', + description='Magnesite', **mineral_kwargs) + X_AlOH = Component.from_chemical('X_AlOH', chemical='Al(OH)3', + description='Aluminum hydroxide', **mineral_kwargs) + X_AlPO4 = Component.from_chemical('X_AlPO4', chemical='AlPO4', + description='Aluminum phosphate', **mineral_kwargs) + X_FeOH = c2d.X_MeOH.copy('X_FeOH') + X_FePO4 = c2d.X_MeP.copy('X_FePO4') + + S_Na = Component.from_chemical('S_Na', chemical='Na+', description='Sodium', **ion_kwargs) + S_Cl = Component.from_chemical('S_Cl', chemical='Cl-', description='Chloride', **ion_kwargs) + + H2O = c2d.H2O + + for cmp in (c2d.S_F, c2d.X_S, c2d.S_I, c2d.X_I, c2d.X_H, c2d.X_AUT, c2d.X_PHA): + cmp.i_NOD = None + c2d.refresh_constants() + c2d = [*c2d] + solubles = c2d[:8] # replace S_ALK with S_IC + particulates = c2d[9:-3] # exclude X_MeOH, X_MeP, H2O + + cmps = Components([*solubles, S_IC, S_K, S_Mg, *particulates, + S_Ca, X_CaCO3, X_struv, X_newb, X_ACP, X_MgCO3, + X_AlOH, X_AlPO4, X_FeOH, X_FePO4, S_Na, S_Cl, H2O]) + cmps.default_compile() + if set_thermo: settings.set_thermo(cmps) + + return cmps + + +#%% +_rhos = np.zeros(21) +def rhos_asm2d(state_arr, params): + 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 = state_arr[:18] + + _rhos[:19] = 0. + if X_H > 0: + K_h = params['K_h'] + K_O2 = params['K_O2'] + K_X = params['K_X'] + K_NO3 = params['K_NO3'] + eta_NO3 = params['eta_NO3'] + eta_fe = params['eta_fe'] + _rhos[:3] = K_h*(X_S/X_H)/(K_X+X_S/X_H)*X_H + _rhos[0] *= S_O2/(K_O2+S_O2) + _rhos[1] *= eta_NO3*K_O2/(K_O2+S_O2)*S_NO3/(K_NO3+S_NO3) + _rhos[2] *= eta_fe*K_O2/(K_O2+S_O2)*K_NO3/(K_NO3+S_NO3) + + mu_H = params['mu_H'] + K_O2_H = params['K_O2_H'] + K_F = params['K_F'] + K_A_H = params['K_A_H'] + K_NH4_H = params['K_NH4_H'] + K_P_H = params['K_P_H'] + K_ALK_H = params['K_ALK_H'] + K_NO3_H = params['K_NO3_H'] + eta_NO3_H = params['eta_NO3_H'] + _rhos[3:7] = mu_H*S_NH4/(K_NH4_H+S_NH4)*S_PO4/(K_P_H+S_PO4)*S_ALK/(K_ALK_H+S_ALK)*X_H + _rhos[[3,5]] *= S_F/(K_F+S_F)*S_F/(S_F+S_A) + _rhos[[4,6]] *= S_A/(K_A_H+S_A)*S_A/(S_F+S_A) + _rhos[3:5] *= S_O2/(K_O2_H+S_O2) + _rhos[5:7] *= eta_NO3_H*K_O2_H/(K_O2_H+S_O2)*S_NO3/(K_NO3_H+S_NO3) + + q_fe = params['q_fe'] + K_fe = params['K_fe'] + b_H = params['b_H'] + _rhos[7] = q_fe*K_O2_H/(K_O2_H+S_O2)*K_NO3_H/(K_NO3_H+S_NO3)*S_F/(K_fe+S_F)*S_ALK/(K_ALK_H+S_ALK)*X_H + _rhos[8] = b_H*X_H + + K_ALK_PAO = params['K_ALK_PAO'] + if X_PAO > 0: + q_PHA = params['q_PHA'] + K_A_PAO = params['K_A_PAO'] + K_PP = params['K_PP'] + _rhos[9] = q_PHA*S_A/(K_A_PAO+S_A)*S_ALK/(K_ALK_PAO+S_ALK)*(X_PP/X_PAO)/(K_PP+X_PP/X_PAO)*X_PAO + + q_PP = params['q_PP'] + K_O2_PAO = params['K_O2_PAO'] + K_PS = params['K_PS'] + K_PHA = params['K_PHA'] + K_MAX = params['K_MAX'] + K_IPP = params['K_IPP'] + eta_NO3_PAO = params['eta_NO3_PAO'] + K_NO3_PAO = params['K_NO3_PAO'] + mu_PAO = params['mu_PAO'] + K_NH4_PAO = params['K_NH4_PAO'] + K_P_PAO = params['K_P_PAO'] + _rhos[10:12] = q_PP*S_PO4/(K_PS+S_PO4)*S_ALK/(K_ALK_PAO+S_ALK)*(X_PHA/X_PAO)/(K_PHA+X_PHA/X_PAO)*(K_MAX-X_PP/X_PAO)/(K_IPP+K_MAX-X_PP/X_PAO)*X_PAO + _rhos[12:14] = mu_PAO*S_NH4/(K_NH4_PAO+S_NH4)*S_PO4/(K_P_PAO+S_PO4)*S_ALK/(K_ALK_PAO+S_ALK)*(X_PHA/X_PAO)/(K_PHA+X_PHA/X_PAO)*X_PAO + _rhos[[10,12]] *= S_O2/(K_O2_PAO+S_O2) + _rhos[[11,13]] *= eta_NO3_PAO*K_O2_PAO/(K_O2_PAO+S_O2)*S_NO3/(K_NO3_PAO+S_NO3) + + b_PAO = params['b_PAO'] + _rhos[14] = b_PAO*X_PAO + + b_PP = params['b_PP'] + b_PHA = params['b_PHA'] + _rhos[15] = b_PP*X_PP + _rhos[16] = b_PHA*X_PHA + _rhos[14:17] *= S_ALK/(K_ALK_PAO+S_ALK) + + if X_AUT > 0: + mu_AUT = params['mu_AUT'] + K_O2_AUT = params['K_O2_AUT'] + K_NH4_AUT = params['K_NH4_AUT'] + K_P_AUT = params['K_P_AUT'] + K_ALK_AUT = params['K_ALK_AUT'] + b_AUT = params['b_AUT'] + _rhos[17] = mu_AUT*S_O2/(K_O2_AUT+S_O2)*S_NH4/(K_NH4_AUT+S_NH4)*S_PO4/(K_P_AUT+S_PO4)*S_ALK/(K_ALK_AUT+S_ALK)*X_AUT + _rhos[18] = b_AUT*X_AUT + + k_PRE = params['k_PRE'] + k_RED = params['k_RED'] + K_ALK_PRE = params['K_ALK_PRE'] + _rhos[19] = k_PRE*S_PO4*X_MeOH + _rhos[20] = k_RED*X_MeP*S_ALK/(K_ALK_PRE+S_ALK) + + return _rhos @chemicals_user class ASM2d(CompiledProcesses): @@ -407,7 +437,7 @@ class ASM2d(CompiledProcesses): >>> cmps = pc.create_asm2d_cmps() >>> asm2d = pc.ASM2d() >>> asm2d.show() - ASM2d([aero_hydrolysis, anox_hydrolysis, anae_hydrolysis, hetero_growth_S_F, hetero_growth_S_A, denitri_S_F, denitri_S_A, ferment, hetero_lysis, PAO_storage_PHA, aero_storage_PP, PAO_aero_growth_PHA, PAO_lysis, PP_lysis, PHA_lysis, auto_aero_growth, auto_lysis, precipitation, redissolution, anox_storage_PP, PAO_anox_growth]) + ASM2d([aero_hydrolysis, anox_hydrolysis, anae_hydrolysis, hetero_growth_S_F, hetero_growth_S_A, denitri_S_F, denitri_S_A, ferment, hetero_lysis, PAO_storage_PHA, aero_storage_PP, anox_storage_PP, PAO_aero_growth_PHA, PAO_anox_growth, PAO_lysis, PP_lysis, PHA_lysis, auto_aero_growth, auto_lysis, precipitation, redissolution]) References ---------- @@ -428,8 +458,7 @@ class ASM2d(CompiledProcesses): 'K_O2_PAO', 'K_NO3_PAO', 'K_A_PAO', 'K_NH4_PAO', 'K_PS','K_P_PAO', 'K_ALK_PAO', 'K_PP', 'K_MAX', 'K_IPP', 'K_PHA', 'mu_AUT', 'b_AUT', 'K_O2_AUT', 'K_NH4_AUT', 'K_ALK_AUT', 'K_P_AUT', - 'k_PRE', 'k_RED', 'K_ALK_PRE') - + 'k_PRE', 'k_RED', 'K_ALK_PRE', 'COD_deN') def __new__(cls, components=None, @@ -473,24 +502,6 @@ def __new__(cls, components=None, parameters=cls._params, compile=False) - if path == _path: - _p12 = Process('anox_storage_PP', - 'S_PO4 + [Y_PHA]X_PHA + [?]S_NO3 -> X_PP + [?]S_N2 + [?]S_NH4 + [?]S_ALK', - components=cmps, - ref_component='X_PP', - rate_equation='q_PP * S_O2/(K_O2_PAO+S_O2) * S_PO4/(K_PS+S_PO4) * S_ALK/(K_ALK_PAO+S_ALK) * (X_PHA/X_PAO)/(K_PHA+X_PHA/X_PAO) * (K_MAX-X_PP/X_PAO)/(K_IPP+K_MAX-X_PP/X_PAO) * X_PAO * eta_NO3_PAO * K_O2_PAO/S_O2 * S_NO3/(K_NO3_PAO+S_NO3)', - parameters=('Y_PHA', 'q_PP', 'K_O2_PAO', 'K_PS', 'K_ALK_PAO', 'K_PHA', 'eta_NO3_PAO', 'K_IPP', 'K_NO3_PAO'), - conserved_for=('COD', 'N', 'P', 'NOD', 'charge')) - - _p14 = Process('PAO_anox_growth', - '[1/Y_PAO]X_PHA + [?]S_NO3 + [?]S_PO4 -> X_PAO + [?]S_N2 + [?]S_NH4 + [?]S_ALK', - components=cmps, - ref_component='X_PAO', - rate_equation='mu_PAO * S_O2/(K_O2_PAO + S_O2) * S_NH4/(K_NH4_PAO + S_NH4) * S_PO4/(K_P_PAO + S_PO4) * S_ALK/(K_ALK_PAO + S_ALK) * (X_PHA/X_PAO)/(K_PHA + X_PHA/X_PAO) * X_PAO * eta_NO3_PAO * K_O2_PAO/S_O2 * S_NO3/(K_NO3_PAO + S_NO3)', - parameters=('Y_PAO', 'mu_PAO', 'K_O2_PAO', 'K_NH4_PAO', 'K_P_PAO', 'K_ALK_PAO', 'K_PHA', 'eta_NO3_PAO', 'K_NO3_PAO'), - conserved_for=('COD', 'N', 'P', 'NOD', 'charge')) - self.extend([_p12, _p14]) - self.compile(to_class=cls) self.set_parameters(f_SI=f_SI, Y_H=Y_H, f_XI_H=f_XI_H, Y_PAO=Y_PAO, Y_PO4=Y_PO4, Y_PHA=Y_PHA, f_XI_PAO=f_XI_PAO, Y_A=Y_A, f_XI_AUT=f_XI_AUT, @@ -506,6 +517,435 @@ def __new__(cls, components=None, K_MAX=K_MAX, K_IPP=K_IPP, K_PHA=K_PHA, mu_AUT=mu_AUT, b_AUT=b_AUT, K_O2_AUT=K_O2_AUT, K_NH4_AUT=K_NH4_AUT, K_ALK_AUT=K_ALK_AUT*12, K_P_AUT=K_P_AUT, - k_PRE=k_PRE, k_RED=k_RED, K_ALK_PRE=K_ALK_PRE*12, + k_PRE=k_PRE, k_RED=k_RED, K_ALK_PRE=K_ALK_PRE*12, + COD_deN=cmps.S_N2.i_COD-cmps.S_NO3.i_COD, **kwargs) - return self \ No newline at end of file + self.set_rate_function(rhos_asm2d) + self.rate_function._params = self.parameters + + return self + +#%% +_mpath = ospath.join(data_path, 'process_data/_masm2d.tsv') +_mmp = ospath.join(data_path, 'process_data/_mmp.tsv') + +def acid_base_rxn(h_ion, ionic_states, Ka): + K, Mg, Ca, Na, Cl, NOx, NH, IC, IP, Ac = ionic_states # in M + Kw, Knh, Kc1, Kc2, Kp1, Kp2, Kp3, Kac = Ka + oh_ion = Kw/h_ion + nh4 = NH * h_ion/(Knh + h_ion) + ac = Ac * Kac/(Kac + h_ion) + co2, hco3, co3 = ion_speciation(h_ion, Kc1, Kc2) * IC + h3po4, h2po4, hpo4, po4 = ion_speciation(h_ion, Kp1, Kp2, Kp3) * IP + return K + 2*Mg + 2*Ca + Na + h_ion + nh4 - Cl - NOx - oh_ion - ac - hco3 - 2*co3 - h2po4 - 2*hpo4 - 3*po4 + +def solve_pH(state_arr, Ka, unit_conversion): + cmps_in_M = state_arr[:31] * unit_conversion *1e-3 + # S_K, S_Mg, S_Ca, S_Na, S_Cl, S_NO3, S_NH4, S_IC, S_PO4, S_A + ions = cmps_in_M[[9, 10, 18, 28, 29, 3, 2, 8, 4, 6]] + h = brenth(acid_base_rxn, 1e-14, 1.0, + args=(ions, Ka), + xtol=1e-12, maxiter=100) + return h + +# rhos = np.zeros(19+7+2) # 19 biological processes, 7 precipitation/dissociation, 2 gas stripping +rhos = np.zeros(19+7) # 19 biological processes, 7 precipitation/dissociation +def _rhos_masm2d(state_arr, params, acceptor_dependent_decay=True, h=None): + if 'ks' not in params: + k_h, mu_H, mu_PAO, mu_AUT, \ + q_fe, q_PHA, q_PP, \ + b_H, b_PAO, b_PP, b_PHA, b_AUT, \ + eta_NO3, eta_fe, eta_NO3_H, eta_NO3_PAO, \ + eta_NO3_Hl, eta_NO3_PAOl, eta_NO3_PPl, eta_NO3_PHAl, eta_NO3_AUTl, \ + K_O2, K_O2_H, K_O2_PAO, K_O2_AUT, \ + K_NO3, K_NO3_H, K_NO3_PAO, K_NO3_AUT, \ + K_X, K_F, K_fe, K_A_H, K_A_PAO, \ + K_NH4_H, K_NH4_PAO, K_NH4_AUT, \ + K_P_H, K_P_PAO, K_P_AUT, K_P_S, \ + K_PP, K_MAX, K_IPP, K_PHA, \ + = list(params.values())[:45] + + cmps = params['cmps'] + params['mass2mol'] = cmps.i_mass / cmps.chem_MW + + params['ks'] = ks = np.zeros(19) + # rate constants + ks[:3] = k_h + ks[3:7] = mu_H + ks[7:19] = (q_fe, b_H, q_PHA, q_PP, q_PP, mu_PAO, mu_PAO, b_PAO, b_PP, b_PHA, mu_AUT, b_AUT) + # rate reduction factors + ks[1] *= eta_NO3 + ks[2] *= eta_fe + ks[5:7] *= eta_NO3_H + ks[[11,13]] *= eta_NO3_PAO + + # half saturation / inhibition factors + params['Ks_o2'] = np.array([K_O2, K_O2_H, K_O2_H, K_O2_PAO, K_O2_PAO, K_O2_AUT]) + params['Ks_no3'] = np.array([K_NO3, K_NO3_H, K_NO3_H, K_NO3_PAO, K_NO3_PAO, K_NO3_AUT]) + params['Ks_nh4'] = np.array([K_NH4_H, K_NH4_PAO, K_NH4_AUT]) + params['Ks_po4'] = np.array([K_P_H, K_P_PAO, K_P_AUT]) + params['eta_decay'] = np.array([eta_NO3_Hl, eta_NO3_PAOl, eta_NO3_PPl, eta_NO3_PHAl, eta_NO3_AUTl]) + + ks = params['ks'] + Ks_o2 = params['Ks_o2'] + Ks_no3 = params['Ks_no3'] + Ks_nh4 = params['Ks_nh4'] + Ks_po4 = params['Ks_po4'] + eta_decay = params['eta_decay'] + + Kx = params['K_X'] + Kf = params['K_F'] + Kfe = params['K_fe'] + Ka_H = params['K_A_H'] + Ka_PAO = params['K_A_PAO'] + Kp_stor = params['K_P_S'] + Kpp = params['K_PP'] + Kmax = params['K_MAX'] + Kipp = params['K_IPP'] + Kpha = params['K_PHA'] + + S_O2, S_N2, S_NH4, S_NO3, S_PO4, S_F, S_A, S_I, S_IC, S_K, S_Mg, \ + X_I, X_S, X_H, X_PAO, X_PP, X_PHA, X_AUT, \ + S_Ca, X_CaCO3, X_struv, X_newb, X_ACP, X_MgCO3, \ + X_AlOH, X_AlPO4, X_FeOH, X_FePO4, S_Na, S_Cl \ + = state_arr[:30] + + ############# biological processes ############### + nutrients = Monod(S_NH4, Ks_nh4) * Monod(S_PO4, Ks_po4) + + rhos[:19] = ks + rhos[:9] *= X_H + rhos[3:7] *= nutrients[0] + rhos[9:15] *= X_PAO + rhos[12:14] *= nutrients[1] + rhos[15] *= X_PP + rhos[16] *= X_PHA + rhos[17:19] *= X_AUT + rhos[17] *= nutrients[2] + + aero = Monod(S_O2, Ks_o2) + anox = Monod(S_NO3, Ks_no3) + rhos[[0,3,4,10,12,17]] *= aero # aerobic + rhos[[1,5,6,11,13]] *= (1-aero[:5]) * anox[:5] # anoxic + rhos[[2,7]] *= (1-aero[:2]) * (1-anox[:2]) # anaerobic/fermentation + + if X_H > 0: rhos[:3] *= Monod(X_S/X_H, Kx) + if S_F+S_A == 0: + rhos[3:7] = 0 + else: + rhos[[3,5]] *= Monod(S_F, Kf) * S_F/(S_F+S_A) + rhos[[4,6]] *= Monod(S_A, Ka_H) * S_A/(S_F+S_A) + + rhos[7] *= Monod(S_F, Kfe) + if X_PAO > 0: + pha = Monod(X_PHA/X_PAO, Kpha) + rhos[9] *= Monod(S_A, Ka_PAO) * Monod(X_PP/X_PAO, Kpp) + rhos[[10,11]] *= pha * Monod(Kmax-X_PP/X_PAO, Kipp) * Monod(S_PO4, Kp_stor) + rhos[[12,13]] *= pha + + if acceptor_dependent_decay: + rhos[8] *= (aero[1] + eta_decay[0]*(1-aero[1])*anox[1]) + rhos[14:17] *= (aero[3] +eta_decay[1:4]*(1-aero[3])*anox[3]) + rhos[18] *= (aero[5] + eta_decay[4]*(1-aero[5])*anox[5]) + + ######### pH ############ + mass2mol = params['mass2mol'] + Ka = params['Ka'] + Kw, Knh, Kc1, Kc2, Kp1, Kp2, Kp3, Kac = Ka + if h == None: h = solve_pH(state_arr, Ka, mass2mol) + nh4 = state_arr[2] * h/(Knh + h) + co2, hco3, co3 = state_arr[8] * ion_speciation(h, Kc1, Kc2) + h3po4, h2po4, hpo4, po4 = state_arr[4] * ion_speciation(h, Kp1, Kp2, Kp3) + + ########## precipitation-dissolution ############# + k_mmp = params['k_mmp'] + Ksp = params['Ksp'] + # K_dis = params['K_dis'] + K_AlOH = params['K_AlOH'] + K_FeOH = params['K_FeOH'] + # f_dis = Monod(state_arr[19:24], K_dis[:5]) + # if X_CaCO3 > 0: rhos[19] = (S_Ca * co3 - Ksp[0]) * f_dis[0] + # else: rhos[19] = S_Ca * co3 + # if X_struv > 0: rhos[20] = (S_Mg * nh4 * po4 - Ksp[1]) * f_dis[1] + # else: rhos[20] = S_Mg * nh4 * po4 + # if X_newb > 0: rhos[21] = (S_Mg * hpo4 - Ksp[2]) * f_dis[2] + # else: rhos[21] = S_Mg * hpo4 + # if X_ACP > 0: rhos[22] = (S_Ca**3 * po4**2 - Ksp[3]) * f_dis[3] + # else: rhos[22] = S_Ca**3 * po4**2 + # if X_MgCO3 > 0: rhos[23] = (S_Mg * co3 - Ksp[4]) * f_dis[4] + # else: rhos[23] = S_Mg * co3 + + rhos[19:26] = 0. + SI = (S_Ca * co3 / Ksp[0])**(1/2) + if SI > 1: rhos[19] = X_CaCO3 * (SI-1)**2 + + SI = (S_Mg * nh4 * po4 / Ksp[1])**(1/3) + if SI > 1: rhos[20] = X_struv * (SI-1)**3 + + SI = (S_Mg * hpo4 / Ksp[2])**(1/2) + if SI > 1: rhos[21] = X_newb * (SI-1)**2 + + SI = (S_Ca**3 * po4**2 / Ksp[3])**(1/5) + if SI > 1: rhos[22] = X_ACP * (SI-1)**2 + + SI = (S_Mg * co3 / Ksp[4])**(1/2) + if SI > 1: rhos[23] = X_MgCO3 * (SI-1)**2 + + rhos[24] = X_AlOH * po4 * Monod(X_AlOH, K_AlOH) + rhos[25] = X_FeOH * po4 * Monod(X_FeOH, K_FeOH) + rhos[19:26] *= k_mmp + + return rhos + +#%% +@chemicals_user +class mASM2d(CompiledProcesses): + ''' + Modified ASM2d. [1]_, [2]_ Compatible with `ADM1p` for plant-wide simulations. + Includes an algebraic pH solver and precipitation/dissolution of common minerals. + + Parameters + ---------- + components : :class:`CompiledComponents`, optional + Can be created with the `create_masm2d_cmps` function. + path : str, optional + File path for an alternative Petersen Matrix. The default is None. + electron_acceptor_dependent_decay : bool, optional + Whether biomass decay kinetics is dependent on concentrations of + electron acceptors. The default is True. + k_h : float, optional + Hydrolysis rate constant, in [d^(-1)]. The default is 3.0. + eta_NO3_Hl : float, optional + Anoxic reduction factor for endogenous respiration of heterotrophs, + unitless. The default is 0.5. + eta_NO3_PAOl : float, optional + Anoxic reduction factor for lysis of PAOs, unitless. The default is 0.33. + eta_NO3_PPl : float, optional + Anoxic reduction factor for lysis of PP, unitless. The default is 0.33. + eta_NO3_PHAl : float, optional + Anoxic reduction factor for lysis of PHA, unitless. The default is 0.33. + eta_NO3_AUTl : float, optional + Anoxic reduction factor for decay of autotrophs, unitless. The default is 0.33. + K_NO3_AUT : float, optional + Half saturation coefficient of NOx- for autotrophs [mg-N/L]. The default is 0.5. + K_P_S : float, optional + Half saturation coefficient of ortho-P for PP storage [mg-P/L]. The default is 0.2. + k_mmp : iterable[float], optional + Rate constants for multi-mineral precipitation/dissolution + [mg-precipitate/L/(unit of solubility product)/d]. Follows the exact order + of `mASM2d._precipitates`. The default is (5.0, 300, 0.05, 150, 50, 1.0, 1.0). + pKsp : iterable[float], optional + Solubility of minerals, in order of `mASM2d._precipitates`. + The default is (6.45, 13.16, 5.8, 23, 7, 21, 26). + K_dis : iterable[float], optional + Saturation coefficient for the switching function of mineral dissolution + [mg-precipitate/L]. The default is (1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0). + K_AlOH : float, optional + Half saturation coefficient of aluminum hydroxide for AlPO4 precipitation + [mg-Al(OH)3/L]. The default is 0.001. + K_FeOH : float, optional + Half saturation coefficient of ferric hydroxide for FePO4 precipitation + [mg-Fe(OH)3/L]. The default is 0.001. + pKa : iterable[float], optional + Equilibrium coefficient values of acid-base pairs, unitless, + following the order of `mASM2d._acid_base_pairs`. + The default is (14, 9.25, 6.37, 10.32, 2.12, 7.21, 12.32, 4.76). + + + See Also + -------- + :class:`qsdsan.processes.ASM2d` + :class:`qsdsan.processes.ADM1p` + + Examples + -------- + >>> import qsdsan.processes as pc + >>> cmps = pc.create_masm2d_cmps() + >>> asm = pc.mASM2d() + >>> asm.show() + mASM2d([aero_hydrolysis, anox_hydrolysis, anae_hydrolysis, hetero_growth_S_F, hetero_growth_S_A, denitri_S_F, denitri_S_A, ferment, hetero_lysis, storage_PHA, aero_storage_PP, anox_storage_PP, PAO_aero_growth_PHA, PAO_anox_growth, PAO_lysis, PP_lysis, PHA_lysis, auto_aero_growth, auto_lysis, CaCO3_precipitation_dissolution, struvite_precipitation_dissolution, newberyite_precipitation_dissolution, ACP_precipitation_dissolution, MgCO3_precipitation_dissolution, AlPO4_precipitation_dissolution, FePO4_precipitation_dissolution]) + + + References + ---------- + .. [1] Henze, M., Gujer, W., Mino, T., & van Loosdrecht, M. (2000). + Activated Sludge Models: ASM1, ASM2, ASM2d and ASM3. In IWA task group + on mathematical modelling for design and operation of biological + wastewater treatment (Ed.), Scientific and Technical Report No. 9. + IWA Publishing. + .. [2] Solon, K., Flores-Alsina, X., Kazadi Mbamba, C., Ikumi, D., Volcke, + E. I. P., Vaneeckhaute, C., Ekama, G., Vanrolleghem, P. A., Batstone, + D. J., Gernaey, K. V., & Jeppsson, U. (2017). Plant-wide modelling + of phosphorus transformations in wastewater treatment systems: + Impacts of control and operational strategies. Water Research, 113, + 97–110. https://doi.org/10.1016/j.watres.2017.02.007 + + ''' + _stoichio_params = ('f_SI', 'Y_H', 'Y_PAO', 'Y_PO4', 'Y_PHA', 'Y_A', + 'f_XI_H', 'f_XI_PAO', 'f_XI_AUT', 'COD_deN', 'K_XPP', 'Mg_XPP') + _kinetic_params = ('k_h', 'mu_H', 'mu_PAO', 'mu_AUT', + 'q_fe', 'q_PHA', 'q_PP', + 'b_H', 'b_PAO', 'b_PP', 'b_PHA', 'b_AUT', + 'eta_NO3', 'eta_fe', 'eta_NO3_H', 'eta_NO3_PAO', + 'eta_NO3_Hl', 'eta_NO3_PAOl', 'eta_NO3_PPl', 'eta_NO3_PHAl', 'eta_NO3_AUTl', + 'K_O2', 'K_O2_H', 'K_O2_PAO', 'K_O2_AUT', + 'K_NO3', 'K_NO3_H', 'K_NO3_PAO', 'K_NO3_AUT', + 'K_X', 'K_F', 'K_fe', 'K_A_H', 'K_A_PAO', + 'K_NH4_H', 'K_NH4_PAO', 'K_NH4_AUT', + 'K_P_H', 'K_P_PAO', 'K_P_AUT', 'K_P_S', + 'K_PP', 'K_MAX', 'K_IPP', 'K_PHA', + 'k_mmp', 'Ksp', 'K_dis', 'K_AlOH', 'K_FeOH', + # 'kLa_min', 'f_kLa', 'K_Henry', + 'Ka', 'cmps') + + _acid_base_pairs = (('H+', 'OH-'), ('NH4+', 'NH3'), + ('CO2', 'HCO3-'), ('HCO3-', 'CO3-2'), + ('H3PO4', 'H2PO4-'), ('H2PO4-', 'HPO4-2'), ('HPO4-2', 'PO4-3'), + ('HAc', 'Ac-'),) + _precipitates = ('X_CaCO3', 'X_struv', 'X_newb', 'X_ACP', 'X_MgCO3', 'X_AlPO4', 'X_FePO4') + + gas_IDs = ['S_N2', 'S_IC'] + kLa_min = [3.0, 3.0] + K_Henry = [6.5e-4, 3.5e-2] # 20 degree C + D_gas = [1.88e-9, 1.92e-9] # diffusivity + p_gas_atm = [0.78, 3.947e-4]# partial pressure in air + + def __new__(cls, components=None, path=None, + electron_acceptor_dependent_decay=True, pH_ctrl=7.0, + f_SI=0.0, Y_H=0.625, Y_PAO=0.625, Y_PO4=0.4, Y_PHA=0.2, Y_A=0.24, + f_XI_H=0.1, f_XI_PAO=0.1, f_XI_AUT=0.1, + k_h=3.0, mu_H=6.0, mu_PAO=1.0, mu_AUT=1.0, + q_fe=3.0, q_PHA=3.0, q_PP=1.5, + b_H=0.4, b_PAO=0.2, b_PP=0.2, b_PHA=0.2, b_AUT=0.15, + eta_NO3=0.6, eta_fe=0.4, eta_NO3_H=0.8, eta_NO3_PAO=0.6, + eta_NO3_Hl=0.5, eta_NO3_PAOl=0.33, eta_NO3_PPl=0.33, eta_NO3_PHAl=0.33, eta_NO3_AUTl=0.33, + K_O2=0.2, K_O2_H=0.2, K_O2_PAO=0.2, K_O2_AUT=0.5, + K_NO3=0.5, K_NO3_H=0.5, K_NO3_PAO=0.5, K_NO3_AUT=0.5, + K_X=0.1, K_F=4.0, K_fe=4.0, K_A_H=4.0, K_A_PAO=4.0, + K_NH4_H=0.05, K_NH4_PAO=0.05, K_NH4_AUT=1.0, + K_P_H=0.01, K_P_PAO=0.01, K_P_AUT=0.01, K_P_S=0.2, + K_PP=0.01, K_MAX=0.34, K_IPP=0.02, K_PHA=0.01, + # k_mmp=(5.0, 300, 0.05, 150, 50, 1.0, 1.0), + # pKsp=(6.45, 13.16, 5.8, 23, 7, 21, 26), + # k_mmp=(0.024, 120, 0.024, 72, 0.024, 0.024, 0.024), # Flores-Alsina 2016 + # pKsp=(8.3, 13.6, 18.175, 28.92, 7.46, 18.2, 37.76), # Flores-Alsina 2016 + k_mmp=(8.4, 240, 1.0, 72, 1.0, 1.0e-5, 1.0e-5), # MATLAB + pKsp=(8.45, 13.5, 5.7, 29.1, 7.4, 18.2, 26.4), # MINTEQ (except newberyite), 20 C + K_dis=(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0), + K_AlOH=0.001, K_FeOH=0.001, + pKa=(14, 9.25, 6.37, 10.32, 2.12, 7.21, 12.32, 4.76), + **kwargs): + + + if not path: path = _mpath + + cmps = _load_components(components) + + self = Processes.load_from_file(path, + components=cmps, + conserved_for=('COD', 'C', 'N', 'P'), + parameters=cls._stoichio_params, + compile=False) + + mmp = Processes.load_from_file(_mmp, components=cmps, + conserved_for=(), compile=False) + mmp_stoichio = {} + df = load_data(_mmp) + for i, j in df.iterrows(): + j.dropna(inplace=True) + key = j.index[j == 1][0] + j = j.to_dict() + j.pop(key) + mmp_stoichio[key] = j + mol_to_mass = cmps.chem_MW / cmps.i_mass + Ksp_mass = np.array([10**(-p) for p in pKsp]) # mass in mg/L or g/m3 + i = 0 + for pd, xid in zip(mmp, cls._precipitates): + for k,v in mmp_stoichio[xid].items(): + m2m = mol_to_mass[cmps.index(k)] * 1e3 + Ksp_mass[i] *= m2m**abs(v) + i += 1 + pd._stoichiometry *= mol_to_mass + pd.ref_component = xid + + self.extend(mmp) + + # for gas in cls._gas_stripping: + # new_p = Process('%s_stripping' % gas.lstrip('S_'), + # reaction={gas:-1}, + # ref_component=gas, + # conserved_for=(),) + # self.append(new_p) + self.compile(to_class=cls) + + dct = self.__dict__ + dct.update(kwargs) + dct['K_Henry'] = [K*mol_to_mass[cmps.index(i)]*1000 for K, i in zip(cls.K_Henry, cls.gas_IDs)] + dct['mmp_stoichio'] = mmp_stoichio + stoichio_vals = (f_SI, Y_H, Y_PAO, Y_PO4, Y_PHA, Y_A, + f_XI_H, f_XI_PAO, f_XI_AUT, cmps.S_N2.i_COD-cmps.S_NO3.i_COD, + cmps.X_PP.i_K, cmps.X_PP.i_Mg) + dct['_parameters'] = dict(zip(cls._stoichio_params, stoichio_vals)) + dct['_edecay'] = bool(electron_acceptor_dependent_decay) + dct['pH_ctrl'] = pH_ctrl + if pH_ctrl: h = 10**(-pH_ctrl) + else: h = None + rhos_masm2d = lambda state_arr, params: _rhos_masm2d(state_arr, params, electron_acceptor_dependent_decay, h) + self.set_rate_function(rhos_masm2d) + Ka = np.array([10**(-p) for p in pKa]) + # f_kLa = np.array(cls.D_gas)/cls.D_O2 + kinetic_vals = (k_h, mu_H, mu_PAO, mu_AUT, + q_fe, q_PHA, q_PP, + b_H, b_PAO, b_PP, b_PHA, b_AUT, + eta_NO3, eta_fe, eta_NO3_H, eta_NO3_PAO, + eta_NO3_Hl, eta_NO3_PAOl, eta_NO3_PPl, eta_NO3_PHAl, eta_NO3_AUTl, + K_O2, K_O2_H, K_O2_PAO, K_O2_AUT, + K_NO3, K_NO3_H, K_NO3_PAO, K_NO3_AUT, + K_X, K_F, K_fe, K_A_H, K_A_PAO, + K_NH4_H, K_NH4_PAO, K_NH4_AUT, + K_P_H, K_P_PAO, K_P_AUT, K_P_S, + K_PP, K_MAX, K_IPP, K_PHA, + np.array(k_mmp), Ksp_mass, + np.array(K_dis), K_AlOH, K_FeOH, + # kLa_min, f_kLa, K_Henry, + Ka, cmps, + ) + self.rate_function._params = dict(zip(cls._kinetic_params, kinetic_vals)) + dct['solve_pH'] = solve_pH + return self + + @property + def electron_acceptor_dependent_decay(self): + '''[bool] Whether the decay rate is dependent on electron acceptor (O2, NO3-) concentrations''' + return self._edecay + @electron_acceptor_dependent_decay.setter + def electron_acceptor_dependent_decay(self, dependent): + self._edecay = edecay = bool(dependent) + rhos_masm2d = lambda state_arr, params: _rhos_masm2d(state_arr, params, edecay) + self.set_rate_function(rhos_masm2d) + + def set_parameters(self, **parameters): + stoichio = self._parameters + kinetic = self.rate_function._params + if set(parameters.keys()).intersection(set(kinetic.keys())): + for key in ('ks', 'Ks_o2', 'Ks_no3', 'Ks_nh4', 'Ks_po4', 'eta_decay'): + kinetic.pop(key, None) + for k,v in parameters.items(): + if k in self._kinetic_params: kinetic[k] = v + else: stoichio[k] = v + if self._stoichio_lambdified is not None: + self.__dict__['_stoichio_lambdified'] = None + + def set_pKsps(self, ps): + cmps = self.components + mol_to_mass = cmps.chem_MW / cmps.i_mass + idxer = cmps.index + stoichio = self.mmp_stoichio + Ksp_mass = [] # mass in mg/L or g/m3 + for xid, p in zip(self._precipitates, ps): + K = 10**(-p) + for cmp, v in stoichio[xid]: + m2m = mol_to_mass[idxer(cmp)] * 1e3 + K *= m2m**abs(v) + Ksp_mass.append(K) + self.rate_function._params['Ksp'] = np.array(Ksp_mass) diff --git a/qsdsan/sanunits/__init__.py b/qsdsan/sanunits/__init__.py index 2ea075d7..62e99095 100644 --- a/qsdsan/sanunits/__init__.py +++ b/qsdsan/sanunits/__init__.py @@ -25,17 +25,27 @@ 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 for license details. ''' - +# %% +from numba import njit +@njit(cache=True) +def dydt_cstr(QC_ins, QC, V, _dstate): + Q_ins = QC_ins[:, -1] + C_ins = QC_ins[:, :-1] + _dstate[-1] = 0 + _dstate[:-1] = (Q_ins @ C_ins - sum(Q_ins)*QC[:-1])/V + +#%% # **NOTE** PLEASE ORDER THE MODULES ALPHABETICALLY # # Units that do not rely on other units from ._abstract import * -from ._clarifier import * from ._combustion import * from ._compressor import * from ._crop_application import * @@ -45,6 +55,7 @@ from ._facilities import * from ._heat_exchanging import * from ._junction import * +from ._membrane_gas_extraction import * from ._non_reactive import * from ._pumping import * from ._reactor import * @@ -58,6 +69,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 * @@ -68,6 +80,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 * @@ -100,6 +113,7 @@ _lagoon, _membrane_bioreactor, _membrane_distillation, + _membrane_gas_extraction, _non_reactive, _polishing_filter, _pumping, @@ -119,6 +133,7 @@ _biogenic_refinery, _eco_san, _reclaimer, + _sludge_treatment, ) @@ -163,4 +178,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..14026924 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 @@ -18,10 +20,13 @@ from biosteam import Stream from .. import SanUnit from ..sanunits import HXutility, WWTpump + from ..equipments import Blower, GasPiping from ..utils import auom, calculate_excavation_volume + __all__ = ('ActivatedSludgeProcess',) +#%% _ft2_to_m2 = auom('ft2').conversion_factor('m2') F_BM_pump = 1.18*(1+0.007/100) # 0.007 is for miscellaneous costs default_F_BM = { @@ -361,6 +366,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 +730,5 @@ 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 + diff --git a/qsdsan/sanunits/_anaerobic_reactor.py b/qsdsan/sanunits/_anaerobic_reactor.py index a7d95488..496cb061 100644 --- a/qsdsan/sanunits/_anaerobic_reactor.py +++ b/qsdsan/sanunits/_anaerobic_reactor.py @@ -18,10 +18,12 @@ from math import ceil, pi from biosteam import Stream from .. import SanUnit, Construction, WasteStream -from ..processes import Decay +from ..processes import Decay, T_correction_factor from ..sanunits import HXutility, WWTpump, CSTR from ..utils import ospath, load_data, data_path, auom, \ calculate_excavation_volume, ExogenousDynamicVariable as EDV +from scipy.optimize import newton + __all__ = ( 'AnaerobicBaffledReactor', 'AnaerobicCSTR', @@ -261,13 +263,15 @@ class AnaerobicCSTR(CSTR): _ins_size_is_fixed = False _outs_size_is_fixed = False _R = 8.3145e-2 # Universal gas constant, [bar/M/K] + algebraic_h2 = False def __init__(self, ID='', ins=None, outs=(), thermo=None, init_with='WasteStream', V_liq=3400, V_gas=300, model=None, T=308.15, headspace_P=1.013, external_P=1.013, pipe_resistance=5.0e4, fixed_headspace_P=False, retain_cmps=(), fraction_retain=0.95, - isdynamic=True, exogenous_vars=(), **kwargs): + isdynamic=True, exogenous_vars=(), + pH_ctrl=None, **kwargs): if len(exogenous_vars) == 0: exogenous_vars = (EDV('T', function=lambda t: T), ) super().__init__(ID=ID, ins=ins, outs=outs, thermo=thermo, @@ -290,8 +294,9 @@ def __init__(self, ID='', ins=None, outs=(), thermo=None, self.fixed_headspace_P = fixed_headspace_P self._f_retain = np.array([fraction_retain if cmp.ID in retain_cmps \ else 0 for cmp in self.components]) + self.pH_ctrl = pH_ctrl self._mixed = WasteStream() - self._tempstate = [] + self._tempstate = {} def ideal_gas_law(self, p=None, S=None): '''Calculates partial pressure [bar] given concentration [M] at @@ -339,13 +344,13 @@ def model(self, model): if model is not None: #!!! how to make unit conversion generalizable to all models? self._S_vapor = self.ideal_gas_law(p=self.p_vapor()) - self._n_gas = len(model._biogas_IDs) - self._state_keys = list(self.components.IDs) \ + self._n_gas = ng = len(model._biogas_IDs) + self._state_keys = keys = list(self.components.IDs) \ + [ID+'_gas' for ID in self.model._biogas_IDs] \ + ['Q'] self._gas_cmp_idx = self.components.indices(self.model._biogas_IDs) - self._state_header = self._state_keys - + units = ['kg/m3']*len(self.components) + ['M']*ng + ['m3/d'] + self._state_header = [f'{name} [{unit}]' for name, unit in zip(keys, units)] split = property(CSTR.split.fget) @split.setter @@ -439,17 +444,23 @@ def _init_state(self): #!!! how to make unit conversion generalizable to all models? if self._concs is not None: Cs = self._concs * 1e-3 # mg/L to kg/m3 else: Cs = mixed.conc * 1e-3 # mg/L to kg/m3 - self._state = np.append(Cs, [0]*self._n_gas + [Q]).astype('float64') + Gs = [0]*self._n_gas # initial gas phase concentrations [M] + Gs[0] = 0.041*0.01 + Gs[1] = 0.041*0.57 + Gs[2] = 0.041*0.4 + self._state = np.append(Cs, Gs + [Q]).astype('float64') self._dstate = self._state * 0. def _update_state(self): y = self._state y[-1] = sum(ws.state[-1] for ws in self.ins) + y[y<1e-16] = 0. f_rtn = self._f_retain i_mass = self.components.i_mass chem_MW = self.components.chem_MW n_cmps = len(self.components) Cs = y[:n_cmps]*(1-f_rtn)*1e3 # kg/m3 to mg/L + pH = self.pH_ctrl or self._tempstate.pop('pH', 7) if self.split is None: gas, liquid = self._outs if liquid.state is None: @@ -457,6 +468,7 @@ def _update_state(self): else: liquid.state[:n_cmps] = Cs liquid.state[-1] = y[-1] + liquid._pH = pH else: gas = self._outs[0] liquids = self._outs[1:] @@ -465,7 +477,8 @@ def _update_state(self): liquid.state = np.append(Cs, y[-1]*spl) else: liquid.state[:n_cmps] = Cs - liquid.state[-1] = y[-1]*spl + liquid.state[-1] = y[-1]*spl + liquid._pH = pH if gas.state is None: gas.state = np.zeros(n_cmps+1) gas.state[self._gas_cmp_idx] = y[n_cmps:(n_cmps + self._n_gas)] @@ -474,7 +487,7 @@ def _update_state(self): gas.state[:n_cmps] = gas.state[:n_cmps] * chem_MW / i_mass * 1e3 # i.e., M biogas to mg (measured_unit) / L def _update_dstate(self): - # self._tempstate = self.model.rate_function._params['root'].data.copy() + self._tempstate = self.model.rate_function._params['root'].data.copy() dy = self._dstate f_rtn = self._f_retain n_cmps = len(self.components) @@ -516,18 +529,25 @@ def f_q_gas_var_P_headspace(self, rhoTs, S_gas, T): @property def ODE(self): if self._ODE is None: - self._compile_ODE() + self._compile_ODE(self.algebraic_h2, self.pH_ctrl) return self._ODE - def _compile_ODE(self): + def _compile_ODE(self, algebraic_h2=True, pH_ctrl=None): if self._model is None: CSTR._compile_ODE(self) else: cmps = self.components f_rtn = self._f_retain + _state = self._state _dstate = self._dstate _update_dstate = self._update_dstate - _f_rhos = self.model.rate_function + h = None + if pH_ctrl: + _params = self.model.rate_function.params + h = 10**(-pH_ctrl) + _f_rhos = lambda state_arr: self.model.flex_rhos(state_arr, _params, h=h) + else: + _f_rhos = self.model.rate_function _f_param = self.model.params_eval _M_stoichio = self.model.stoichio_eval n_cmps = len(cmps) @@ -541,7 +561,6 @@ def _compile_ODE(self): f_qgas = self.f_q_gas_fixed_P_headspace else: f_qgas = self.f_q_gas_var_P_headspace - if self.model._dyn_params: def M_stoichio(state_arr): _f_param(state_arr) @@ -549,20 +568,52 @@ def M_stoichio(state_arr): else: _M_stoichio = self.model.stoichio_eval().T M_stoichio = lambda state_arr: _M_stoichio + + h2_idx = cmps.index('S_h2') + if algebraic_h2: + params = self.model.rate_function.params + if self.model._dyn_params: + def h2_stoichio(state_arr): + return M_stoichio(state_arr)[h2_idx] + else: + _h2_stoichio = _M_stoichio[h2_idx] + h2_stoichio = lambda state_arr: _h2_stoichio + unit_conversion = cmps.i_mass / cmps.chem_MW + solve_pH = self.model.solve_pH + dydt_Sh2_AD = self.model.dydt_Sh2_AD + grad_dydt_Sh2_AD = self.model.grad_dydt_Sh2_AD + def solve_h2(QC, S_in, T, h=h): + Ka = params['Ka_base'] * T_correction_factor(params['T_base'], T, params['Ka_dH']) + if h == None: h = solve_pH(QC, Ka, unit_conversion) + # S_h2_0 = QC[h2_idx] + S_h2_0 = 2.8309E-07 + S_h2_in = S_in[h2_idx] + S_h2 = newton( + dydt_Sh2_AD, S_h2_0, grad_dydt_Sh2_AD, + args=(QC, h, params, h2_stoichio, V_liq, S_h2_in), + ) + return S_h2 + def update_h2_dstate(dstate): + dstate[h2_idx] = 0. + else: + solve_h2 = lambda QC, S_ins, T: QC[h2_idx] + def update_h2_dstate(dstate): + pass def dy_dt(t, QC_ins, QC, dQC_ins): - S_liq = QC[:n_cmps] - S_gas = QC[n_cmps: (n_cmps+n_gas)] - #!!! Volume change due to temperature difference accounted for - # in _run and _init_state + # QC[QC < 0] = 0. Q_ins = QC_ins[:, -1] S_ins = QC_ins[:, :-1] * 1e-3 # mg/L to kg/m3 Q = sum(Q_ins) + S_in = Q_ins @ S_ins / Q if hasexo: exo_vars = f_exovars(t) QC = np.append(QC, exo_vars) T = exo_vars[0] else: T = self.T + QC[h2_idx] = _state[h2_idx] = solve_h2(QC, S_in, T) rhos =_f_rhos(QC) + S_liq = QC[:n_cmps] + S_gas = QC[n_cmps: (n_cmps+n_gas)] _dstate[:n_cmps] = (Q_ins @ S_ins - Q*S_liq*(1-f_rtn))/V_liq \ + np.dot(M_stoichio(QC), rhos) q_gas = f_qgas(rhos[-3:], S_gas, T) @@ -570,7 +621,9 @@ def dy_dt(t, QC_ins, QC, dQC_ins): + rhos[-3:] * V_liq/V_gas * gas_mass2mol_conversion # _dstate[-1] = dQC_ins[0,-1] _dstate[-1] = 0. + update_h2_dstate(_dstate) _update_dstate() + self._ODE = dy_dt def get_retained_mass(self, biomass_IDs): diff --git a/qsdsan/sanunits/_clarifier.py b/qsdsan/sanunits/_clarifier.py index 0f7ca3fe..586b80ad 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,13 +16,46 @@ ''' from numpy import maximum as npmax, minimum as npmin, exp as npexp +from math import sqrt, pi +from warnings import warn +from numba import njit from .. import SanUnit, WasteStream import numpy as np +from ..sanunits import WWTpump +from ..sanunits._pumping import default_F_BM as default_WWTpump_F_BM +from ..sanunits import dydt_cstr __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 +@njit(cache=True) 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))) @@ -74,30 +109,65 @@ 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. - + maximum_nonsettleable_solids : float, optional + Maximum non-settleable solids concentration, in mgTSS/L. The default is None. + 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): - - SanUnit.__init__(self, ID, ins, outs, thermo, init_with, isdynamic=isdynamic) + rh=5.76e-4, rp=2.86e-3, fns=2.28e-3, + maximum_nonsettleable_solids=None, + 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, 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 @@ -107,15 +177,35 @@ def __init__(self, ID='', ins=None, outs=(), thermo=None, self._rh = rh self._rp = rp self._fns = fns + self.maximum_nonsettleable_solids = maximum_nonsettleable_solids self._solids = 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 +331,27 @@ 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 maximum_nonsettleable_solids(self): + '''[float] Maximum non-settleable solids concentration, in mgTSS/L.''' + return self._max_ns + @maximum_nonsettleable_solids.setter + def maximum_nonsettleable_solids(self, ns): + self._max_ns = ns + + @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)) @@ -286,7 +396,9 @@ def _update_state(self): arr = self._state x = self.components.x n = self._N_layer - Q_e = arr[-(1+n)] - self._Qras - self._Qwas + arr[-(1+n)] = Q_in = self._ins_QC[0, -1] + Q_e = Q_in - self._Qras - self._Qwas + # Q_e = arr[-(1+n)] - self._Qras - self._Qwas Z = arr[:len(x)] inf, = self.ins imass = self.components.i_mass @@ -295,20 +407,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 +436,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): @@ -370,6 +482,7 @@ def _compile_ODE(self): m = len(x) imass = self.components.i_mass fns = self._fns + max_ns = self._max_ns or 1e6 Q_s = self._Qras + self._Qwas dQC = self._dstate @@ -384,22 +497,28 @@ def _compile_ODE(self): settle_in = nzeros.copy() # Make these constants into arrays so it'll be faster in `dy_dt` - vmax_arr = np.full_like(nzeros, self._v_max) - vmaxp_arr = np.full_like(nzeros, self._v_max_p) - rh_arr = np.full_like(nzeros, self._rh) - rp_arr = np.full_like(nzeros, self._rp) + # vmax_arr = np.full_like(nzeros, self._v_max) + # vmaxp_arr = np.full_like(nzeros, self._v_max_p) + # rh_arr = np.full_like(nzeros, self._rh) + # rp_arr = np.full_like(nzeros, self._rp) + vmax_arr = self._v_max + vmaxp_arr = self._v_max_p + rh_arr = self._rh + rp_arr = 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) + # A_arr = np.full_like(nzeros, A) + # hj_arr = np.full_like(nzeros, hj) J = np.zeros(n-1) - X_t_arr = np.full(jf, self._X_t) - Q_in_arr = np.zeros(m) - V_arr = np.full(m, V) + # X_t_arr = np.full(jf, self._X_t) + X_t = self._X_t + # Q_in_arr = np.zeros(m) + # V_arr = np.full(m, V) def dy_dt(t, QC_ins, QC, dQC_ins): - dQC[-(n+1)] = dQC_ins[0,-1] + # dQC[-(n+1)] = dQC_ins[0,-1] + dQC[-(n+1)] = 0. Q_in = QC_ins[0,-1] Q_e = Q_in - Q_s C_in = QC_ins[0,:-1] @@ -407,7 +526,7 @@ def dy_dt(t, QC_ins, QC, dQC_ins): Z_in = C_in*(1-x) X_in = sum(C_in*imass*x) # influent TSS dX_in = sum(dC_in*imass*x) - X_min_arr[:] = X_in * fns + X_min_arr[:] = min(X_in * fns, max_ns) X = QC[-n:] # (n, ), TSS for each layer Z = QC[:m] * (1-x) #***********TSS************* @@ -421,31 +540,299 @@ def dy_dt(t, QC_ins, QC, dQC_ins): flow_in = X_rolled * Q_jout VX = func_vx(X, X_min_arr) J[:] = npmin(VX[:-1], VX[1:]) - condition = (X_rolled[:jf] 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): + """ + Ideal clarifier that settles suspended solids by specified efficiency. Has + no design or costing algorithm. Governing equations are + + .. math:: + Q_i X_i = Q_e X_e + Q_s X_s + + Q_i = Q_e + Q_s + + X_e = X_i * (1-e_rmv) + + where subscripts 'i', 'e', 's' represent influent, overflow effluent, and + underflow sludge, respectively. 'Q' indicates volumetric flowrate and 'X' + indicates suspended solids concentration. - def _design(self): - pass - + Parameters + ---------- + sludge_flow_rate : float, optional + Underflow sludge flowrate [m3/d]. The default is 2000. + solids_removal_efficiency : float, optional + Removal efficiency (concentration basis) of suspended solids, unitless. + The default is 0.995. + sludge_MLSS : float, optional + Underflow MLSS [mg/L]. Used only when either `solids_removal_efficiency` + or `sludge_flow_rate` is unspecified. The default is None. -class IdealClarifier(SanUnit): + """ _N_ins = 1 - _N_outs = 2 + _N_outs = 2 # [0] effluent overflow, [1] sludge underflow + _outs_size_is_fixed = True def __init__(self, ID='', ins=None, outs=(), thermo=None, - sludge_flow_rate=2000, solids_removal_efficiency=.995, + sludge_flow_rate=2000, solids_removal_efficiency=0.995, sludge_MLSS=None, isdynamic=False, init_with='WasteStream', F_BM_default=None, **kwargs): @@ -454,6 +841,9 @@ def __init__(self, ID='', ins=None, outs=(), thermo=None, self.sludge_flow_rate = sludge_flow_rate self.solids_removal_efficiency = solids_removal_efficiency self.sludge_MLSS = sludge_MLSS + self._mixed = WasteStream() + self._f_uf = None + self._f_of = None @property def sludge_flow_rate(self): @@ -462,9 +852,7 @@ def sludge_flow_rate(self): @sludge_flow_rate.setter def sludge_flow_rate(self, Qs): - if Qs is not None: self._Qs = Qs - elif self.ins[0].isempty(): self._Qs = None - else: self._Qs = self._calc_Qs() + self._Qs = Qs @property def solids_removal_efficiency(self): @@ -472,12 +860,9 @@ def solids_removal_efficiency(self): @solids_removal_efficiency.setter def solids_removal_efficiency(self, f): - if f is not None: - if f > 1 or f < 0: - raise ValueError(f'solids removal efficiency must be within [0, 1], not {f}') - self._e_rmv = f - elif self.ins[0].isempty(): self._e_rmv = None - else: self._e_rmv = self._calc_ermv() + if f is not None and (f > 1 or f < 0): + raise ValueError(f'solids removal efficiency must be within [0, 1], not {f}') + self._e_rmv = f @property def sludge_MLSS(self): @@ -485,55 +870,665 @@ def sludge_MLSS(self): @sludge_MLSS.setter def sludge_MLSS(self, MLSS): - if MLSS is not None: self._MLSS = MLSS - elif self.ins[0].isempty(): self._MLSS = None - else: self._MLSS = self._calc_SS()[1] - - def _calc_Qs(self, TSS_in=None, Q_in=None): - if Q_in is None: Q_in = self.ins[0].get_total_flow('m3/d') - if TSS_in is None: TSS_in = self.ins[0].get_TSS() - return Q_in*TSS_in*self._e_rmv/(self._MLSS-TSS_in) - - def _calc_ermv(self, TSS_in=None, Q_in=None): - if Q_in is None: Q_in = self.ins[0].get_total_flow('m3/d') - if TSS_in is None: TSS_in = self.ins[0].get_TSS() - return self._Qs*(self._MLSS-TSS_in)/TSS_in/(Q_in-self._Qs) - - def _calc_SS(self, SS_in=None, Q_in=None): - if Q_in is None: Q_in = self.ins[0].get_total_flow('m3/d') - if SS_in is None: SS_in = self.ins[0].get_TSS() - SS_e = (1-self._e_rmv)*SS_in - Qs = self._Qs - Qe = Q_in - Qs - return SS_e, (Q_in*SS_in - Qe*SS_e)/Qs + if MLSS is not None: + warn(f'sludge MLSS {MLSS} mg/L is only used to estimate ' + f'sludge flowrate or solids removal efficiency, when either ' + f'one of them is unspecified.') + self._MLSS = MLSS def _run(self): - inf, = self.ins - eff, sludge = self.outs - cmps = self.components - Q_in = inf.get_total_flow('m3/d') - TSS_in = (inf.conc*cmps.x*cmps.i_mass).sum() - params = (Qs, e_rmv, MLSS) = self._Qs, self._e_rmv, self._MLSS - if sum([i is None for i in params]) > 1: - raise RuntimeError('must specify two of the following parameters: ' - 'sludge_flow_rate, solids_removal_efficiency, sludge_MLSS') - if Qs is None: - Qs = self._calc_Qs(TSS_in, Q_in) - Xs = MLSS / TSS_in * inf.conc * cmps.x - Xe = (1-e_rmv) * inf.conc * cmps.x - elif e_rmv is None: - e_rmv = self._calc_ermv(TSS_in, Q_in) - Xs = MLSS / TSS_in * inf.conc * cmps.x - Xe = (1-e_rmv) * inf.conc * cmps.x + inf = self._mixed + inf.mix_from(self.ins) + of, uf = self.outs + TSS_in = inf.get_TSS() + if TSS_in <= 0: + uf.empty() + of.copy_like(inf) else: - Xe, Xs = self._calc_SS(inf.conc * cmps.x, Q_in) - Zs = Ze = inf.conc * (1-cmps.x) - Ce = dict(zip(cmps.IDs, Ze+Xe)) - Cs = dict(zip(cmps.IDs, Zs+Xs)) - Ce.pop('H2O', None) - Cs.pop('H2O', None) - eff.set_flow_by_concentration(Q_in-Qs, Ce, units=('m3/d', 'mg/L')) - sludge.set_flow_by_concentration(Qs, Cs, units=('m3/d', 'mg/L')) - - def _design(self): - pass \ No newline at end of file + Q_in = inf.F_vol * 24 # m3/d + x = inf.components.x + Qs, e_rmv, mlss = self._Qs, self._e_rmv, self._MLSS + if Qs and e_rmv: + f_Qu = Qs/Q_in + f_Xu = e_rmv + (1-e_rmv) * f_Qu + elif Qs and mlss: + f_Qu = Qs/Q_in + f_Xu = f_Qu*mlss/TSS_in + elif e_rmv and mlss: + f_Qu = e_rmv / (mlss/TSS_in - (1-e_rmv)) + f_Xu = e_rmv + (1-e_rmv) * f_Qu + split_to_uf = (1-x)*f_Qu + x*f_Xu + if any(split_to_uf > 1): split_to_uf = 1 + inf.split_to(uf, of, split_to_uf) + + def _init_state(self): + inf = self._mixed + C_in = inf.conc + Q_in = inf.F_vol * 24 + self._state = np.append(C_in, Q_in) + self._dstate = self._state * 0. + + def _update_state(self): + arr = self._state + Cs = arr[:-1] + Qi = arr[-1] + Qs, e_rmv, mlss = self._Qs, self._e_rmv, self._MLSS + x = self.components.x + i_tss = x * self.components.i_mass + + of, uf = self.outs + if uf.state is None: uf.state = np.zeros(len(x)+1) + if of.state is None: of.state = np.zeros(len(x)+1) + + if Qs: + Qe = Qi - Qs + if e_rmv: + fuf = e_rmv * Qi/Qs + (1-e_rmv) + fof = 1-e_rmv + elif mlss: + tss_in = sum(Cs * i_tss) + tss_e = (Qi * tss_in - Qs * mlss)/Qe + fuf = mlss/tss_in + fof = tss_e/tss_in + elif e_rmv and mlss: + tss_in = sum(Cs * i_tss) + Qs = Qi * e_rmv / (mlss/tss_in - (1-e_rmv)) + Qe = Qi - Qs + fuf = mlss/tss_in + fof = 1-e_rmv + else: + raise RuntimeError('missing parameter') + + if Qs >= Qi: + uf.state[:] = arr + of.state[:] = 0. + else: + self._f_uf = fuf + self._f_of = fof + uf.state[:-1] = Cs * ((1-x) + x*fuf) + uf.state[-1] = Qs + of.state[:-1] = Cs * ((1-x) + x*fof) + of.state[-1] = Qe + + def _update_dstate(self): + of, uf = self.outs + x = self.components.x + if uf.dstate is None: uf.dstate = np.zeros(len(x)+1) + if of.dstate is None: of.dstate = np.zeros(len(x)+1) + + @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): + 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 + + +# %% +# 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 the Otterpohl model [1] in BSM2 [2]. + + 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). + volume : float, optional + Clarifier volume, in m^3. The default is 900. + ratio_uf : float + The volumetric ratio of sludge to primary influent. The default is 0.007, + based on IWA report.[2] + mean_f_x : float, optional + The average fraction of particulate COD out of total COD in primary influent. + The default is 0.85. + f_corr : float + Dimensionless correction factor for removal efficiency in the primary clarifier.[2] + + 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'), + ... isdynamic=False) + >>> PC.simulate() + >>> of, uf = PC.outs + >>> uf.imass['X_OHO']/ws.imass['X_OHO'] # doctest: +ELLIPSIS + 0.598... + >>> PC.show() + PrimaryClarifierBSM2: PC + 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 + Alkalinity : 2.5 mg/L + 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 + TSS : 11124.4 mg/L + outs... + [0] eff + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 9.93e+03 + S_NH4 1.99e+04 + X_OHO 6.03e+03 + H2O 9.93e+05 + WasteStream-specific properties: + pH : 7.0 + COD : 15436.7 mg/L + BOD : 10190.8 mg/L + TC : 5208.2 mg/L + TOC : 5208.2 mg/L + TN : 19890.2 mg/L + TP : 206.9 mg/L + TK : 27.8 mg/L + TSS : 4531.6 mg/L + [1] sludge + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 70 + S_NH4 140 + X_OHO 8.97e+03 + H2O 7e+03 + WasteStream-specific properties: + pH : 7.0 + COD : 693717.8 mg/L + BOD : 393895.8 mg/L + TC : 253653.5 mg/L + TOC : 253653.5 mg/L + TN : 57923.7 mg/L + TP : 13132.3 mg/L + TK : 3282.0 mg/L + TSS : 534594.2 mg/L + + References + ---------- + [1] 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. + [2] Gernaey, Krist V., Ulf Jeppsson, Peter A. Vanrolleghem, and John B. Copp. + Benchmarking of control strategies for wastewater treatment plants. IWA publishing, 2014. + """ + + _N_ins = 3 + _N_outs = 2 # [0] effluent; [1] underflow + _ins_size_is_fixed = False + + 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, + F_BM=default_F_BM, **kwargs): + SanUnit.__init__(self, ID, ins, outs, thermo, isdynamic=isdynamic, + init_with=init_with) + 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.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 + + @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 + + @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): + dydt_cstr(QC_ins, QC, V, _dstate) + _dstate[-1] = (sum(QC_ins[:,-1])-QC[-1])/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(IdealClarifier): + + """ + Primary clarifier with an ideal settling process model. + + Parameters + ---------- + surface_overflow_rate : float + Surface overflow rate in the clarifier in [(m3/day)/m2]. [1] + 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 [1], [2]. + 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]. [3] + The default is 36 m/hr. (10 mm/sec) + F_BM : dict + Equipment bare modules. + + Examples + -------- + >>> from qsdsan import set_thermo, Components, WasteStream, System + >>> 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 PrimaryClarifier + >>> PC = PrimaryClarifier(ID='PC', ins=ws, outs=('effluent', 'sludge'), + ... solids_removal_efficiency=0.6, + ... sludge_flow_rate=ws.F_vol*24*0.3, + ... isdynamic=True) + >>> sys = System('sys', path=(PC,)) + >>> sys.simulate(t_span=(0,10), method='BDF') # doctest: +ELLIPSIS + >>> PC.show() # doctest: +ELLIPSIS + PrimaryClarifier: PC + 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 + Alkalinity : 2.5 mg/L + 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 + TSS : 11124.4 mg/L + outs... + [0] effluent + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 7e+03 + S_NH4 1.4e+04 + X_OHO 4.2e+03 + H2O 7.04e+05 + WasteStream-specific properties: + pH : 7.0 + COD : 15278.7 mg/L + BOD : 10093.4 mg/L + TC : 5152.8 mg/L + TOC : 5152.8 mg/L + TN : 19776.2 mg/L + TP : 204.4 mg/L + TK : 27.3 mg/L + TSS : 4449.8 mg/L + [1] sludge + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_F 3e+03 + S_NH4 6e+03 + X_OHO 1.08e+04 + H2O 2.96e+05 + WasteStream-specific properties: + pH : 7.0 + COD : 43926.4 mg/L + BOD : 26326.2 mg/L + TC : 15637.8 mg/L + TOC : 15637.8 mg/L + TN : 21732.9 mg/L + TP : 748.7 mg/L + TK : 163.9 mg/L + TSS : 26698.6 mg/L + + References + ---------- + [1] Chapter-10: Primary Treatment. Design of water resource recovery facilities. + WEF Manual of Practice No. 8. 6th Edition. Virginia: McGraw-Hill, 2018. + [2] Metcalf, Leonard, Harrison P. Eddy, and Georg Tchobanoglous. Wastewater + engineering: treatment, disposal, and reuse. Vol. 4. New York: McGraw-Hill, 1991. + [3] Introduction to Wastewater Clarifier Design by Nikolay Voutchkov, PE, BCEE. + """ + + _N_ins = 1 + _N_outs = 2 # [0] overflow effluent [1] underflow sludge + _ins_size_is_fixed = False + _outs_size_is_fixed = True + + peak_flow_safety_factor = 2.5 # assumed based on average and maximum velocities + + # 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 + + def __init__(self, ID='', ins=None, outs=(), + sludge_flow_rate=2000, solids_removal_efficiency=0.6, + sludge_MLSS=None, thermo=None, isdynamic=False, + init_with='WasteStream', + surface_overflow_rate=41, depth_clarifier=4.5, + downward_flow_velocity=36, F_BM=default_F_BM, **kwargs): + super().__init__(ID, ins, outs, thermo, + sludge_flow_rate=sludge_flow_rate, + solids_removal_efficiency=solids_removal_efficiency, + sludge_MLSS=sludge_MLSS, + isdynamic=isdynamic, + init_with=init_with) + + 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._sludge = uf = WasteStream(f'{ID}_sludge') + pump_id = f'{ID}_sludge_pump' + self.sludge_pump = WWTpump( + ID=pump_id, ins=uf, thermo=thermo, pump_type='sludge', + prefix='Sludge', + include_pump_cost=True, + include_building_cost=False, + include_OM_cost=True, + ) + + # self.auxiliary_unit_names = tuple({*self.auxiliary_unit_names, pump_id}) + + # @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 _design_pump(self): + D = self.design_results + N = D['Number of pumps'] + pump = self.sludge_pump + self._sludge.copy_like(self.outs[1]) + self._sludge.scale(1/N) + pump.simulate() + D.update(pump.design_results) + + _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' + } + + density_ss = 7930 # kg/m3, 18/8 Chromium + + def _design(self): + mixed = self._mixed + mixed.mix_from(self.ins) + D = self.design_results + + # Number of clarifiers based on tentative suggestions by Jeremy + # (would be verified through collaboration with industry) + Q_mgd = mixed.get_total_flow('MGD') + if Q_mgd <= 3: N = 2 + elif Q_mgd <= 8: N = 3 + elif Q_mgd <= 20: N = 4 + else: N = 3 + int(Q_mgd / 20) + D['Number of clarifiers'] = D['Number of pumps'] = N + + SOR = D['SOR'] = self.surface_overflow_rate # in (m3/day)/m2 + Q = D['Volumetric flow'] = mixed.get_total_flow('m3/d')/N # m3/day + A = D['Surface area'] = Q/SOR # in m2 + dia = D['Cylindrical diameter'] = sqrt(4*A/pi) #in m + + # Check on cylindrical diameter d [2, 3] + if dia < 3 or dia > 60: + warn(f'Cylindrical diameter = {dia:.2f} is not between 3 m and 60 m') + + rad = D['Conical radius'] = dia/2 + # The slope of the bottom conical floor lies between 1:10 to 1:12 [3, 4] + h_cone = D['Conical height'] = rad/12 + h = D['Clarifier depth'] = self.depth_clarifier # in m + h_cyl = D['Cylindrical height'] = h - h_cone + + # Check on cylindrical and conical depths + if h_cyl < h_cone: + warn(f'Cylindrical highet = {h_cyl} is lower than conical height = {h_cone}') + + V_cyl = D['Cylindrical volume'] = A * h_cyl # in m3 + V_cone = D['Conical volume'] = A * h_cone / 3 # in m3 + V = D['Volume'] = V_cyl + V_cone # in m3 + + HRT = D['Hydraulic Retention Time'] = V/(Q/24) # in hrs + + # Check on cylinderical HRT [3] + if HRT < 1.5 or HRT > 2.5: + 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] + h_cf = D['Center feed depth'] = 0.5*h_cyl + # 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] + v_down = D['Downward flow velocity'] = self.downward_flow_velocity*self.peak_flow_safety_factor # in m/hr + + A_cf = (Q/24)/v_down # in m2 + + dia_cf = D['Center feed diameter'] = sqrt(4*A_cf/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 dia_cf < 0.10*dia or dia_cf > 0.25*dia: + warn(f'Diameter of the center feed does not lie between 15-25% of tank diameter. It is {dia_cf*100/dia:.2f}% of tank diameter') + + # Amount of concrete required + # D_tank = D['Cylindrical depth']*39.37 # m to inches + h_ft = h*3.2808398950131235 # m to feet + # 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 + d_wall = (1 + max(h_ft-12, 0)/12) * 0.3048 # feet to m + OD = dia + 2*d_wall + D['Volume of concrete wall'] = pi*h_cyl/4*(OD**2 - dia**2) # m3 + + # Concrete slab thickness, [ft], default to be 2 in thicker than the wall thickness. (Brian's code) + d_slab = d_wall + (2/12)*0.3048 # from inch to m + # outer_diameter_cone = inner_diameter + 2*(thickness_concrete_wall + thickness_concrete_slab) + OD_cone = dia + 2*d_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 + D['Volume of concrete slab'] = pi/3*((h_cone + d_slab)*(OD_cone/2)**2 - h_cone*(dia/2)**2) + + # Amount of metal required for center feed + #!!! consider empirical estimation of steel volume for all equipment (besides center feed, e.g., scrapper, support column, EDI, skimmer, walkway etc.) + d_wall_cf = 0.3048 # equal to 1 feet, in m (!! NEED A RELIABLE SOURCE !!) + OD_cf = dia_cf + 2*d_wall_cf + volume_center_feed = (pi*h_cf/4)*(OD_cf**2 - dia_cf**2) + D['Stainless steel'] = volume_center_feed*self.density_ss # in kg + + # Pumps + self._design_pump() + + def _cost(self): + D = self.design_results + C = self.baseline_purchase_costs + N = D['Number of clarifiers'] + + # Construction of concrete and stainless steel walls + C['Wall concrete'] = N*D['Volume of concrete wall']*self.wall_concrete_unit_cost + C['Slab concrete'] = N*D['Volume of concrete slab']*self.slab_concrete_unit_cost + C['Wall stainless steel'] = N*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 !!!) + Q = 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'] = N*base_cost_v_notch_weir*(Q/base_flow_v_notch_weir)**0.6 + + # Pump (construction and maintainance) + pump = self.sludge_pump + add_OPEX = self.add_OPEX + add_OPEX.update({k: v*N for k,v in pump.add_OPEX.items()}) + C.update({k: v*N for k,v in pump.baseline_purchase_costs.items()}) + self.power_utility.rate += pump.power_utility.rate*N + # self.power_utility.rate += scraper_power diff --git a/qsdsan/sanunits/_heat_exchanging.py b/qsdsan/sanunits/_heat_exchanging.py index 2c707d1e..d5beeaf4 100644 --- a/qsdsan/sanunits/_heat_exchanging.py +++ b/qsdsan/sanunits/_heat_exchanging.py @@ -220,6 +220,7 @@ def __init__( inner_fluid_pressure_drop=None, outer_fluid_pressure_drop=None, neglect_pressure_drop=True, + furnace_pressure=None, # [Pa] equivalent to 500 psig ): SanUnit.__init__(self, ID, ins, outs, thermo, init_with=init_with, F_BM_default=F_BM_default, @@ -263,6 +264,10 @@ def __init__( #: If value is None, it defaults to the heat transfer efficiency of the #: heat utility. self.heat_transfer_efficiency = heat_transfer_efficiency + + #: Optional[float] Internal pressure of combustion gas. Defaults + #: 500 psig (equivalent to 3548325.0 Pa) + self.furnace_pressure = 500 if furnace_pressure is None else furnace_pressure def _design(self, duty=None): HXU._design(self) diff --git a/qsdsan/sanunits/_junction.py b/qsdsan/sanunits/_junction.py index 66eda081..fa66af31 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 @@ -17,14 +19,24 @@ from warnings import warn from math import isclose from biosteam.units import Junction as BSTjunction -from .. import SanUnit, processes as pc +from .. import Stream, SanUnit, processes as pc __all__ = ( 'Junction', - 'ADMjunction', 'ADMtoASM', 'ASMtoADM', - ) + 'ADMjunction', + 'mADMjunction', + 'ADMtoASM', + 'ASMtoADM', + 'ASM2dtoADM1', + 'ADM1toASM2d', + 'ASM2dtomADM1', + 'mADM1toASM2d', + 'A1junction', + 'ADM1ptomASM2d', + 'mASM2dtoADM1p' + ) -#%% +#%% Junction class Junction(SanUnit): ''' A non-reactive class that serves to convert a stream with one set of components @@ -54,7 +66,7 @@ class Junction(SanUnit): 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 + thermo = downstream.thermo if isinstance(downstream, Stream) else thermo SanUnit.__init__(self, ID, ins=upstream, outs=downstream, thermo=thermo, init_with=init_with, F_BM_default=F_BM_default, isdynamic=isdynamic, @@ -233,7 +245,7 @@ def reactions(self, i): self._compile_reactions() -# %% +#%% ADMjunction #TODO: add a `rtol` kwargs for error checking class ADMjunction(Junction): @@ -252,6 +264,8 @@ class ADMjunction(Junction): _parse_reactions = Junction._no_parse_reactions rtol = 1e-2 atol = 1e-6 + # Should be constants + cod_vfa = np.array([64, 112, 160, 208]) def __init__(self, ID='', upstream=None, downstream=(), thermo=None, init_with='WasteStream', F_BM_default=None, isdynamic=False, @@ -264,20 +278,6 @@ def __init__(self, ID='', upstream=None, downstream=(), thermo=None, 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): @@ -287,7 +287,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 @@ -313,7 +313,7 @@ def pKa(self): ('H+', 'OH-'), ('NH4+', 'NH3'), ('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))) + return self.pKa_base-np.log10(pc.T_correction_factor(self.T_base, self.T, self.Ka_dH)) @property def alpha_IC(self): @@ -329,7 +329,17 @@ def alpha_IN(self): pKa_IN = self.pKa[1] return 10**(pKa_IN-pH)/(1+10**(pKa_IN-pH))/14 - + @property + def alpha_vfa(self): + '''[float] charge per g of VFA-COD.''' + return 1.0/self.cod_vfa*(-1.0/(1.0 + 10**(self.pKa[3:]-self.pH))) + + 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 _compile_AE(self): _state = self._state _dstate = self._dstate @@ -350,9 +360,132 @@ def yt(t, QC_ins, dQC_ins): _update_dstate() self._AE = yt + +#%% mADMjunction +class mADMjunction(ADMjunction): + ''' + An abstract superclass holding common properties of modified ADM interface classes. + Users should use its subclasses (e.g., ``ASM2dtomADM1``, ``mADM1toASM2d``) instead. + + See Also + -------- + :class:`qsdsan.sanunits.ADMJunction` + + :class:`qsdsan.sanunits.mADM1toASM2d` + + :class:`qsdsan.sanunits.ASM2dtomADM1` + ''' + _parse_reactions = Junction._no_parse_reactions + rtol = 1e-2 + atol = 1e-6 + cod_vfa = np.array([64, 112, 160, 208]) + + def __init__(self, ID='', upstream=None, downstream=(), thermo=None, + init_with='WasteStream', F_BM_default=None, isdynamic=False, + adm1_model=None, asm2d_model=None): + self.asm2d_model = asm2d_model + super().__init__(ID=ID, upstream=upstream, downstream=downstream, + thermo=thermo, init_with=init_with, + F_BM_default=F_BM_default, isdynamic=isdynamic, + adm1_model=adm1_model) + + @property + def asm2d_model(self): + '''[qsdsan.CompiledProcesses] ASM2d process model.''' + return self._asm2d_model + @asm2d_model.setter + def asm2d_model(self, model): + if not isinstance(model, (pc.ASM2d, pc.mASM2d)): + raise ValueError('`asm2d_model` must be an `ASM2d` object, ' + f'the given object is {type(model).__name__}.') + self._asm2d_model = model + + @property + def adm1_model(self): + '''[qsdsan.CompiledProcesses] mADM1 process model.''' + return self._adm1_model + @adm1_model.setter + def adm1_model(self, model): + if not isinstance(model, (pc.ADM1_p_extension, pc.ADM1p)): + raise ValueError('`adm1_model` must be an `ADM1_p_extension` object, ' #!!! update error message + f'the given object is {type(model).__name__}.') + self._adm1_model = model + + @property + def pKa(self): + ''' + [numpy.array] pKa array of the following acid-base pairs: + ('H+', 'OH-'), ('NH4+', 'NH3'), ('H2PO4-', 'HPO4-2'), ('CO2', 'HCO3-'), + ('HAc', 'Ac-'), ('HPr', 'Pr-'), ('HBu', 'Bu-'), ('HVa', 'Va-') + ''' + return self.pKa_base-np.log10(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 (-1/(1+10**(pKa_IP-pH))-1)/31 #!!! alpha IP should be negative + + @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 + + @property + def alpha_vfa(self): + return 1.0/self.cod_vfa*(-1.0/(1.0 + 10**(self.pKa[4:]-self.pH))) -# %% + def check_component_properties(self, cmps_asm, cmps_adm): + get = getattr + setv = setattr + for name in ('X_PHA', 'X_PP', 'X_PAO'): + casm = get(cmps_asm, name) + cadm = get(cmps_adm, name) + for attr in ('measured_as', 'i_COD', 'i_C', 'i_N', 'i_P'): + vasm = get(casm, attr) + if get(cadm, attr) != vasm: + setv(cadm, attr, vasm) + warn(f"ADM component {name}'s {attr} is changed to match " + "the corresponding ASM component") + + for name in ('S_I', 'X_I'): + casm = get(cmps_asm, name) + cadm = get(cmps_adm, name) + for attr in ('measured_as', 'i_C', 'i_N', 'i_P'): + vadm = get(cadm, attr) + if get(casm, attr) != vadm: + setv(casm, attr, vadm) + warn(f"ASM component {name}'s {attr} is changed to match " + "the corresponding ADM component") + + for attr in ('i_N', 'i_P'): + vadm = get(cmps_adm.S_ac, attr) + if get(cmps_asm.S_A, attr) != vadm: + cmps_asm.S_A.i_N = vadm + warn(f"ASM component S_A's {attr} is changed to match " + "the ADM component S_ac.") + + if cmps_asm.S_ALK.measured_as != cmps_adm.S_IC.measured_as: + raise RuntimeError('S_ALK in ASM and S_IC in ADM must both be measured as "C".') + if cmps_asm.S_NH4.measured_as != cmps_adm.S_IN.measured_as: + raise RuntimeError('S_NH4 in ASM and S_IN in ADM must both be measured as "N".') + if cmps_asm.S_PO4.measured_as != cmps_adm.S_IP.measured_as: + raise RuntimeError('S_PO4 in ASM and S_IP in ADM must both be measured as "P".') + cmps_asm.refresh_constants() + cmps_adm.refresh_constants() + +#%% ADMtoASM class ADMtoASM(ADMjunction): ''' Interface unit to convert anaerobic digestion model (ADM) components @@ -391,15 +524,22 @@ class ADMtoASM(ADMjunction): # User defined values bio_to_xs = 0.7 - # 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 + # whether to conserve the nitrogen split between soluble and particulate components + conserve_particulate_N = False + + @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 + def balance_cod_tkn(self, adm_vals, asm_vals): cmps_adm = self.ins[0].components cmps_asm = self.outs[0].components @@ -469,10 +609,8 @@ def _compile_reactions(self): asm_X_P_i_N = cmps_asm.X_P.i_N asm_ions_idx = cmps_asm.indices(('S_NH', 'S_ALK')) - 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, \ @@ -497,20 +635,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 @@ -575,6 +726,9 @@ def adm2asm(adm_vals): asm_vals = f_corr(adm_vals, asm_vals) # Step 5: charge balance for alkalinity + alpha_IN = self.alpha_IN + alpha_IC = self.alpha_IC + alpha_vfa = self.alpha_vfa S_NH = asm_vals[asm_ions_idx[0]] S_ALK = (sum(_ions * np.append([alpha_IN, alpha_IC], alpha_vfa)) - S_NH/14)*(-12) asm_vals[asm_ions_idx[1]] = S_ALK @@ -582,14 +736,8 @@ def adm2asm(adm_vals): 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))) - -# %% - +#%% ASMtoADM class ASMtoADM(ADMjunction): ''' Interface unit to convert activated sludge model (ASM) components @@ -635,12 +783,35 @@ class ASMtoADM(ADMjunction): bio_to_li = 0.4 frac_deg = 0.68 + def __init__(self, ID='', upstream=None, downstream=(), thermo=None, + init_with='WasteStream', F_BM_default=None, isdynamic=False, + adm1_model=None, T=298.15, pH=7): + self._T = T + self._pH = pH + super().__init__(ID=ID, upstream=upstream, downstream=downstream, + thermo=thermo, init_with=init_with, + F_BM_default=F_BM_default, isdynamic=isdynamic, + adm1_model=adm1_model) - 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 + @property + def T(self): + '''[float] Temperature of the downstream [K].''' + try: return self.outs[0].sink.T + except: return self._T + @T.setter + def T(self, T): + self._T = self.outs[0].T = T + + @property + def pH(self): + '''[float] downstream pH.''' + if self._pH: return self._pH + else: + try: return self.outs[0].sink.outs[1].pH + except: return 7. + @pH.setter + def pH(self, ph): + self._pH = self.outs[0].pH = ph def balance_cod_tkn(self, asm_vals, adm_vals): cmps_asm = self.ins[0].components @@ -693,7 +864,7 @@ def _compile_reactions(self): atol = self.atol cmps_asm = ins.components - S_NO_i_COD = cmps_asm.S_NO.i_COD + S_NO_i_COD = -40/14 X_BH_i_N = cmps_asm.X_BH.i_N X_BA_i_N = cmps_asm.X_BA.i_N asm_X_I_i_N = cmps_asm.X_I.i_N @@ -715,9 +886,6 @@ def _compile_reactions(self): 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): @@ -848,13 +1016,16 @@ def asm2adm(asm_vals): adm_vals = f_corr(asm_vals, adm_vals) # Step 7: charge balance + 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 asm_charge_tot = _snh/14 - _sno/14 - _salk/12 #!!! 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: + if net_Scat > 0: S_cat = net_Scat S_an = 0 else: @@ -865,4 +1036,2011 @@ def asm2adm(asm_vals): return adm_vals - self._reactions = asm2adm \ No newline at end of file + self._reactions = asm2adm + +#%% ASM2dtoADM1 + +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 + + 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] + + cmps_asm = ins.components + S_NO3_i_COD = cmps_asm.S_NO3.i_COD - cmps_asm.S_N2.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 + asm_X_I_i_N = cmps_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_A.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 + 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 + snd = S_F*S_F_i_N + xnd = X_S*X_S_i_N + + 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 readily biodegradable COD and TKN (S_F, S_A) + # into amino acids and sugars + scod = S_F + S_A + req_scod = snd / S_aa_i_N + + if scod < req_scod: + S_aa = scod + S_su = 0 + snd -= S_aa * S_aa_i_N + else: + S_aa = req_scod + S_su = scod - S_aa + snd = 0 + + # Step 3: convert slowly biodegradable COD and TKN (X_S, X_PHA) + # into proteins, lipids, and carbohydrates + xcod = X_S + X_PHA + req_xcod = xnd / X_pr_i_N + if xcod < req_xcod: + X_pr = xcod + X_li = X_ch = 0 + xnd -= X_pr * X_pr_i_N + else: + X_pr = req_xcod + X_li = self.xs_to_li * (xcod - X_pr) + X_ch = (xcod - X_pr) - X_li + xnd = 0 + + # Step 4: convert active biomass (biodegradable portion) into + # protein, lipids, carbohydrates and potentially particulate TKN + biomass_cod = X_H + X_AUT + X_PAO + available_bioN = bioN - biomass_cod * (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.') + + req_bioN = biomass_cod * frac_deg * X_pr_i_N + + if available_bioN + xnd >= req_bioN: + X_pr += biomass_cod * frac_deg + xnd += available_bioN - req_bioN + else: + bio2pr = (available_bioN + xnd)/X_pr_i_N + X_pr += bio2pr + bio_to_split = biomass_cod * frac_deg - bio2pr + bio2li = bio_to_split * self.bio_to_li + X_li += bio2li + X_ch += (bio_to_split - bio2li) + xnd = 0 + + # Step 5: map particulate inerts + xi_nsp = X_I * asm_X_I_i_N + xi_ndm = X_I * adm_X_I_i_N + + if xi_nsp + xnd >= xi_ndm: + X_I += biomass_cod * (1-frac_deg) + xnd -= xi_ndm - xi_nsp + else: + raise RuntimeError('Not enough N in X_I, X_S to fully ' + 'convert X_I in ASM2d into X_I in ADM1.') + + si_ndm = S_I * adm_S_I_i_N + si_nsp = S_I * asm_S_I_i_N + + si_nsp -= si_ndm + if si_nsp < 0: + snd += si_nsp + si_nsp = 0 + if snd < 0: + xnd += snd + snd = 0 + if xnd < 0: + S_NH4 += xnd + xnd = 0 + if S_NH4 < 0: + warn('Additional soluble inert COD is mapped to S_su.') + icod_surplus = - S_NH4 / adm_S_I_i_N # negative + S_I -= icod_surplus + S_su += icod_surplus + S_NH4 = 0 + + # Step 6: Step map any remaining TKN/P + S_IN = S_NH4 + xnd + snd + si_nsp + + # Step 8: check COD and TKN balance + 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 + 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 + asm_charge_tot = - _sa/64 + _snh4/14 - _sno3/14 - 1.5*_spo4/31 - _salk - _xpp/31 #Based on page 84 of IWA ASM handbook + 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 + +#%% ADM1toASM2d + +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] + + 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', 'X_PP', 'S_ALK')) + + 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 + + _sa, _snh4, _sno3, _spo4, _xpp, _salk = asm_vals[asm_ions_idx] + + 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[5]] = 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))) + +#%% mADM1toASM2d + +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.mADMjunction` + + :class:`qsdsan.sanunits.ASM2dtomADM1` + ''' + + # User defined values + bio_to_xs = 0.9 + + @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 + + 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): + cmps_adm = self.ins[0].components + cmps_asm = self.outs[0].components + self.check_component_properties(cmps_asm, cmps_adm) + + # N balance + X_pr_i_N = cmps_adm.X_pr.i_N + S_aa_i_N = cmps_adm.S_aa.i_N + adm_i_N = cmps_adm.i_N + adm_bio_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 + S_aa_i_P = cmps_adm.S_aa.i_P + adm_i_P = cmps_adm.i_P + + # N balance + 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_ions_idx = cmps_asm.indices(('S_NH4', 'S_A', 'S_NO3', 'S_PO4', 'S_ALK')) + + # P balance + X_S_i_P = cmps_asm.X_S.i_P + S_F_i_P = cmps_asm.S_F.i_P + asm_X_I_i_P = cmps_asm.X_I.i_P + + f_corr = self.balance_cod_tkn + bio_to_xs = self.bio_to_xs + # 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 + # X_PP in ADM1 is charge neutral + _ions = np.array([S_IN, S_IC, S_IP, 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 + 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_indices]) + bio_p = sum((adm_vals*adm_i_P)[adm_bio_indices]) + + xs_cod = bio_cod * bio_to_xs + xs_ndm = xs_cod * X_S_i_N + xs_pdm = xs_cod * X_S_i_P + + bio2xi = bio_cod * (1 - bio_to_xs) + X_I += bio2xi + deficit_N = bio2xi*asm_X_I_i_N # additional N needed for the mapping + deficit_P = bio2xi*asm_X_I_i_P + # 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 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 + + S_A = S_ac + S_pro + S_bu + S_va + + S_NH4 = S_IN + ssub_n + xsub_n + bio_n - deficit_N + S_PO4 = S_IP + ssub_p + xsub_p + bio_p - deficit_P + + 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 + S_NH4, S_A, S_NO3, S_PO4 = asm_vals[asm_ions_idx[:4]] + + # _ions = np.array([S_IN, S_IC, S_IP, S_Mg, S_K, S_ac, S_pro, S_bu, S_va]) + adm_alphas = np.array([self.alpha_IN, self.alpha_IC, self.alpha_IP, + 2/24, 1/39, *self.alpha_vfa]) #!!! should be in unit of charge per g + adm_charge = np.dot(_ions, adm_alphas) + #!!! X_PP in ASM2d has negative charge, to compensate for the absent variables S_K & S_Mg + S_ALK = (adm_charge - (S_NH4/14 - S_A/64 - S_NO3/14 - 1.5*S_PO4/31 - X_PP/31))*(-12) + asm_vals[asm_ions_idx[-1]] = S_ALK + + return asm_vals + + self._reactions = madm12asm2d + + +#%% ASM2dtomADM1 +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 + + def __init__(self, ID='', upstream=None, downstream=(), thermo=None, + init_with='WasteStream', F_BM_default=None, isdynamic=False, + adm1_model=None, asm2d_model=None, T=298.15, pH=7): + self._T = T + self._pH = pH + super().__init__(ID=ID, upstream=upstream, downstream=downstream, + thermo=thermo, init_with=init_with, + F_BM_default=F_BM_default, isdynamic=isdynamic, + adm1_model=adm1_model, asm2d_model=asm2d_model) + + @property + def T(self): + '''[float] Temperature of the downstream [K].''' + try: return self.outs[0].sink.T + except: return self._T + @T.setter + def T(self, T): + self._T = self.outs[0].T = T + + @property + def pH(self): + '''[float] downstream pH.''' + if self._pH: return self._pH + else: + try: return self.outs[0].sink.outs[1].pH + except: return 7. + @pH.setter + def pH(self, ph): + self._pH = self.outs[0].pH = ph + + 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): + + cmps_asm = self.ins[0].components + cmps_adm = self.outs[0].components + self.check_component_properties(cmps_asm, cmps_adm) + + # For COD balance + S_NO3_i_COD = cmps_asm.S_NO3.i_COD - cmps_asm.S_N2.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 + + # 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_aa_i_N = cmps_adm.S_aa.i_N + X_pr_i_N = cmps_adm.X_pr.i_N + X_I_i_N = cmps_adm.X_I.i_N + + X_pr_i_P = cmps_adm.X_pr.i_P + X_I_i_P = cmps_adm.X_I.i_P + + adm_ions_idx = cmps_adm.indices(['S_IN', 'S_IP', 'S_IC', 'S_cat', 'S_an']) + xs_to_li = self.xs_to_li + bio_to_li = self.bio_to_li + frac_deg = self.frac_deg + f_corr = self.balance_cod_tkn_tp + + # To convert components from ASM2d to mADM1 (asm2d-2-madm1) + def asm2d2madm1(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_A + S_F) + X_S + (X_H + X_AUT) + + 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 + snd = S_F*S_F_i_N #S_ND (in asm1) equals the N content in S_F + # To be used in Step 3 + xnd = X_S*X_S_i_N #X_ND (in asm1) equals the N content in X_S + # To be used in Step 5 (a) + xpd = X_S*X_S_i_P + # To be used in Step 5 (b) + spd = 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_ac = S_A + req_scod = snd / S_aa_i_N + if S_F < req_scod: + S_aa = S_F + S_su = 0 + snd -= S_aa * S_aa_i_N + else: + S_aa = req_scod + S_su = S_F - S_aa + snd = 0 + + # Step 3: convert slowly biodegradable COD and TKN + # into proteins, lipids, and carbohydrates + req_xcod = xnd / 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 X_S < req_xcod: + X_pr = X_S + X_li = X_ch = 0 + xnd -= X_pr * X_pr_i_N + xpd -= X_pr * X_pr_i_P + else: + X_pr = req_xcod + X_li = xs_to_li * (X_S - X_pr) + X_ch = (X_S - X_pr) - X_li + xnd = 0 + xpd -= X_pr * X_pr_i_P + + # Step 4: convert active biomass into protein, lipids, + # carbohydrates and potentially particulate TKN + bio2xi = (X_H + X_AUT) * (1-frac_deg) + available_bioN = bioN - bio2xi * 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) * 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 + xnd >= req_bioN and available_bioP + xpd >= req_bioP: + X_pr += (X_H + X_AUT) * frac_deg + xnd += available_bioN - req_bioN + xpd += 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 + xpd)/X_pr_i_P < (available_bioN + xnd)/X_pr_i_N: + bio2pr = (available_bioP + xpd)/X_pr_i_P + xpd = 0 + xnd += available_bioN - (bio2pr*X_pr_i_N) + else: + bio2pr = (available_bioN + xnd)/X_pr_i_N + xnd = 0 + xpd += available_bioP - (bio2pr*X_pr_i_P) + X_pr += bio2pr + bio_to_split = (X_H + X_AUT) * frac_deg - bio2pr + bio2li = bio_to_split * bio_to_li + X_li += bio2li + X_ch += (bio_to_split - bio2li) + + # Step 5: map particulate inerts + X_I += bio2xi + + S_IN = snd + xnd + S_NH4 + S_IP = spd + xpd + 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 + + 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 = - _sa/64 + _snh4/14 - _sno3/14 - 1.5*_spo4/31 - _salk - _xpp/31 #Based on page 84 of IWA ASM handbook + S_IN, S_IP = adm_vals[adm_ions_idx[:2]] + + #!!! charge balance should technically include VFAs, S_K, S_Mg, + # but since their concentrations are assumed zero it is acceptable. + S_IC = (asm_charge - S_IN*self.alpha_IN - S_IP*self.alpha_IP)/self.alpha_IC + proton_charge = 10**(-self.pKa[0]+self.pH) - 10**(-self.pH) # self.pKa[0] is pKw + # net_Scat = Scat - San + net_Scat = asm_charge + 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 + +#%% A1junction + +class A1junction(ADMjunction): + ''' + An abstract superclass holding common properties of modified ADM interface classes. + Users should use its subclasses (e.g., ``mASM2dtoADM1p``, ``ADM1ptomASM2d``) instead. + + See Also + -------- + :class:`qsdsan.sanunits.ADMJunction` + + :class:`qsdsan.sanunits.ADM1ptomASM2d` + + :class:`qsdsan.sanunits.mASM2dtoADM1p` + ''' + _parse_reactions = Junction._no_parse_reactions + rtol = 1e-2 + atol = 1e-6 + cod_vfa = np.array([64, 112, 160, 208]) + + def __init__(self, ID='', upstream=None, downstream=(), thermo=None, + init_with='WasteStream', F_BM_default=None, isdynamic=False, + adm1_model=None, asm2d_model=None): + self.asm2d_model = asm2d_model + super().__init__(ID=ID, upstream=upstream, downstream=downstream, + thermo=thermo, init_with=init_with, + F_BM_default=F_BM_default, isdynamic=isdynamic, + adm1_model=adm1_model) + + @property + def asm2d_model(self): + '''[qsdsan.CompiledProcesses] ASM2d process model.''' + return self._asm2d_model + @asm2d_model.setter + def asm2d_model(self, model): + if not isinstance(model, (pc.ASM2d, pc.mASM2d)): + raise ValueError('`asm2d_model` must be an `ASM2d` object, ' + f'the given object is {type(model).__name__}.') + self._asm2d_model = model + + @property + def adm1_model(self): + '''[qsdsan.CompiledProcesses] mADM1 process model.''' + return self._adm1_model + @adm1_model.setter + def adm1_model(self, model): + if not isinstance(model, (pc.ADM1_p_extension, pc.ADM1p)): + raise ValueError('`adm1_model` must be an `ADM1_p_extension` object, ' #!!! update error message + f'the given object is {type(model).__name__}.') + self._adm1_model = model + + def check_component_properties(self, cmps_asm, cmps_adm): + get = getattr + setv = setattr + for name in ('X_PHA', 'X_PP', 'X_PAO'): + casm = get(cmps_asm, name) + cadm = get(cmps_adm, name) + for attr in ('measured_as', 'i_COD', 'i_C', 'i_N', 'i_P'): + vasm = get(casm, attr) + if get(cadm, attr) != vasm: + setv(cadm, attr, vasm) + warn(f"ADM component {name}'s {attr} is changed to match " + "the corresponding ASM component") + + for name in ('S_I', 'X_I'): + casm = get(cmps_asm, name) + cadm = get(cmps_adm, name) + for attr in ('measured_as', 'i_C', 'i_N', 'i_P'): + vadm = get(cadm, attr) + if get(casm, attr) != vadm: + setv(casm, attr, vadm) + warn(f"ASM component {name}'s {attr} is changed to match " + "the corresponding ADM component") + + for attr in ('i_N', 'i_P'): + vadm = get(cmps_adm.S_ac, attr) + if get(cmps_asm.S_A, attr) != vadm: + cmps_asm.S_A.i_N = vadm + warn(f"ASM component S_A's {attr} is changed to match " + "the ADM component S_ac.") + + if cmps_asm.S_IC.measured_as != cmps_adm.S_IC.measured_as: + raise RuntimeError('S_ALK in ASM and S_IC in ADM must both be measured as "C".') + if cmps_asm.S_NH4.measured_as != cmps_adm.S_IN.measured_as: + raise RuntimeError('S_NH4 in ASM and S_IN in ADM must both be measured as "N".') + if cmps_asm.S_PO4.measured_as != cmps_adm.S_IP.measured_as: + raise RuntimeError('S_PO4 in ASM and S_IP in ADM must both be measured as "P".') + cmps_asm.refresh_constants() + cmps_adm.refresh_constants() + + +#%% ADM1ptomASM2d +class ADM1ptomASM2d(A1junction): + ''' + Interface unit to convert ADM1 state variables + to ASM2d components, following the A1 algorithm in [1]_. + + 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] 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.A1junction` + + :class:`qsdsan.sanunits.mASM2dtoADM1p` + ''' + + 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): + cmps_adm = self.ins[0].components + cmps_asm = self.outs[0].components + self.check_component_properties(cmps_asm, cmps_adm) + + _asm_ids = cmps_asm.indices(['S_F', 'X_S', 'S_A']) + _adm_ids = cmps_adm.indices(['S_su', 'S_aa', 'S_fa', + 'S_va', 'S_bu', 'S_pro', 'S_ac', + 'X_pr', 'X_li', 'X_ch']) + + # For carbon balance + C_SF, C_XS, C_SA = cmps_asm.i_C[_asm_ids] + C_su, C_aa, C_fa, C_va, C_bu, C_pro, C_ac, C_pr, C_li, C_ch = cmps_adm.i_C[_adm_ids] + + # For nitrogen balance + N_SF, N_XS, N_SA = cmps_asm.i_N[_asm_ids] + N_su, N_aa, N_fa, N_va, N_bu, N_pro, N_ac, N_pr, N_li, N_ch = cmps_adm.i_N[_adm_ids] + + # For phosphorous balance + P_SF, P_XS, P_SA = cmps_asm.i_P[_asm_ids] + P_su, P_aa, P_fa, P_va, P_bu, P_pro, P_ac, P_pr, P_li, P_ch = cmps_adm.i_P[_adm_ids] + + adm = self.adm1_model + asm = self.asm2d_model + adm_p1_idx = cmps_adm.indices(('X_su', 'X_aa', 'X_fa', 'X_c4', + 'X_pro', 'X_ac', 'X_h2', + 'X_PAO', 'X_PP', 'X_PHA')) + decay_idx = [i for i in adm.IDs if i.startswith(('decay', 'lysis'))] + decay_stoichio = np.asarray(adm.stoichiometry.loc[decay_idx]) + + _mmp_idx = cmps_asm.indices(('X_CaCO3', 'X_struv', 'X_newb', 'X_ACP', 'X_MgCO3', 'X_AlPO4', 'X_FePO4')) + mmp_ic = cmps_asm.i_C[_mmp_idx] + mmp_in = cmps_asm.i_N[_mmp_idx] + mmp_ip = cmps_asm.i_P[_mmp_idx] + ic_idx, in_idx, ip_idx = cmps_asm.indices(['S_IC', 'S_NH4', 'S_PO4']) + cac_sto = np.asarray(asm.stoichiometry.loc['CaCO3_precipitation_dissolution']) + struv_sto = np.asarray(asm.stoichiometry.loc['struvite_precipitation_dissolution']) + newb_sto = np.asarray(asm.stoichiometry.loc['newberyite_precipitation_dissolution']) + acp_sto = np.asarray(asm.stoichiometry.loc['ACP_precipitation_dissolution']) + mgc_sto = np.asarray(asm.stoichiometry.loc['MgCO3_precipitation_dissolution']) + alp_sto = np.asarray(asm.stoichiometry.loc['AlPO4_precipitation_dissolution']) + fep_sto = np.asarray(asm.stoichiometry.loc['FePO4_precipitation_dissolution']) + # f_corr = self.balance_cod_tkn + + # To convert components from ADM1p to ASM2d (A1) + def adm1p2masm2d(adm_vals): + + _adm_vals = adm_vals.copy() + + # PROCESS 1: decay of biomas, X_PP, X_PHA + bio_xpp_pha = _adm_vals[adm_p1_idx] + _adm_vals += np.dot(bio_xpp_pha, decay_stoichio) + + # PROCESS 2: strip biogas. Omitted because no S_ch4 or S_h2 in ASM2d 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, S_Ca, X_CaCO3, X_struv, \ + X_newb, X_ACP, X_MgCO3, X_AlOH, X_AlPO4, X_FeOH, X_FePO4, \ + S_Na, S_Cl, H2O = _adm_vals + + if S_h2 > 0 or S_ch4 > 0: warn('Ignored dissolved H2 or CH4.') + + S_NH4 = S_IN + S_PO4 = S_IP + + # CONV 1: convert X_pr, X_li, X_ch to X_S + X_S = X_pr + X_li + X_ch + S_IC += X_pr*C_pr + X_li*C_li + X_ch*C_ch - X_S*C_XS + S_NH4 += X_pr*N_pr + X_li*N_li + X_ch*N_ch - X_S*N_XS + S_PO4 += X_pr*P_pr + X_li*P_li + X_ch*P_ch - X_S*P_XS + + # CONV 2: convert S_su, S_aa, S_fa to S_F + S_F = S_su + S_aa + S_fa + S_IC += S_su*C_su + S_aa*C_aa + S_fa*C_fa - S_F*C_SF + S_NH4 += S_su*N_su + S_aa*N_aa + S_fa*N_fa - S_F*N_SF + S_PO4 += S_su*P_su + S_aa*P_aa + S_fa*P_fa - S_F*P_SF + + # CONV 3: convert VFAs to S_A + S_A = S_va + S_bu + S_pro + S_ac + S_IC += S_va*C_va + S_bu*C_bu + S_pro*C_pro + S_ac*C_ac - S_A*C_SA + # S_NH4 += S_va*N_va + S_bu*N_bu + S_pro*N_pro + S_ac*N_ac - S_A*N_SA + # S_PO4 += S_va*P_va + S_bu*P_bu + S_pro*P_pro + S_ac*P_ac - S_A*P_SA + + asm_vals = np.array(([ + 0, 0, # S_O2, S_N2, + S_NH4, + 0, # S_NO3 + S_PO4, S_F, S_A, S_I, + S_IC, S_K, S_Mg, + X_I, X_S, + 0,0,0,0,0, # X_H, X_PAO, X_PP, X_PHA, X_AUT, + S_Ca, X_CaCO3, X_struv, X_newb, X_ACP, X_MgCO3, # directly mapped + X_AlOH, X_AlPO4, X_FeOH, X_FePO4, S_Na, S_Cl, H2O])) + + # Dissolve precipitated minerals if S_IC, S_IN or S_IP becomes negative + if S_IC < 0: + xc_mmp = sum(asm_vals[_mmp_idx] * mmp_ic) + if xc_mmp > 0: + fraction_dissolve = max(0, min(1, - S_IC / xc_mmp)) + asm_vals -= fraction_dissolve * X_CaCO3 * cac_sto + asm_vals -= fraction_dissolve * X_MgCO3 * mgc_sto + if S_IN < 0: + xn_mmp = sum(asm_vals[_mmp_idx] * mmp_in) + if xn_mmp > 0: + fraction_dissolve = max(0, min(1, - S_IN / xn_mmp)) + asm_vals -= fraction_dissolve * X_struv * struv_sto + X_struv = asm_vals[_mmp_idx[0]] + if S_IP < 0: + xp_mmp = sum(asm_vals[_mmp_idx] * mmp_ip) + if xp_mmp > 0: + fraction_dissolve = max(0, min(1, - S_IP / xp_mmp)) + asm_vals -= fraction_dissolve * X_struv * struv_sto + asm_vals -= fraction_dissolve * X_newb * newb_sto + asm_vals -= fraction_dissolve * X_ACP * acp_sto + asm_vals -= fraction_dissolve * X_AlPO4 * alp_sto + asm_vals -= fraction_dissolve * X_FePO4 * fep_sto + + # asm_vals = f_corr(adm_vals, asm_vals) + return asm_vals + + self._reactions = adm1p2masm2d + +#%% mASM2dtoADM1p + +class mASM2dtoADM1p(A1junction): + ''' + Interface unit to convert ASM2d state variables + to ADM1 components, following the A1 scenario in [1]_. + + Parameters + ---------- + upstream : stream or str + Influent stream with ASM components. + downstream : stream or str + Effluent stream with ADM components. + adm1_model : :class:`qsdsan.processes.ADM1_p_extension` + The anaerobic digestion process model. + xs_to_li : float + Split of slowly biodegradable substrate COD to lipid, + after all N is mapped into protein. + rtol : float + Relative tolerance for COD and TKN balance. + atol : float + Absolute tolerance for COD and TKN balance. + + References + ---------- + [1] 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.A1junction` + + :class:`qsdsan.sanunits.ADM1ptomASM2d` + + ''' + # User defined values + xs_to_li = 0.6 + + 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): + cmps_asm = self.ins[0].components + cmps_adm = self.outs[0].components + self.check_component_properties(cmps_asm, cmps_adm) + + _asm_ids = cmps_asm.indices(['S_F', 'X_S', 'X_H', 'S_I', 'X_I']) + _adm_ids = cmps_adm.indices(['S_aa', 'S_su', 'X_pr', 'X_li', 'X_ch']) + # For carbon balance + C_SF, C_XS, C_XB, C_SI, C_XI = cmps_asm.i_C[_asm_ids] + C_aa, C_su, C_pr, C_li, C_ch = cmps_adm.i_C[_adm_ids] + + # For nitrogen balance + N_SF, N_XS, N_XB, N_SI, N_XI = cmps_asm.i_N[_asm_ids] + N_aa, N_su, N_pr, N_li, N_ch = cmps_adm.i_N[_adm_ids] + + # For phosphorous balance + P_SF, P_XS, P_XB, P_SI, P_XI = cmps_asm.i_P[_asm_ids] + P_aa, P_su, P_pr, P_li, P_ch = cmps_adm.i_P[_adm_ids] + + S_O2_idx, S_NO3_idx, S_A_idx, S_F_idx, X_S_idx =\ + cmps_asm.indices(['S_O2', 'S_NO3', 'S_A', 'S_F', 'X_S']) + # f_corr = self.balance_cod_tkn_tp + + asm = self.asm2d_model + adm = self.adm1_model + p1a_stoichio = np.asarray(asm.stoichiometry.loc['hetero_growth_S_A']) + p1a_stoichio /= abs(p1a_stoichio[S_O2_idx]) + p1f_stoichio = np.asarray(asm.stoichiometry.loc['hetero_growth_S_F']) + p1f_stoichio /= abs(p1f_stoichio[S_O2_idx]) + p2a_stoichio = np.asarray(asm.stoichiometry.loc['denitri_S_A']) + p2a_stoichio /= abs(p2a_stoichio[S_NO3_idx]) + p2f_stoichio = np.asarray(asm.stoichiometry.loc['denitri_S_F']) + p2f_stoichio /= abs(p2f_stoichio[S_NO3_idx]) + p3_stoichio = np.array([adm.parameters[f'f_{k}_xb'] for k in ('sI', 'ch', 'pr', 'li', 'xI')]) + + _mmp_idx = cmps_adm.indices(('X_CaCO3', 'X_struv', 'X_newb', 'X_ACP', 'X_MgCO3', 'X_AlPO4', 'X_FePO4')) + mmp_ic = cmps_adm.i_C[_mmp_idx] + mmp_in = cmps_adm.i_N[_mmp_idx] + mmp_ip = cmps_adm.i_P[_mmp_idx] + ic_idx, in_idx, ip_idx = cmps_adm.indices(['S_IC', 'S_IN', 'S_IP']) + cac_sto = np.asarray(adm.stoichiometry.loc['CaCO3_precipitation_dissolution']) + struv_sto = np.asarray(adm.stoichiometry.loc['struvite_precipitation_dissolution']) + newb_sto = np.asarray(adm.stoichiometry.loc['newberyite_precipitation_dissolution']) + acp_sto = np.asarray(adm.stoichiometry.loc['ACP_precipitation_dissolution']) + mgc_sto = np.asarray(adm.stoichiometry.loc['MgCO3_precipitation_dissolution']) + alp_sto = np.asarray(adm.stoichiometry.loc['AlPO4_precipitation_dissolution']) + fep_sto = np.asarray(adm.stoichiometry.loc['FePO4_precipitation_dissolution']) + + xs_to_li = self.xs_to_li + + # To convert components from ASM2d to mADM1 (asm2d-2-madm1) + def masm2d2adm1p(asm_vals): + _asm_vals = asm_vals.copy() + # breakpoint() + + # PROCESSES 1 & 2: remove S_O2 and S_NO3 with S_A, then S_F, X_S with associated stoichiometry + O2_coddm = _asm_vals[S_O2_idx] + NO3_coddm = _asm_vals[S_NO3_idx] + + _asm_vals += O2_coddm * p1a_stoichio # makes S_O2 = 0 + if _asm_vals[S_A_idx] > 0: # enough S_A to comsume all S_O2 for X_H growth + _asm_vals += NO3_coddm * p2a_stoichio # makes S_NO3 = 0 + if _asm_vals[S_A_idx] < 0: # not enough S_A for complete denitrification of S_NO3 + _asm_vals -= (_asm_vals[S_A_idx] / p2a_stoichio[S_A_idx])*p2a_stoichio # make S_A = 0 + NO3_coddm = _asm_vals[S_NO3_idx] + _asm_vals += NO3_coddm * p2f_stoichio # makes S_NO3 = 0 thru X_H growth w S_F + subst_cod = _asm_vals[X_S_idx] + _asm_vals[S_F_idx] + if subst_cod < 0: # not enough S_F + X_S for complete denitrification of S_NO3 + _asm_vals -= (subst_cod / p2f_stoichio[S_F_idx])*p2f_stoichio # S_NO3 stays positive + _asm_vals[[S_F_idx, X_S_idx]] = 0 + warn('not enough S_A, S_F, X_S for complete denitrification of S_NO3') + elif _asm_vals[S_F_idx] < 0: + _asm_vals[X_S_idx] += _asm_vals[S_F_idx] + _asm_vals[S_F_idx] = 0 + else: + _asm_vals -= (_asm_vals[S_A_idx] / p1a_stoichio[S_A_idx])*p1a_stoichio # make S_A = 0 + O2_coddm = _asm_vals[S_O2_idx] + _asm_vals += O2_coddm * p1f_stoichio # makes S_O2 = 0 by consuming S_F + subst_cod = _asm_vals[X_S_idx] + _asm_vals[S_F_idx] + if subst_cod < 0: # not enough S_F + X_S for complete consumption of S_O2 + _asm_vals -= (subst_cod / p1f_stoichio[S_F_idx])*p1f_stoichio # S_O2 and S_NO3 stays positive + _asm_vals[[S_F_idx, X_S_idx]] = 0 + warn('not enough S_A, S_F, X_S for complete consumption of S_O2 and S_NO3') + else: + _asm_vals += NO3_coddm * p2f_stoichio # makes S_NO3 = 0 by consuming S_F + subst_cod = _asm_vals[X_S_idx] + _asm_vals[S_F_idx] + if subst_cod < 0: # not enough S_F + X_S for complete denitrification of S_NO3 + _asm_vals -= (subst_cod / p2f_stoichio[S_F_idx])*p2f_stoichio # S_NO3 stays positive + _asm_vals[[S_F_idx, X_S_idx]] = 0 + warn('not enough S_A, S_F, X_S for complete denitrification of S_NO3') + elif _asm_vals[S_F_idx] < 0: + _asm_vals[X_S_idx] += _asm_vals[S_F_idx] + _asm_vals[S_F_idx] = 0 + + S_O2, S_N2, S_NH4, S_NO3, S_PO4, S_F, S_A, S_I, S_IC, S_K, S_Mg, \ + X_I, X_S, X_H, X_PAO, X_PP, X_PHA, X_AUT, S_Ca, X_CaCO3, \ + X_struv, X_newb, X_ACP, X_MgCO3, X_AlOH, X_AlPO4, \ + X_FeOH, X_FePO4, S_Na, S_Cl, H2O = _asm_vals + + S_IN = S_NH4 + S_IP = S_PO4 + + # CONV 1: transform S_F into S_aa, S_su, S_fa + S_ND = S_F*N_SF # N in S_F + req_scod = S_ND / N_aa + + if S_F < req_scod: # if S_F cod is not enough to convert all organic soluble N into aa + S_aa = S_F + S_su = 0 + else: # if S_F cod is more than enough to convert all organic soluble N into aa + S_aa = req_scod # All soluble organic N will be mapped to amino acid + S_su = S_F - S_aa + + S_IN += S_ND - S_aa*N_aa + S_IC += S_F*C_SF - (S_aa*C_aa + S_su*C_su) + S_IP += S_F*P_SF + + # PROCESS 3: biomass decay (X_H, X_AUT lysis) anaerobic + bio = X_H + X_AUT + _si, _ch, _pr, _li, _xi = bio * p3_stoichio + S_IC += bio*C_XB - (_si*C_SI + _ch*C_ch + _pr*C_pr + _li*C_li + _xi*C_XI) + S_IN += bio*N_XB - (_si*N_SI + _ch*N_ch + _pr*N_pr + _li*N_li + _xi*N_XI) + S_IP += bio*P_XB - (_si*P_SI + _ch*P_ch + _pr*P_pr + _li*P_li + _xi*P_XI) + + # CONV 2: transform asm X_S into X_pr, X_li, X_ch + X_ND = X_S*N_XS + req_xcod = X_ND / N_pr + # 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: # if X_S cod is not enough to convert all organic particulate N into pr + X_pr = X_S + X_li = X_ch = 0 + else: + X_pr = req_xcod + X_li = xs_to_li * (X_S - X_pr) + X_ch = (X_S - X_pr) - X_li + + S_IN += X_ND - X_pr*N_pr + S_IC += X_S*C_XS - (X_pr*C_pr + X_li*C_li + X_ch*C_ch) + S_IP += X_S*P_XS - (X_pr*P_pr + X_li*P_li + X_ch*P_ch) + + X_pr += _pr + X_li += _li + X_ch += _ch + S_I += _si + X_I += _xi + + # PROCESS 4-5: omitted, PAO related components mapped directly + # CONV 3-5: convert S_A, S_I, X_I; conversion is immediate because identical component composition is enforced + S_ac = S_A + + 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, + S_K, S_Mg, S_Ca, X_CaCO3, X_struv, X_newb, X_ACP, X_MgCO3, + X_AlOH, X_AlPO4, X_FeOH, X_FePO4, S_Na, S_Cl, H2O]) + + # Dissolve precipitated minerals if S_IC, S_IN or S_IP becomes negative + if S_IC < 0: + xc_mmp = sum(adm_vals[_mmp_idx] * mmp_ic) + if xc_mmp > 0: + fraction_dissolve = max(0, min(1, - S_IC / xc_mmp)) + adm_vals -= fraction_dissolve * X_CaCO3 * cac_sto + adm_vals -= fraction_dissolve * X_MgCO3 * mgc_sto + if S_IN < 0: + xn_mmp = sum(adm_vals[_mmp_idx] * mmp_in) + if xn_mmp > 0: + fraction_dissolve = max(0, min(1, - S_IN / xn_mmp)) + adm_vals -= fraction_dissolve * X_struv * struv_sto + X_struv = adm_vals[_mmp_idx[0]] + if S_IP < 0: + xp_mmp = sum(adm_vals[_mmp_idx] * mmp_ip) + if xp_mmp > 0: + fraction_dissolve = max(0, min(1, - S_IP / xp_mmp)) + adm_vals -= fraction_dissolve * X_struv * struv_sto + adm_vals -= fraction_dissolve * X_newb * newb_sto + adm_vals -= fraction_dissolve * X_ACP * acp_sto + adm_vals -= fraction_dissolve * X_AlPO4 * alp_sto + adm_vals -= fraction_dissolve * X_FePO4 * fep_sto + + # adm_vals = f_corr(asm_vals, adm_vals) + # adm_vals = f_corr(_asm_vals, adm_vals) + return adm_vals + + self._reactions = masm2d2adm1p diff --git a/qsdsan/sanunits/_membrane_gas_extraction.py b/qsdsan/sanunits/_membrane_gas_extraction.py new file mode 100644 index 00000000..810fd8fe --- /dev/null +++ b/qsdsan/sanunits/_membrane_gas_extraction.py @@ -0,0 +1,570 @@ +# -*- 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', 'MembraneGasExtraction',) + +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 + + +# For naming consistency +MembraneGasExtraction = GasExtractionMembrane \ No newline at end of file diff --git a/qsdsan/sanunits/_pumping.py b/qsdsan/sanunits/_pumping.py index a0f5d0c2..44a24a28 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..35c0d8e6 --- /dev/null +++ b/qsdsan/sanunits/_sludge_treatment.py @@ -0,0 +1,722 @@ +# -*- 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 + +def calc_f_thick(thickener_perc, TSS_in): + """Returns thickening factor, i.e., thickened sludge solid concentration to influent solids concentration""" + if TSS_in > 0: + thickener_factor = thickener_perc*10000/TSS_in # underlying assumption is density of mixed liquor = 1 kg/L = 1e6 mg/L + 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): + """Returns Qu factor (i.e., underflow flowrate to influent flowrate) and + thinning factor (i.e., overflow solids concentration to influent solids concentration)""" + 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 + +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` + Thickened sludge and effluent. + 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 + Alkalinity : 2.5 mg/L + 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 + TSS : 11124.4 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 + TSS : 66748.0 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 + TSS : 265.9 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 # [0] thickened sludge, [1] reject water + _ins_size_is_fixed = False + _outs_size_is_fixed = False + + 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): + '''The percentage of suspended solids in the thickened sludge, in %.''' + 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): + inf = self._mixed + inf.mix_from(self.ins) + if not self.ins: return + elif inf.isempty(): return + else: + TSS_in = inf.get_TSS() + self._Qu_factor = None + return calc_f_thick(self._tp, TSS_in) + + @property + def thinning_factor(self): + f_Qu, f_thin = calc_f_Qu_thin(self.TSS_removal_perc, self.thickener_factor) + return f_thin + + @property + def Qu_factor(self): + f_Qu, f_thin = calc_f_Qu_thin(self.TSS_removal_perc, self.thickener_factor) + return f_Qu + + def _update_parameters(self): + cmps = self.components + TSS_in = np.sum(self._state[:-1]*cmps.i_mass*cmps.x) + self._f_thick = f_thick = calc_f_thick(self._tp, TSS_in) + self._f_Qu, self._f_thin = calc_f_Qu_thin(self._TSS_rmv, f_thick) + + def _run(self): + mixed = self._mixed + mixed.mix_from(self.ins) + x = self.components.x + uf, of = self.outs + + TSS_rmv = self._TSS_rmv + TSS_in = mixed.get_TSS() + f_thick = calc_f_thick(self._tp, TSS_in) + f_Qu, f_thin = calc_f_Qu_thin(TSS_rmv, f_thick) + + if f_thick > 1: split_to_uf = (1-x)*f_Qu + x*TSS_rmv/100 + else: split_to_uf = 1 + mixed.split_to(uf, of, split_to_uf) + + def _init_state(self): + 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. + self._update_parameters() + + def _update_state(self): + '''updates conditions of output stream based on conditions of the Thickener''' + + thickener_factor = self._f_thick + thinning_factor = self._f_thin + Qu_factor = self._f_Qu + x = self.components.x + + uf, of = self.outs + if uf.state is None: uf.state = np.zeros(len(self.components)+1) + if of.state is None: of.state = np.zeros(len(self.components)+1) + + arr = self._state + if thickener_factor <= 1: + uf.state[:] = arr + of.state[:] = 0. + else: + # For sludge, the particulate concentrations (x) are multipled by thickener factor, and + # flowrate is multiplied by Qu_factor. The soluble concentrations (1-x) remains same. + uf.state[:-1] = arr[:-1] * ((1-x) + x*thickener_factor) + uf.state[-1] = arr[-1] * Qu_factor + # For effluent, the particulate concentrations (x) are multipled by thinning factor, and + # flowrate is multiplied by Qu_factor. The soluble concentrations (1-x) remains same. + of.state[:-1] = arr[:-1] * ((1-x) + x*thinning_factor) + of.state[-1] = arr[-1] * (1 - Qu_factor) + + def _update_dstate(self): + '''updates rates of change of output stream from rates of change of the Thickener''' + + thickener_factor = self._f_thick + thinning_factor = self._f_thin + Qu_factor = self._f_Qu + x = self.components.x + + uf, of = self.outs + if uf.dstate is None: uf.dstate = np.zeros(len(self.components)+1) + if of.dstate is None: of.dstate = np.zeros(len(self.components)+1) + arr = self._dstate + if thickener_factor <= 1: + uf.dstate[:] = arr + of.dstate[:] = 0. + else: + # For sludge, the particulate concentrations are multipled by thickener factor, and + # flowrate is multiplied by Qu_factor. The soluble concentrations remains same. + uf.dstate[:-1] = arr[:-1] * ((1-x) + x*thickener_factor) + uf.dstate[-1] = arr[-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. + of.dstate[:-1] = arr[:-1] * ((1-x) + x*thinning_factor) + of.dstate[-1] = arr[-1] * (1 - Qu_factor) + + @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 + _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 + + +#%% Centrifuge + +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 + + 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=F_BM_default, + 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 + + +#%% Incinerator + +class Incinerator(SanUnit): + + """ + Fluidized bed incinerator. + + 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 + Alkalinity : 2.5 mg/L + 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 + TSS : 11124.4 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_dioxide_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_dioxide_ID = carbon_dioxide_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_dioxide_ID = self.carbon_dioxide_ID + ash_cmp_ID = self.ash_component_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) + idx_co2 = cmps.index(carbon_dioxide_ID) + idx_ash = cmps.index(ash_cmp_ID) + i_mass = cmps.i_mass + i_iss = i_mass*(1-cmps.f_Vmass_Totmass) + + n2 = inf[idx_n2] + h2o = inf[idx_h2o] + + mass_ash = np.sum(inf*i_iss) - n2*i_mass[idx_n2] - h2o*i_mass[idx_h2o] + + # Conservation of mass + mass_flue_gas = np.sum(inf*i_mass) - mass_ash + mass_co2 = mass_flue_gas - n2*i_mass[idx_n2] - h2o*i_mass[idx_h2o] + + flue_gas.set_flow([n2, h2o, (mass_co2/i_mass[idx_co2])], + 'kg/hr', (nitrogen_ID, water_ID, carbon_dioxide_ID)) + ash.set_flow(mass_ash/i_mass[idx_ash],'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): + self._state = np.zeros(4) + self._dstate = self._state * 0. + self._cached_state = self._state.copy() + self._cached_t = 0 + + def _update_state(self): + cmps = self.components + flue_gas, ash = self.outs + idx_ash = cmps.index(self.ash_component_ID) + idx_gases = cmps.indices([self.carbon_dioxide_ID, self.nitrogen_ID, self.water_ID]) + + if flue_gas.state is None: flue_gas.state = np.array([0]*len(cmps)+[1]) + if ash.state is None: ash.state = np.array([0]*len(cmps)+[1]) + + flue_gas.state[idx_gases] = self._state[1:] + ash.state[idx_ash] = self._state[0] + + def _update_dstate(self): + cmps = self.components + flue_gas, ash = self.outs + idx_ash = cmps.index(self.ash_component_ID) + idx_gases = cmps.indices([self.carbon_dioxide_ID, self.nitrogen_ID, self.water_ID]) + + if flue_gas.dstate is None: flue_gas.dstate = np.zeros(len(cmps)+1) + if ash.dstate is None: ash.dstate = np.zeros(len(cmps)+1) + + flue_gas.dstate[idx_gases] = self._dstate[1:] + ash.dstate[idx_ash] = self._dstate[0] + + + @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 + cmps = self.components + idx_h2o = cmps.index(self.water_ID) + idx_n2 = cmps.index(self.nitrogen_ID) + idx_co2 = cmps.index(self.carbon_dioxide_ID) + idx_ash = cmps.index(self.ash_component_ID) + i_mass = cmps.i_mass + i_iss = i_mass*(1-cmps.f_Vmass_Totmass) + + def yt(t, QC_ins, dQC_ins): + Mass_ins = np.diag(QC_ins[:,-1]) @ QC_ins[:,:-1] + n2 = Mass_ins[idx_n2] + h2o = Mass_ins[idx_h2o] + ash = np.sum(Mass_ins*i_iss) - n2*i_mass[idx_n2] - h2o*i_mass[idx_h2o] + co2 = np.sum(Mass_ins*i_mass) - ash - n2*i_mass[idx_n2] - h2o*i_mass[idx_h2o] + + _state[:] = [ash/i_mass[idx_ash], co2/i_mass[idx_co2], n2, h2o] + + 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 6761abdf..5c305a59 100644 --- a/qsdsan/sanunits/_suspended_growth_bioreactor.py +++ b/qsdsan/sanunits/_suspended_growth_bioreactor.py @@ -12,6 +12,7 @@ from .. import SanUnit, WasteStream, Process, Processes, CompiledProcesses from ._clarifier import _settling_flux +from ..sanunits import dydt_cstr from sympy import symbols, lambdify, Matrix from scipy.integrate import solve_ivp from warnings import warn @@ -23,26 +24,18 @@ __all__ = ('CSTR', 'BatchExperiment', 'SBR', - 'dydt_cstr' + 'PFR', ) -def _add_aeration_to_growth_model(aer, model): - if isinstance(aer, Process): - processes = Processes(model.tuple) - processes.append(aer) - processes.compile() - else: - processes = model - processes.compile() - return processes - -# %% -@njit(cache=True) -def dydt_cstr(QC_ins, QC, V, _dstate): - Q_ins = QC_ins[:, -1] - C_ins = QC_ins[:, :-1] - _dstate[-1] = 0 - _dstate[:-1] = (Q_ins @ C_ins - sum(Q_ins)*QC[:-1])/V +# def _add_aeration_to_growth_model(aer, model): +# if isinstance(aer, Process): +# processes = Processes(model.tuple) +# processes.append(aer) +# processes.compile() +# else: +# processes = model +# processes.compile() +# return processes #%% class CSTR(SanUnit): @@ -64,6 +57,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, @@ -77,16 +78,29 @@ 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 + + _D_O2 = 2.10e-9 # m2/s def __init__(self, ID='', ins=None, outs=(), split=None, thermo=None, - init_with='WasteStream', V_max=1000, aeration=2.0, + 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, + gas_stripping=False, gas_IDs=None, stripping_kLa_min=None, + K_Henry=None, D_gas=None, p_gas_atm=None, isdynamic=True, exogenous_vars=(), **kwargs): SanUnit.__init__(self, ID, ins, outs, thermo, init_with, isdynamic=isdynamic, exogenous_vars=exogenous_vars, **kwargs) @@ -94,12 +108,26 @@ def __init__(self, ID='', ins=None, outs=(), split=None, thermo=None, self._aeration = aeration self._DO_ID = DO_ID self._model = suspended_growth_model + self.gas_IDs = gas_IDs + self.stripping_kLa_min = stripping_kLa_min + self.K_Henry = K_Henry + self.D_gas = D_gas + self.p_gas_atm = p_gas_atm + self.gas_stripping = gas_stripping 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.''' @@ -108,7 +136,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.''' @@ -151,6 +231,26 @@ def DO_ID(self, doid): f'i.e., one of {self.components.IDs}.') self._DO_ID = doid + @property + def gas_stripping(self): + return self._gstrip + @gas_stripping.setter + def gas_stripping(self, strip): + self._gstrip = strip = bool(strip) + if strip: + if self.gas_IDs: + cmps = self.components.IDs + for i in self.gas_IDs: + if i not in cmps: + raise RuntimeError((f'gas ID {i} not in component set: {cmps}.')) + else: + mdl = self._model + self.gas_IDs = mdl.gas_IDs + self.stripping_kLa_min = np.array(mdl.kLa_min) + self.D_gas = np.array(mdl.D_gas) + self.K_Henry = np.array(mdl.K_Henry) + self.p_gas_atm = np.array(mdl.p_gas_atm) + @property def split(self): '''[numpy.1darray or NoneType] The volumetric split of outs.''' @@ -181,10 +281,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 @@ -196,6 +293,7 @@ def _init_state(self): def _update_state(self): arr = self._state + arr[arr < 1e-16] = 0. arr[-1] = sum(ws.state[-1] for ws in self.ins) if self.split is None: self._outs[0].state = arr else: @@ -237,24 +335,32 @@ def ODE(self): def _compile_ODE(self): isa = isinstance - C = list(symbols(self.components.IDs)) - m = len(C) + cmps = self.components + m = cmps.size + aer = self._aeration if self._model is None: warn(f'{self.ID} was initialized without a suspended growth model, ' f'and thus run as a non-reactive unit') r = lambda state_arr: np.zeros(m) else: - processes = _add_aeration_to_growth_model(self._aeration, self._model) - r = processes.production_rates_eval + # processes = _add_aeration_to_growth_model(aer, self._model) + r = self._model.production_rates_eval _dstate = self._dstate _update_dstate = self._update_dstate V = self._V_max + gstrip = self.gas_stripping + if gstrip: + gas_idx = self.components.indices(self.gas_IDs) + if isa(aer, Process): kLa = aer.kLa + else: kLa = 0. + S_gas_air = np.asarray(self.K_Henry)*np.asarray(self.p_gas_atm) + kLa_stripping = np.maximum(kLa*self.D_gas/self._D_O2, self.stripping_kLa_min) hasexo = bool(len(self._exovars)) f_exovars = self.eval_exo_dynamic_vars - if isa(self._aeration, (float, int)): + if isa(aer, (float, int)): i = self.components.index(self._DO_ID) fixed_DO = self._aeration def dy_dt(t, QC_ins, QC, dQC_ins): @@ -262,19 +368,72 @@ def dy_dt(t, QC_ins, QC, dQC_ins): dydt_cstr(QC_ins, QC, V, _dstate) if hasexo: QC = np.append(QC, f_exovars(t)) _dstate[:-1] += r(QC) + if gstrip: _dstate[gas_idx] -= kLa_stripping * (QC[gas_idx] - S_gas_air) _dstate[i] = 0 _update_dstate() + elif isa(aer, Process): + aer_stoi = aer._stoichiometry + aer_frho = aer.rate_function + def dy_dt(t, QC_ins, QC, dQC_ins): + dydt_cstr(QC_ins, QC, V, _dstate) + if hasexo: QC = np.append(QC, f_exovars(t)) + _dstate[:-1] += r(QC) + aer_stoi * aer_frho(QC) + if gstrip: _dstate[gas_idx] -= kLa_stripping * (QC[gas_idx] - S_gas_air) + _update_dstate() else: def dy_dt(t, QC_ins, QC, dQC_ins): dydt_cstr(QC_ins, QC, V, _dstate) if hasexo: QC = np.append(QC, f_exovars(t)) _dstate[:-1] += r(QC) + if gstrip: _dstate[gas_idx] -= kLa_stripping * (QC[gas_idx] - S_gas_air) _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): @@ -599,40 +758,509 @@ def dX_dt(t, X): def _design(self): pass - def _compile_dC_dt(self, V0, Qin, Cin, C, fill, aer): + # def _compile_dC_dt(self, V0, Qin, Cin, C, fill, aer): + # isa = isinstance + # processes = _add_aeration_to_growth_model(aer, self._model) + # if fill: + # t = symbols('t') + # mass_balance_terms = list(zip(Cin, C, processes.production_rates.rate_of_production)) + # C_dot_eqs = [(cin-c)/(t+V0/Qin) + r for cin, c, r in mass_balance_terms] + # if isa(aer, (float, int)): C_dot_eqs[self.components.index(self._DO_ID)] = 0 + # def dC_dt(t, y): + # C_dot = lambdify([t]+C, C_dot_eqs) + # return C_dot(t, *y) + # J = Matrix(dC_dt(t, C)).jacobian(C) + # else: + # C_dot_eqs = processes.production_rates.rate_of_production + # if isa(aer, (float, int)): C_dot_eqs[self.components.index(self._DO_ID)] = 0 + # def dC_dt(t, y): + # C_dot = lambdify(C, C_dot_eqs) + # return C_dot(*y) + # J = Matrix(dC_dt(None, C)).jacobian(C) + # def J_func(t, y): + # J_func = lambdify(C, J) + # return J_func(*y) + # return (dC_dt, J_func) + +#%% +class PFR(SanUnit): + """ + A plug flow reactor discretized into CSTRs in series with internal recycles and multiple influents. + + Parameters + ---------- + N_tanks_in_series : int, optional + The number of CSTRs or zones in which the PFR is discretized. The default is 5. + V_tanks : iterable[float], optional + The volume [m3] for each zone, length must match the number of CSTRs. + The default is [1500, 1500, 3000, 3000, 3000]. + influent_fractions : iterable[float], optional + The volume fractions fed to each zone for each influent. Number of rows + must match the number of influents. Number of columns must match the number + of zones. The default is [[1.0, 0,0,0,0]]. + internal_recycles : list[3-tuple[int, int, float]], optional + A list of three-element tuples (i, j, Q) indicating internal recycling + streams from zone i to zone j with a flowrate of Q [m3/d], respectively. + Indices i,j start from 0 not 1. The default is [(4,0,35000),]. + DO_setpoints : iterable[float], optional + Dissolve oxygen setpoints of each zone [mg-O2/L]. Length must match the + number of zones. 0 is treated as no active aeration. + DO setpoints take priority over kLa values. The default is []. + kLa : iterable[float], optional + Oxygen transfer rate constant values of each zone [d^(-1)]. If DO + setpoints are specified, kLa values would be ignored in process simulation. + The default is [0, 0, 120, 120, 60]. + DO_sat : float, optional + Saturation dissolved oxygen concentration [mg/L]. The default is 8.0. + gas_stripping : bool, optional + Whether to model gas stripping. The default is False. + gas_IDs : iterable[str], optional + Component IDs of stripped gases. The default is None. + stripping_kLa_min : iterable[float], optional + Minimum gas transfer rate constants [d^(-1)] of each stripped gas component. + The default is None. + K_Henry : iterable[float], optional + Henry's law constants [(conc)/atm], where "conc" indicate the concentration + unit for state variables in the suspended growth model. The default is None. + D_gas : iterable[float], optional + Gas diffusion coefficients in water [m2/s]. The default is None. + p_gas_atm : iterable[float], optional + Partial pressure of the stripped gases in the air [atm]. The default is None. + + + Examples + -------- + >>> import qsdsan.sanunits as su, qsdsan.processes as pc + >>> from qsdsan import WasteStream + >>> cmps = pc.create_asm1_cmps() + >>> asm1 = pc.ASM1() + >>> inf = WasteStream('inf', H2O=1.53e6, S_I=46, S_S=54, X_I=1770, X_S=230, + ... X_BH=3870, X_BA=225, X_P=680, S_O=0.377, S_NO=7.98, + ... S_NH=25.6, S_ND=5.87, X_ND=13.4, S_ALK=103) + >>> AS = su.PFR('AS', ins=(inf,), outs=('eff',), + ... N_tanks_in_series=5, V_tanks=[1000]*2+[1333]*3, + ... influent_fractions=[[1.0, 0,0,0,0]], DO_setpoints=[0]*2+[1.7, 2.4, 0.5], + ... internal_recycles=[(4,0,55338)], DO_ID='S_O', + ... suspended_growth_model=asm1) + >>> AS.set_init_conc(S_I=30, S_S=5, X_I=1000, X_S=100, X_BH=500, X_BA=100, + ... X_P=100, S_O=2, S_NO=20, S_NH=2, S_ND=1, X_ND=1, S_ALK=84) + >>> AS.simulate(t_span=(0,100), method='BDF') + >>> eff, = AS.outs + >>> eff.show() + WasteStream: eff from + phase: 'l', T: 298.15 K, P: 101325 Pa + flow (g/hr): S_I 4.6e+04 + S_S 2.24e+03 + X_I 1.77e+06 + X_S 7.59e+04 + X_BH 3.94e+06 + X_BA 2.3e+05 + X_P 6.95e+05 + S_O 770 + S_NO 1.6e+04 + S_NH 2.68e+03 + S_ND 1.06e+03 + X_ND 5.42e+03 + S_ALK 7.65e+04 + S_N2 2.11e+04 + H2O 1.53e+09 + WasteStream-specific properties: + pH : 7.0 + Alkalinity : 2.5 mg/L + COD : 4389.1 mg/L + BOD : 1563.3 mg/L + TC : 1599.8 mg/L + TOC : 1550.1 mg/L + TN : 329.0 mg/L + TP : 68.2 mg/L + TK : 15.1 mg/L + TSS : 3268.3 mg/L + Component concentrations (mg/L): + S_I 29.9 + S_S 1.5 + X_I 1150.0 + X_S 49.3 + X_BH 2557.0 + X_BA 149.5 + X_P 451.9 + S_O 0.5 + S_NO 10.4 + S_NH 1.7 + S_ND 0.7 + X_ND 3.5 + S_ALK 49.7 + S_N2 13.7 + H2O 994140.9 + + See Also + -------- + :class:`qsdsan.sanunits.CSTR` + + """ + + _N_ins = 1 + _N_outs = 1 + _ins_size_is_fixed = False + _outs_size_is_fixed = True + + _D_O2 = 2.10e-9 # m2/s + + def __init__(self, ID='', ins=None, outs=(), thermo=None, init_with='WasteStream', + N_tanks_in_series=5, V_tanks=[1500, 1500, 3000, 3000, 3000], + influent_fractions=[[1.0, 0,0,0,0]], internal_recycles=[(4,0,35000),], + DO_setpoints=[], kLa=[0, 0, 120, 120, 60], DO_sat=8.0, + DO_ID='S_O2', suspended_growth_model=None, + gas_stripping=False, gas_IDs=None, stripping_kLa_min=None, + K_Henry=None, D_gas=None, p_gas_atm=None, + isdynamic=True, **kwargs): + + exogenous_vars = kwargs.pop('exogenous_vars', None) + if exogenous_vars: + warn('currently exogenous dynamic variables are not supported in process simulation for PFR') + SanUnit.__init__(self, ID, ins, outs, thermo, init_with, isdynamic=isdynamic, + exogenous_vars=exogenous_vars, **kwargs) + self.N_tanks_in_series = N_tanks_in_series + self.V_tanks = V_tanks + self.influent_fractions = influent_fractions + self.internal_recycles = internal_recycles + self.DO_setpoints = DO_setpoints + self.kLa = kLa + self.DO_sat = DO_sat + self.DO_ID = DO_ID + self.suspended_growth_model = suspended_growth_model + self.gas_IDs = gas_IDs + self.stripping_kLa_min = stripping_kLa_min + self.K_Henry = K_Henry + self.D_gas = D_gas + self.p_gas_atm = p_gas_atm + self.gas_stripping = gas_stripping + self._concs = None + self._Qs = self.V_tanks * 0 + + @property + def V_tanks(self): + '''[iterable[float]] Volumes of CSTRs in series [m3]''' + return self._Vs + @V_tanks.setter + def V_tanks(self, Vs): + if not iter(Vs): + raise TypeError(f'V_tanks must be an iterable, not {type(Vs).__name__}') + elif len(Vs) != self.N_tanks_in_series: + raise RuntimeError(f'cannot set the volumes of {self.N_tanks_in_series} tanks' + f'in series with {len(Vs)} value(s).') + else: self._Vs = np.asarray(Vs) + + @property + def influent_fractions(self): + '''[iterable[float]] Fractions of influents fed into different zones [unitless]''' + return self._f_ins + @influent_fractions.setter + def influent_fractions(self, fs): + fs = np.asarray(fs) + if len(fs.shape) == 1: + if len(self.ins) != 1: + raise RuntimeError(f'influent fractions must be a 2d array, with {len(self.ins)} row(s)' + f'and {self.N_tanks_in_series} columns') + if fs.shape[0] != self.N_tanks_in_series: + raise RuntimeError('cannot set the fractions of influent fed into ' + f'{self.N_tanks_in_series} tanks in series with {len(fs)} value(s).') + fs = fs.reshape(1, len(fs)) + elif len(fs.shape) > 2: + raise RuntimeError(f'influent fractions must be a 2d array, with {len(self.ins)} row(s)' + f'and {self.N_tanks_in_series} columns') + if (fs < 0).any(): + raise ValueError('influent fractions must not have negative value(s)') + for row in fs: + rowsum = row.sum() + if rowsum != 1: row /= rowsum + self._f_ins = fs + + @property + def internal_recycles(self): + '''[list[3-tuple[int, int, float]]] A list of three-element tuples (i, j, Q) indicating internal recycling + streams from zone i to zone j with a flowrate of Q [m3/d], respectively. + Indices i,j start from 0 not 1.''' + return self._rcy + @internal_recycles.setter + def internal_recycles(self, rcy): isa = isinstance - processes = _add_aeration_to_growth_model(aer, self._model) - if fill: - t = symbols('t') - mass_balance_terms = list(zip(Cin, C, processes.production_rates.rate_of_production)) - C_dot_eqs = [(cin-c)/(t+V0/Qin) + r for cin, c, r in mass_balance_terms] - if isa(aer, (float, int)): C_dot_eqs[self.components.index(self._DO_ID)] = 0 - def dC_dt(t, y): - C_dot = lambdify([t]+C, C_dot_eqs) - return C_dot(t, *y) - J = Matrix(dC_dt(t, C)).jacobian(C) + if isa(rcy, tuple): + if len(rcy) != 3: + raise TypeError('internal recycles must be indicated by a list of 3-tuples') + rcy = [rcy] + elif isa(rcy, list): + for row in rcy: + if len(row) != 3: + raise TypeError('internal recycles must be indicated by a list of 3-tuples') + else: + raise TypeError('internal recycles must be indicated by a list of 3-tuples') + _rcy = [] + for row in rcy: + i, j, q = row + _rcy.append((int(i), int(j), q)) + self._rcy = _rcy + + @property + def DO_setpoints(self): + '''[iterable[float]] Dissolve oxygen setpoints of CSTRs in series [mg-O2/L]. + 0 is treated as no active aeration. DO setpoints take priority over kLa values.''' + return self._DOs + @DO_setpoints.setter + def DO_setpoints(self, DOs): + if not iter(DOs): + raise TypeError(f'DO setpoints must be an iterable, not {type(DOs).__name__}') + elif 0 < len(DOs) != self.N_tanks_in_series: + raise RuntimeError(f'cannot set the DO setpoints of {self.N_tanks_in_series} tanks' + f'in series with {len(DOs)} value(s).') + else: self._DOs = np.asarray(DOs) + + @property + def kLa(self): + '''[iterable[float]] Aeration kLa values of CSTRs in series [d^(-1)]. If DO + setpoints are specified, kLa values would be ignored in process simulation.''' + return self._kLa + @kLa.setter + def kLa(self, ks): + if any(self._DOs): + if ks != []: + warn('kLa is ignored because DO setpoints have been specified. ' + 'To specify kLa, first set DO_setpoints as []') + # ks = [] else: - C_dot_eqs = processes.production_rates.rate_of_production - if isa(aer, (float, int)): C_dot_eqs[self.components.index(self._DO_ID)] = 0 - def dC_dt(t, y): - C_dot = lambdify(C, C_dot_eqs) - return C_dot(*y) - J = Matrix(dC_dt(None, C)).jacobian(C) - def J_func(t, y): - J_func = lambdify(C, J) - return J_func(*y) - return (dC_dt, J_func) - -# class PFR(SanUnit): - -# _N_ins = 1 -# _N_outs = 2 - -# def __init__(self, ID='', ins=None, outs=(), **kwargs): -# SanUnit.__init__(self, ID, ins, outs) - -# def _run(self, steady_state=True): -# pass - -# def _design(self): -# pass \ No newline at end of file + if not iter(ks): + raise TypeError(f'V_tanks must be an iterable, not {type(ks).__name__}') + elif len(ks) != self.N_tanks_in_series: + raise RuntimeError(f'cannot set kLa of {self.N_tanks_in_series} tanks' + f'in series with {len(ks)} value(s).') + self._kLa = np.asarray(ks) + + @property + def suspended_growth_model(self): + '''[:class:`CompiledProcesses` or NoneType] Suspended growth model.''' + return self._model + + @suspended_growth_model.setter + def suspended_growth_model(self, model): + if isinstance(model, CompiledProcesses) or model is None: self._model = model + else: raise TypeError(f'suspended_growth_model must be one of the following ' + f'types: CompiledProesses, NoneType. Not {type(model)}') + if model is not None: + self._state_keys = keys = list(self.components.IDs) + ['Q'] + self._ncol = ncol = len(keys) + N = self.N_tanks_in_series + self._Qs_idx = list(ncol * np.arange(1, 1+N) - 1) + names = [f'{i} [mg/L]' for i in self.components.IDs] + ['Q [m3/d]'] + names *= N + zones = [[f'zone {i}']*ncol for i in range(N)] + zones = sum(zones, []) + self._state_header = [f'{z} {n}' for z,n in zip(zones, names)] + + @property + def DO_ID(self): + '''[str] The `Component` ID for dissolved oxygen used in the suspended growth model and the aeration model.''' + return self._DO_ID + + @DO_ID.setter + def DO_ID(self, doid): + if doid not in self.components.IDs: + raise ValueError(f'DO_ID must be in the set of `CompiledComponents` used to set thermo, ' + f'i.e., one of {self.components.IDs}.') + self._DO_ID = doid + + @property + def gas_stripping(self): + return self._gstrip + @gas_stripping.setter + def gas_stripping(self, strip): + self._gstrip = strip = bool(strip) + if strip: + if self.gas_IDs: + cmps = self.components.IDs + for i in self.gas_IDs: + if i not in cmps: + raise RuntimeError((f'gas ID {i} not in component set: {cmps}.')) + else: + mdl = self._model + self.gas_IDs = mdl.gas_IDs + self.stripping_kLa_min = mdl.kLa_min + self.D_gas = mdl.D_gas + self.K_Henry = mdl.K_Henry + self.p_gas_atm = mdl.p_gas_atm + + def _run(self): + out, = self.outs + out.mix_from(self.ins) + + @property + def state(self): + '''The state of the PFR, including component concentrations [mg/L] and flow rate [m^3/d] for each zone.''' + if self._state is None: return None + else: + N = self.N_tanks_in_series + y = self._state.copy() + y = y.reshape((N, self._ncol)) + y = pd.DataFrame(y, columns=self._state_keys) + return y + + def set_init_conc(self, concentrations=None, i_zone=None, **kwargs): + '''set the initial concentrations [mg/L] of specific zones.''' + isa = isinstance + cmps = self.components + N = self.N_tanks_in_series + if self._concs is None: self._concs = np.zeros((N, len(cmps))) + if concentrations is None: + concs = cmps.kwarray(kwargs) + if i_zone is None: + self._concs[:] = concs + else: + self._concs[i_zone] = concs + elif isa(concentrations, pd.DataFrame): + concentrations.index = range(N) + dct = concentrations.to_dict('index') + for i, concs in dct.items(): + self._concs[i] = cmps.kwarray(concs) + elif isa(concentrations, np.ndarray): + if concentrations.shape != self._concs.shape: + raise RuntimeError(f'cannot set the concentrations of {len(cmps)} ' + f'components across {N} with a {concentrations.shape} array') + self._concs = concentrations + else: + raise TypeError('specify initial concentrations with pandas.DataFrame, numpy.ndarray' + 'or " = value" kwargs') + + def _init_state(self): + out, = self.outs + y = np.zeros((self.N_tanks_in_series, self._ncol)) + if self._concs is not None: + y[:,:-1] = self._concs + else: + y[:,:-1] = out.conc + y[:,-1] = out.F_vol*24 + self._state = y.flatten() + self._dstate = self._state * 0. + + def _update_state(self): + out, = self.outs + ncol = self._ncol + self._state[self._state < 2.2e-16] = 0. + self._state[self._Qs_idx] = self._Qs + if out.state is None: out.state = np.zeros(ncol) + out.state[:-1] = self._state[-ncol:-1] + out.state[-1] = sum(ws.state[-1] for ws in self.ins) + + def _update_dstate(self): + out, = self.outs + ncol = self._ncol + if out.dstate is None: out.dstate = np.zeros(ncol) + out.dstate[:-1] = self._dstate[-ncol:-1] + out.dstate[-1] = sum(ws.dstate[-1] for ws in self.ins) + + @property + def ODE(self): + if self._ODE is None: + self._compile_ODE() + return self._ODE + + def _compile_ODE(self): + _Qs = self._Qs + _dstate = self._dstate + _update_dstate = self._update_dstate + N = self.N_tanks_in_series + ncol = self._ncol + _1_ov_V = np.diag(1/self.V_tanks) + f_in = self.influent_fractions + DO = self.DO_setpoints + kLa = self.kLa + if not any(kLa): kLa = np.zeros(N) + gstrip = self.gas_stripping + if gstrip: + gas_idx = self.components.indices(self.gas_IDs) + S_gas_air = np.asarray(self.K_Henry)*np.asarray(self.p_gas_atm) + S_gas_air = np.tile(S_gas_air, (N, 1)) + D_O2 = self._D_O2 + kLa_stripping = np.array([np.maximum(kLa*(D/D_O2)**0.5, kmin) + for D, kmin in zip(self.D_gas, self.stripping_kLa_min)]).T + rcy = self.internal_recycles + DO_idx = self.components.index(self.DO_ID) + DOsat = self.DO_sat + Q_internal = np.zeros(N) + for i_from, i_to, qr in rcy: + Q_internal[i_to: i_from+1] += qr + + if self._model is None: + warn(f'{self.ID} was initialized without a suspended growth model, ' + f'and thus run as a non-reactive unit') + Rs = lambda Cs: 0. + else: + f_rho = self._model.rate_function + M_stoi = self._model.stoichio_eval() + # @njit + def Rs(Cs): + # rhos = np.vstack([f_rho(c) for c in Cs]) + rhos = np.apply_along_axis(f_rho, 1, Cs) # n_zone * n_process + rxn = rhos @ M_stoi + return rxn + + if any(DO): + aerated_zones = (DO > 0) + aerated_DO = DO[aerated_zones] + # @njit + def dy_dt(t, QC_ins, QC, dQC_ins): + y = QC.reshape((N, ncol)) + Cs = y[:,:-1] + Cs[Cs < 2.2e-16] = 0. + Cs[aerated_zones, DO_idx] = aerated_DO + _Qs[:] = Qs = np.dot(QC_ins[:,-1], f_in).cumsum() + Q_internal + M_ins = f_in.T @ np.diag(QC_ins[:,-1]) @ QC_ins[:,:-1] # N * n_cmps + M_outs = np.diag(Qs) @ Cs + M_ins[1:] += M_outs[:-1] + for i_from, i_to, qr in rcy: + M_rcy = Cs[i_from] * qr + M_ins[i_to] += M_rcy + if i_from + 1 < N: M_ins[i_from+1] -= M_rcy + rxn = Rs(Cs) + dy = np.zeros_like(y) + dy[:,:-1] = _1_ov_V @ (M_ins - M_outs) + rxn + dy[aerated_zones, DO_idx] = 0. + if gstrip: + S_liq = Cs[:, gas_idx] + dy[:, gas_idx] -= kLa_stripping*(S_liq - S_gas_air) + _dstate[:] = dy.flatten() + _update_dstate() + + else: + # @njit + def dy_dt(t, QC_ins, QC, dQC_ins): + y = QC.reshape((N, ncol)) + Cs = y[:,:-1] + Cs[Cs < 2.2e-16] = 0. + do = Cs[:, DO_idx] + aer = kLa*(DOsat-do) + _Qs[:] = Qs = np.dot(QC_ins[:,-1], f_in).cumsum() + Q_internal + M_ins = f_in.T @ np.diag(QC_ins[:,-1]) @ QC_ins[:,:-1] # N * n_cmps + M_outs = np.diag(Qs) @ Cs + M_ins[1:] += M_outs[:-1] + for i_from, i_to, qr in rcy: + M_rcy = Cs[i_from] * qr + M_ins[i_to] += M_rcy + if i_from + 1 < N: M_ins[i_from+1] -= M_rcy + rxn = Rs(Cs) + dy = np.zeros_like(y) + dy[:,:-1] = _1_ov_V @ (M_ins - M_outs) + rxn + dy[:,DO_idx] += aer + if gstrip: + S_liq = Cs[:, gas_idx] + dy[:, gas_idx] -= kLa_stripping*(S_liq - S_gas_air) + _dstate[:] = dy.flatten() + _update_dstate() + + self._ODE = dy_dt + + def get_retained_mass(self, biomass_IDs): + cmps = self.components + idx = cmps.indices(biomass_IDs) + mass = self.state.iloc[:,idx] @ cmps.i_mass[idx] + return mass @ self.V_tanks + + def _design(self): + pass \ No newline at end of file diff --git a/qsdsan/utils/cod.py b/qsdsan/utils/cod.py index 6203eb6a..60abca24 100644 --- a/qsdsan/utils/cod.py +++ b/qsdsan/utils/cod.py @@ -158,9 +158,11 @@ def electron_acceptor_cod(atoms, charge=0): r''' .. math:: - NO_2^- + 3e^- + 4H^+ -> \frac{1}{2}N_2 + 2H_2O + NO_2^- + 6e^- + 8H^+ -> NH_4^+ + 2H_2O - NO_3^- + 5e^- + 6H^+ -> \frac{1}{2}N_2 + 3H_2O + NO_3^- + 8e^- + 10H^+ -> NH_4^+ + 3H_2O + + N_2 + 6e^- + 8H^+ -> 2NH_4^+ O_2 + 4e^- + 4H^+ -> 2H_2O @@ -168,11 +170,11 @@ def electron_acceptor_cod(atoms, charge=0): if atoms == {'O':2}: return -1 elif atoms == {'N':2}: - return 0 + return -6/4 elif atoms == {'N':1, 'O':2} and charge == -1: - return -3/4 + return -6/4 elif atoms == {'N':1, 'O':3} and charge == -1: - return -5/4 + return -8/4 diff --git a/qsdsan/utils/construction.py b/qsdsan/utils/construction.py index c1a118a4..45583a40 100644 --- a/qsdsan/utils/construction.py +++ b/qsdsan/utils/construction.py @@ -5,11 +5,11 @@ QSDsan: Quantitative Sustainable Design for sanitation and resource recovery systems This module is developed by: - + Yalin Li - + Saumitra Rai - + Joy Zhang This module is under the University of Illinois/NCSA Open Source License. @@ -474,10 +474,10 @@ def calculate_pipe_material(OD, t, ID, L, density=None): # Based on ANSI (American National Standards Institute) pipe chart # the original code has a bug (no data for 22) and has been fixed here IDs = np.array([ - 0.307, 0.410, 0.545, 0.674, 0.884, 1.097, 1.442, 1.682, 2.157, - 2.635, 3.260, 3.760, 4.260, 4.760, 5.295, 6.357, 7.357, 8.329, - 9.329, 10.420, 11.420, 12.390, 13.624, 15.602, 17.624, 19.564, 21.500, - 23.500, 25.500, 27.500, 29.376, 31.376, 33.376, 35.376, 41.376, 47.376 + 0.307, 0.410, 0.545, 0.674, 0.884, 1.097, 1.442, 1.682, 2.157, + 2.635, 3.260, 3.760, 4.260, 4.760, 5.295, 6.357, 7.357, 8.329, + 9.329, 10.420, 11.420, 12.390, 13.624, 15.602, 17.624, 19.564, 21.500, + 23.500, 25.500, 27.500, 29.376, 31.376, 33.376, 35.376, 41.376, 47.376 ]) size = IDs.shape[0] @@ -544,12 +544,12 @@ def select_pipe(Q, v): A = Q / v # cross-section area ID = (4*A/np.pi) ** 0.5 # maximum inner diameter, [ft] ID *= 12 # maximum inner diameter, [in] - + ids = IDs[IDs <= ID] - if ids.size == 0: + if ids.size == 0: ID = IDs[0] # inch - else: + else: ID = ids[-1] OD, t = pipe_dct[ID] - return OD, t, ID \ No newline at end of file + return OD, t, ID diff --git a/qsdsan/utils/wwt_design.py b/qsdsan/utils/wwt_design.py index 6b38e87b..136a505d 100644 --- a/qsdsan/utils/wwt_design.py +++ b/qsdsan/utils/wwt_design.py @@ -4,22 +4,46 @@ QSDsan: Quantitative Sustainable Design for sanitation and resource recovery systems 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') + + +#%% + def get_SRT(system, biomass_IDs, wastage=None, active_unit_IDs=None): """ - Estimate sludge residence time (SRT) of an activated sludge system. + Estimate sludge residence time (SRT) [day] 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. @@ -29,10 +53,6 @@ def get_SRT(system, biomass_IDs, wastage=None, active_unit_IDs=None): IDs of activated sludge units. The default is None, meaning to include all units in the system. - Returns - ------- - [float] Estimated sludge residence time in days. - .. note:: [1] This function uses component flowrates of the system's product `WasteStream` @@ -48,7 +68,675 @@ 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(flow, influent_COD, eff_COD_soluble, + f_d=0.1, b_H=0.4, SRT=10, Y_H=0.625): + """ + Estimates oxygen requirement [kg-O2/day] for heterotrophic biological processes in + an activated sludge system given design and performance assumptions, + following equation 10.10 in [1]. + + Parameters + ---------- + 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]. + 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 + Design sludge retention time of the system. Default is 10 days. + Y_H : float, optional + Yield of heterotrophs [gCOD/gCOD]. The default is 0.625. + + References + ---------- + [1] 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. + """ + 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(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): + """ + Estimates oxygen requirement [kg-O2/day] for autotrophic biological processes in + an activated sludge system given design and performance assumptions, + following equations 11.16-11.19 in [1]. + + Parameters + ---------- + 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 + Design 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 + 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. + + References + ---------- + [1] 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. + """ + + NR = 0.087*(1 + f_d*b_H*SRT)*Y_H/(1 + b_H*SRT) + 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 = 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): + """ + Estimates diffused aeration air flow rate [m3/min] of an activated sludge + system based on oxygen requirements, following equation 11.2 in [1]. + + Parameters + ---------- + oxygen_heterotrophs : float + Oxygen requirement for heterotrophic biological processes, in kg-O2/day. + oxygen_autotrophs : float + Oxygen requirement for autotrophic biological processes, in kg-O2/day. + oxygen_transfer_efficiency : float + Field oxygen transfer efficiency in percentage. The default is 12. + + References + ---------- + [1] 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): + """ + Estimates blower power requirement [kW] for diffused aeration, following + equation 5-77 in [1] and equation 4.27 in [2]. + + 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. The default is 0.7, usual range is 0.7 to 0.9. + K : float, optional + Equal to (kappa - 1)/kappa, where kappa is the adiabatic exponent of air, + i.e., the specific heat ratio. For single-stage centrifugal blower, + the default is 0.283, equivalent to kappa = 1.3947 for dry air. + + References + ---------- + [1] Metcalf & Eddy, Wastewater Engineering: Treatment and + Resource Recovery. 5th ed. New York: McGraw-Hill Education. 2014. + [2] 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/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): + ''' + Total power of the specified unit operations [kW]. + + 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. + ''' + + return sum([y.power_utility.power for y in system.flowsheet.unit if y.ID in active_unit_IDs]) + +def get_cost_sludge_disposal(sludge, unit_weight_disposal_cost=375): + ''' + Returns the daily operating cost of sludge treatment and disposal [USD/day]. + Typical sludge disposal unit costs: + + Land application: 300 - 800 USD/US ton. [2] + Landfill: 100 - 650 USD/US ton. [2] + Incineration: 300 - 500 USD/US ton. [2] + + Parameters + ---------- + sludge : iterable[:class:`WasteStream`] + Effluent sludge from the system for which treatment and disposal costs are being calculated. + The default is None. + unit_weight_disposal_cost : float, optional + The sludge treatment and disposal cost per unit weight [USD/ton]. + Feasible range for this value lies between 100-800 USD/ton [1]. + The default is 375 USD/US ton, which is the close to average of landfill. + + 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 + return np.sum(sludge_prod)*unit_weight_disposal_cost #in USD/day + + +def get_normalized_energy(system, aeration_power, pumping_power, miscellaneous_power): + ''' + Normalized operational energy consumption associated with WRRF [kWh/m3]. + + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized energy consumption is being determined. + aeration_power : float + Power of blower [kW]. + pumping_power : float + Power rquired for sludge pumping and other equipments [kW]. + miscellaneous_power : float + Any other power requirement in the system [kW]. + + ''' + Q = sum([s.F_vol for s in system.feeds]) + return np.array([aeration_power, pumping_power, miscellaneous_power])/Q + + +def get_daily_operational_costs(aeration_power, pumping_power, miscellaneous_power, \ + sludge_disposal_cost, unit_electricity_cost=0.161): + ''' + Normalized daily operational costs associated with WRRF [USD/day], in the + order of aeration, sludge pumping, sludge disposal, and miscellaneous. + + Parameters + ---------- + 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]. + + References + ---------- + [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 + miscellaneous_cost = miscellaneous_power*24*unit_electricity_cost # in (kWh/day)*(USD/kWh) = USD/day + + return np.array([aeration_cost, pumping_cost, sludge_disposal_cost, miscellaneous_cost]) #5 + +get_daily_operational_cost = get_daily_operational_costs + +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=375, # sludge disposal costs + unit_electricity_cost=0.161): + ''' + Normalized daily operational cost associated with WRRF [USD/day]. + + Parameters + ---------- + + q_air : float + Air volumetric flow rate for diffused aeration [m3/min]. + sludge : iterable[:class:`WasteStream`], optional + Effluent sludge from the system for which treatment and disposal costs are being calculated. + The default is None. + 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. + T : float + Air temperature [degree Celsius]. + P_atm : float + Atmostpheric pressure [kPa] + P_inlet_loss : float + Head loss at aeration blower 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. + 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. + The default is 375 USD/US ton, which is the close to average of landfill. + 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. + + ''' + aeration_power = get_P_blower(q_air, T, P_atm, P_inlet_loss, P_diffuser_loss, + h_submergance, efficiency, K) + pumping_power = get_power_utility(system, active_unit_IDs) + sludge_disposal_cost = get_cost_sludge_disposal(sludge, unit_weight_disposal_cost) + opex = get_daily_operational_costs(aeration_power, pumping_power, + miscellaneous_power, + sludge_disposal_cost, + unit_electricity_cost) + return sum(opex) + +def get_GHG_emissions_sec_treatment(system=None, influent=None, effluent=None, + CH4_EF=0.0075, N2O_EF=0.016): + ''' + Returns a 2-tuple of the fugitive emissions of CH4 and N2O [kg/day] + during secondary treatment. + + 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 wastewater to secondary treatment. The default is None. + effluent : iterable[:class:`WasteStream`], optional + Effluent wastewater from the secondary 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 COD removed. [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] + + References + ---------- + [1] IPCC (2019). Chapter 6 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'] + mass_influent_COD = sum(inf.F_vol*24*inf.COD for inf in influent)/1000 # in kg/day + mass_effluent_COD = sum(eff.F_vol*24*eff.COD for eff in effluent)/1000 # in kg/day + + CH4_emitted = CH4_EF*(mass_influent_COD - mass_effluent_COD) + + mass_influent_N = sum(inf.F_vol*24*inf.TN for inf in influent)/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): + ''' + Returns a 2-tuple of the fugitive emissions of CH4 and N2O [kg/day] + at discharge. + + Parameters + ---------- + effluent : : iterable[:class:`WasteStream`], optional + Effluent wastewater discharged from the system. 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] + + References + ---------- + [1] IPCC, 2019. Chapter 6 in 2019 Refinement to the 2006 IPCC Guidelines + for National Greenhouse Gas Inventories. + ''' + mass_effluent_COD = sum(eff.F_vol*24*eff.COD for eff in effluent)/1000 # in kg/day + CH4_emitted = CH4_EF*mass_effluent_COD + mass_effluent_N = sum(eff.F_vol*24*eff.TN for eff in effluent)/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): + ''' + Returns GHG emission associated with operational electricity consumption [kg CO2-eq/day]. + + 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 scope-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} + + ''' + + 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.38, MCF=0.8, + k=0.06, F=0.5, pl=30): + ''' + The average amount of methane emitted from sludge disposed in landfill [kg/day], + returned in a 2-tuple representing emissions during and after project lifetime, + respectively. + + 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. The default value is 0.5. + MCF : float, optional + CH4 correction factor for aerobic decomposition in the year of + deposition. The default is 0.8. + k : float, optional + Methane generation rate [yr^(-1)]. The default is 0.185. + The decomposition of carbon is assumed to follow 1st-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 are: + k (dry climate) = 0.06 + k (wet climate) = 0.185 + F : float, optional + Volume fraction of methane in generated landfill gas. The default is 0.5. + pl : float, optional + The project lifetime [yr] over which methane emissions would be calculated. + The default is 30 years. + + References + ---------- + [1] IPCC, 2019. Chapter 3: Solid Waste Disposal, in 2019 Refinement to + the 2006 IPCC Guidelines for National Greenhouse Gas Inventories. + ''' + DOC_mass_flow = 0 + for slg in sludge: + DOC_mass_flow += slg.composite("C", flow=True, exclude_gas=True, + degradability="b", organic=True, 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(-k * t_vary)) + CH4_emitted_during_pl = sum(decomposed_DOC)*F*16/12 + + accumulated_DOC_at_pl = annual_DDOC* (1 - np.exp(-k * (pl-1))) / (1 - np.exp(-k)) + CH4_emitted_after_pl = accumulated_DOC_at_pl*F*16/12 + + return CH4_emitted_during_pl/(pl*365), CH4_emitted_after_pl/(pl*365) + +def get_CO2_eq_WRRF(system, GHG_treatment, GHG_discharge, GHG_electricity, + GHG_sludge_disposal, CH4_CO2eq=29.8, N2O_CO2eq=273): + ''' + Normalized GHG emissions from onsite and offsite operations associated + with WRRF [kg CO2 eq./m3]. + + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized GHG emission is being determined. + GHG_treatment : tuple[float], optional + The amount of methane and nitrous oxide emitted during secondary treatment (kg/day). + GHG_discharge : tuple[float], 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 : float, optional + Conversion factor of CH4 to equivalent CO2. The default is 29.8 kg CO2eq/kg CH4 [1]. + N2O_CO2eq : float, optional + Conversion factor of N2O to equivalent CO2. The default is 273 kg CO2eq/kg CH4 [1]. + + 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_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, CH4_EF_sc=0.0075, N2O_EF_sc=0.016, + CH4_EF_discharge=0.009, N2O_EF_discharge=0.005, T=20, F=0.5, + P_inlet_loss=1, P_diffuser_loss=7, h_submergance=5.18, efficiency=0.7, + CO2_EF=0.675, DOC_f=0.38, MCF=0.8, k=0.06, pl=30 + ): + + ''' + Returns the total normalized GHG emissions from onsite and offsite operations + associated with WRRF [kg CO2 eq./m3]. + + Parameters + ---------- + system : :class:`biosteam.System` + The system for which normalized GHG emission is being determined. + + ----Secondary treatment---- + + influent_sc : iterable[:class:`WasteStream`], optional + Influent wastewater to secondary treatment. The default is None. + effluent_sc : iterable[:class:`WasteStream`], optional + Effluent wastewater 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. + 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. + + ----Discharge---- + + effluent_sys : iterable[:class:`WasteStream`], optional + Effluent wastewater discharged from the system. 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. + 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. + + ----Electricity--- + CO2_EF : float + The emission factor used to calculate scope-2 CO2 emissions due to + electricity consumption. The default is 0.675 kg-CO2-Eq/kWh. + + --blower power- + q_air : float + Air volumetric flow rate for diffused aeration [m3/min]. + T : float + Air temperature [degree Celsius]. + P_atm : float + Atmostpheric pressure [kPa] + P_inlet_loss : float + Head loss at aeration blower 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. + + --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. The default value is 0.5. + MCF : float, optional + CH4 correction factor for aerobic decomposition in the year of + deposition. The default is 0.8. + k : float, optional + Methane generation rate [yr^(-1)]. The default is 0.185. + The decomposition of carbon is assumed to follow 1st-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 are: + k (dry climate) = 0.06 + k (wet climate) = 0.185 + F : float, optional + Volume fraction of methane in generated landfill gas. The default is 0.5. + pl : float, optional + The project lifetime [yr] over which methane emissions would be calculated. + The default is 30 years. + + --------- Eq CO2 EFs --------- + + CH4_CO2eq : float, optional + Conversion factor of CH4 to equivalent CO2. The default is 29.8 kg CO2eq/kg CH4. + N2O_CO2eq : float, optional + Conversion factor of N2O to equivalent CO2. The default is 273 kg CO2eq/kg CH4. + + ''' + + # source 1 (on-site) + CH4_treatment, N2O_treatment = get_GHG_emissions_sec_treatment( + system, influent_sc, effluent_sc, CH4_EF_sc, N2O_EF_sc + ) + + # source 3 (off-site) + CH4_discharge, N2O_discharge = get_GHG_emissions_discharge( + effluent_sys, CH4_EF_discharge, N2O_EF_discharge + ) + + # source 5 (off-site) + blower_power = get_P_blower(q_air, T, P_atm, P_inlet_loss, P_diffuser_loss, + h_submergance, efficiency, K) + pumping_power = get_power_utility(system, active_unit_IDs) + CO2_eq_electricity = (blower_power + pumping_power)*24*CO2_EF # in kg-CO2-Eq/day + + # source 4 (off-site) + CH4_sludge_disposal = get_GHG_emissions_sludge_disposal( + sludge, DOC_f, MCF, k, F, pl + ) + + CO2_eq_WRRF = np.sum([CH4_treatment*CH4_CO2eq, N2O_treatment*N2O_CO2eq, #1 + CH4_discharge*CH4_CO2eq, N2O_discharge*N2O_CO2eq, #3 + sum(CH4_sludge_disposal)*CH4_CO2eq, #4 + CO2_eq_electricity]) #5 + + return CO2_eq_WRRF/sum([24*s.F_vol for s in system.feeds]) \ No newline at end of file diff --git a/setup.py b/setup.py index 57034c09..2059114b 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ setup( name='qsdsan', packages=['qsdsan'], - version='1.3.1', + version='1.4.0', license='UIUC', author='Quantitative Sustainable Design Group', author_email='quantitative.sustainable.design@gmail.com', @@ -31,8 +31,8 @@ 'Repository': 'https://github.com/QSD-Group/QSDsan', }, install_requires=[ - 'biosteam>=2.37.4', - 'thermosteam>=0.35.1', + 'biosteam>=2.46.1', + 'thermosteam>=0.45.0', 'matplotlib>=3.3.2', 'pandas>=1.3.2', 'SALib>=1.4.5', diff --git a/tests/test_component.py b/tests/test_component.py index 8c53109d..97550ada 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -68,8 +68,8 @@ def test_component(): cmps3 = Components.load_default() assert cmps3.S_H2.measured_as == 'COD' assert cmps3.S_H2.i_COD == 1 - assert isclose(cmps3.S_NO2.i_COD, - 3*molecular_weight({'O':2})/(4*molecular_weight({'N':1})), rel_tol=1e-3) - assert isclose(cmps3.S_NO3.i_COD, - 5*molecular_weight({'O':2})/(4*molecular_weight({'N':1})), rel_tol=1e-3) + assert isclose(cmps3.S_NO2.i_COD, -3.4268, rel_tol=1e-3) + assert isclose(cmps3.S_NO3.i_COD, -4.569, rel_tol=1e-3) set_thermo(cmps3) # Check if the default groups are up-to-date diff --git a/tests/test_exposan.py b/tests/test_exposan.py index 5be8c89d..5f645ba3 100644 --- a/tests/test_exposan.py +++ b/tests/test_exposan.py @@ -35,7 +35,7 @@ def test_exposan(): from exposan.bsm1 import create_system as create_bsm1_system, biomass_IDs bsm1_sys = create_bsm1_system() bsm1_sys.simulate(t_span=(0,10), method='BDF') - print(get_SRT(bsm1_sys, biomass_IDs=biomass_IDs)) # to test the `get_SRT` function + print(get_SRT(bsm1_sys, biomass_IDs=biomass_IDs['asm1'])) # to test the `get_SRT` function #!!! Will use bsm2 to test the junction models # from exposan.interface import create_system as create_inter_system