diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 38cf60dd05..adbc7f989f 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -248,7 +248,7 @@ jobs: install-target: ${{ matrix.pip-install-target }} - name: Remove dependencies installable with pip but not with conda # NOTE some dependencies that are installed by default with pip are not available through conda - # so they're not installed if IDAES is installed with `conda -c conda-forge -c IDAES-PSE idaes-pse` + # so they're not installed if IDAES is installed with `conda -c conda-forge idaes-pse` # to ensure this scenario is handled properly, since we don't have (yet) the conda-build process integrated with the CI, # we manually remove the "pip-but-not-conda" dependencies after installing with pip # as an approximation of the enviroment that we'd get from `conda install` diff --git a/docs/build.py b/docs/build.py index 52ad82e441..004576a381 100644 --- a/docs/build.py +++ b/docs/build.py @@ -213,8 +213,8 @@ def main() -> int: "-t", "--timeout", dest="timeout", - help="Timeout (in seconds) for sphinx-build (default=180)", - default=180, + help="Timeout (in seconds) for sphinx-build (default=360)", + default=360, type=int, ) prs.add_argument( diff --git a/docs/reference_guides/model_libraries/generic/unit_models/index.rst b/docs/reference_guides/model_libraries/generic/unit_models/index.rst index 40f6746e7b..c988b29c9c 100644 --- a/docs/reference_guides/model_libraries/generic/unit_models/index.rst +++ b/docs/reference_guides/model_libraries/generic/unit_models/index.rst @@ -27,6 +27,7 @@ Unit Models skeleton_unit statejunction stoichiometric_reactor + stream_scaler translator turbine valve diff --git a/docs/reference_guides/model_libraries/generic/unit_models/stream_scaler.rst b/docs/reference_guides/model_libraries/generic/unit_models/stream_scaler.rst new file mode 100644 index 0000000000..13a71068fe --- /dev/null +++ b/docs/reference_guides/model_libraries/generic/unit_models/stream_scaler.rst @@ -0,0 +1,46 @@ +Stream Scaler Block +=================== + +Stream Scaler Blocks are used to adjust size of streams to represent, for example, a stream being split across several identical units, which are then all modeled as a single IDAES unit + +Degrees of Freedom +------------------ + +Stream Scaler blocks have one degree of freedom (beyond the state variables in the ``StateBlock`` properties), a ``Var`` called ``multiplier``. It is the factor by which extensive state variables (defined as those having "flow" in their name) are scaled, with ``output_var = multiplier * input_var``. + +Model Structure +--------------- + +Stream Scaler Blocks consists of a single ``StateBlock`` (named properties), each with an inlet and outlet port. + +Additional Constraints +---------------------- + +Stream Scaler Blocks write no additional constraints* (besides those naturally occurring in ``StateBlocks``). + +Variables +--------- + +Stream Scaler blocks add no additional Variables. + +.. module:: idaes.models.unit_models.stream_scaler + + +Initialization +-------------- + +.. autoclass:: StreamScalerInitializer + :members: initialization_routine + +StreamScaler Class +------------------ + +.. autoclass:: StreamScaler + :members: + +StreamScalerData Class +---------------------- + +.. autoclass:: StreamScalerData + :members: + diff --git a/docs/reference_guides/model_libraries/models_extra/index.rst b/docs/reference_guides/model_libraries/models_extra/index.rst index 069761e4a7..2f932d0981 100644 --- a/docs/reference_guides/model_libraries/models_extra/index.rst +++ b/docs/reference_guides/model_libraries/models_extra/index.rst @@ -6,3 +6,5 @@ Additional IDAES Model Libraries phe temperature_swing_adsorption/fixed_bed_tsa0d + membrane_model/1d_membrane + diff --git a/docs/reference_guides/model_libraries/models_extra/membrane_model/1d_membrane.rst b/docs/reference_guides/model_libraries/models_extra/membrane_model/1d_membrane.rst new file mode 100644 index 0000000000..678ca9858d --- /dev/null +++ b/docs/reference_guides/model_libraries/models_extra/membrane_model/1d_membrane.rst @@ -0,0 +1,39 @@ +One-dimensional membrane class for CO2 gas separation +================================================================ + +This is a one-dimensional model for gas separation in CO₂ capture applications. +The model will be discretized in the flow direction, and it supports two flow patterns: +counter-current flow and co-current flow. The model was customized for gas-phase separation +in CO₂ capture with a single-layer design. If a multi-layer design is needed, multiple units +can be connected for this application. The two sides of the membrane are called the feed side +and sweep side. The sweep stream inlet is optional. The driving force across the membrane is the +partial pressure difference in this gas separation application. Additionally, the energy balance +assumes that temperature remains constant on each side of the membrane. + +Variables +--------- + +Model Inputs - symbol: + +* Membrane length - :math:`L` +* Membrane Area - :math:`A` +* Permeance - :math:`per` +* Feed flowrate - :math:`F_fr` +* Feed compositions - :math:`x` +* Feed pressure - :math:`P` +* Feed temperature - :math:`T` + + +Model Outputs : + +* Permeate compositions +* Permeate flowrate + +Degrees of Freedom +------------------ + +The DOF should be 0 for square problem simulations. + + + + diff --git a/docs/tutorials/getting_started/install_templates/conda_idaes_pse.txt b/docs/tutorials/getting_started/install_templates/conda_idaes_pse.txt index 302e795c2a..920490e270 100644 --- a/docs/tutorials/getting_started/install_templates/conda_idaes_pse.txt +++ b/docs/tutorials/getting_started/install_templates/conda_idaes_pse.txt @@ -7,7 +7,7 @@ We recommend using Conda to manage your environment & modules. conda activate my-idaes-env # Install IDAES Conda package - conda install --yes -c IDAES-PSE -c conda-forge idaes-pse + conda install --yes -c conda-forge idaes-pse .. note:: The command above will install the most recent stable (release) version of IDAES. To install other versions of IDAES, including pre-release versions, diff --git a/docs/tutorials/getting_started/install_templates/quickstart.txt b/docs/tutorials/getting_started/install_templates/quickstart.txt index 51cb475815..90525a6073 100644 --- a/docs/tutorials/getting_started/install_templates/quickstart.txt +++ b/docs/tutorials/getting_started/install_templates/quickstart.txt @@ -1,5 +1,5 @@ # Set up & activate Conda new environment with IDAES-PSE -conda create --yes --name my-idaes-env -c conda-forge -c IDAES-PSE python=3.10 idaes-pse +conda create --yes --name my-idaes-env -c conda-forge python=3.10 idaes-pse conda activate my-idaes-env # Install IDAES Extensions diff --git a/idaes/config.py b/idaes/config.py index 8acd5073bd..c784080244 100644 --- a/idaes/config.py +++ b/idaes/config.py @@ -69,6 +69,7 @@ "xubuntu1804": "ubuntu1804", "xubuntu2004": "ubuntu2004", "xubuntu2204": "ubuntu2204", + "pop22": "ubuntu2204", } # Machine map binary_arch_map = { diff --git a/idaes/core/surrogate/tests/data/onnx_models/net_st_net_5000_STM_100_s_2000000_60_5_tanh_1e-06_4096_tr_15481_Calcite_ST.onnx b/idaes/core/surrogate/tests/data/onnx_models/net_Calcite_ST.onnx similarity index 100% rename from idaes/core/surrogate/tests/data/onnx_models/net_st_net_5000_STM_100_s_2000000_60_5_tanh_1e-06_4096_tr_15481_Calcite_ST.onnx rename to idaes/core/surrogate/tests/data/onnx_models/net_Calcite_ST.onnx diff --git a/idaes/core/surrogate/tests/data/onnx_models/net_st_net_5000_STM_100_s_2000000_60_5_tanh_1e-06_4096_tr_15481_Calcite_ST_idaes_info.json b/idaes/core/surrogate/tests/data/onnx_models/net_Calcite_ST_idaes_info.json similarity index 100% rename from idaes/core/surrogate/tests/data/onnx_models/net_st_net_5000_STM_100_s_2000000_60_5_tanh_1e-06_4096_tr_15481_Calcite_ST_idaes_info.json rename to idaes/core/surrogate/tests/data/onnx_models/net_Calcite_ST_idaes_info.json diff --git a/idaes/core/surrogate/tests/test_onnx_surrogate.py b/idaes/core/surrogate/tests/test_onnx_surrogate.py index 623254c285..e4ae7d44ce 100644 --- a/idaes/core/surrogate/tests/test_onnx_surrogate.py +++ b/idaes/core/surrogate/tests/test_onnx_surrogate.py @@ -40,7 +40,7 @@ def load_onnx_model_data( - name="net_st_net_5000_STM_100_s_2000000_60_5_tanh_1e-06_4096_tr_15481_Calcite_ST", + name="net_Calcite_ST", ): onnx_folder_name = os.path.join(this_file_dir(), "data", "onnx_models") onnx_model = onnx.load(os.path.join(onnx_folder_name, "{}.onnx".format(name))) @@ -138,7 +138,7 @@ def test_onnx_surrogate_load_and_save_from_file(): onnx_surrogate = ONNXSurrogate.load_onnx_model( onnx_model_location=os.path.join(this_file_dir(), "data", "onnx_models"), - model_name="net_st_net_5000_STM_100_s_2000000_60_5_tanh_1e-06_4096_tr_15481_Calcite_ST", + model_name="net_Calcite_ST", ) with TempfileManager.new_context() as tf: dname = tf.mkdtemp() diff --git a/idaes/models/unit_models/__init__.py b/idaes/models/unit_models/__init__.py index 7b5397d290..6cf96a7cf0 100644 --- a/idaes/models/unit_models/__init__.py +++ b/idaes/models/unit_models/__init__.py @@ -40,6 +40,7 @@ ) from .shell_and_tube_1d import ShellAndTube1D, ShellAndTubeInitializer from .skeleton_model import SkeletonUnitModel, SkeletonUnitModelData +from .stream_scaler import StreamScaler, StreamScalerData from .statejunction import StateJunction, StateJunctionInitializer from .stoichiometric_reactor import StoichiometricReactor from .translator import Translator diff --git a/idaes/models/unit_models/stream_scaler.py b/idaes/models/unit_models/stream_scaler.py new file mode 100644 index 0000000000..1b6455eb0d --- /dev/null +++ b/idaes/models/unit_models/stream_scaler.py @@ -0,0 +1,244 @@ +################################################################################# +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2023 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +################################################################################# +""" +Unit model to adjust size of streams to represent, for example, a stream being split across several identical units, +which are then all modeled as a single IDAES unit +""" +from functools import partial + +from pyomo.environ import ( + Block, + PositiveReals, + units as pyunits, + Var, +) +from pyomo.network import Port +from pyomo.common.config import ConfigBlock, ConfigValue, In + +from idaes.core import ( + declare_process_block_class, + UnitModelBlockData, + useDefault, +) +from idaes.core.util.config import ( + is_physical_parameter_block, +) +from idaes.core.base.var_like_expression import VarLikeExpression +from idaes.core.util.tables import create_stream_table_dataframe +import idaes.core.util.scaling as iscale +import idaes.logger as idaeslog + +from idaes.models.unit_models.feed import FeedInitializer as StreamScalerInitializer + +__author__ = "Douglas Allan, Tanner Polley" + + +# Set up logger +_log = idaeslog.getLogger(__name__) + + +@declare_process_block_class("StreamScaler") +class StreamScalerData(UnitModelBlockData): + """ + Unit model to adjust size of streams to represent, for example, a stream being split across several identical units, + which are then all modeled as a single IDAES unit + """ + + default_initializer = StreamScalerInitializer + + CONFIG = ConfigBlock() + CONFIG.declare( + "dynamic", + ConfigValue( + domain=In([False]), + default=False, + description="Dynamic model flag - must be False", + doc="""Indicates whether this model will be dynamic or not, + **default** = False. Scaler blocks are always steady-state.""", + ), + ) + CONFIG.declare( + "has_holdup", + ConfigValue( + default=False, + domain=In([False]), + description="Holdup construction flag - must be False", + doc="Scaler blocks do not contain holdup, thus this must be False.", + ), + ) + CONFIG.declare( + "property_package", + ConfigValue( + default=useDefault, + domain=is_physical_parameter_block, + description="Property package to use for StreamScaler", + doc="""Property parameter object used to define property +calculations, **default** - useDefault. +**Valid values:** { +**useDefault** - use default package from parent model or flowsheet, +**PropertyParameterObject** - a PropertyParameterBlock object.}""", + ), + ) + CONFIG.declare( + "property_package_args", + ConfigBlock( + implicit=True, + description="Arguments to use for constructing property packages", + doc="""A ConfigBlock with arguments to be passed to a property +block(s) and used when constructing these, +**default** - None. +**Valid values:** { +see property package for documentation.}""", + ), + ) + + def build(self): + """ + General build method for StreamScalerData. This method calls a number + of sub-methods which automate the construction of expected attributes + of unit models. + + Inheriting models should call `super().build`. + + Args: + None + + Returns: + None + """ + # Call super.build() + super(StreamScalerData, self).build() + + tmp_dict = dict(**self.config.property_package_args) + tmp_dict["has_phase_equilibrium"] = False + tmp_dict["defined_state"] = True + + # Call setup methods from ControlVolumeBlockData + self._get_property_package() + self._get_indexing_sets() + + self.properties = self.config.property_package.build_state_block( + self.flowsheet().time, doc="Material properties at inlet", **tmp_dict + ) + self.scaled_expressions = Block() + self.multiplier = Var( + initialize=1, + domain=PositiveReals, + units=pyunits.dimensionless, + doc="Factor by which to scale dimensionless streams", + ) + self.add_inlet_port(name="inlet", block=self.properties) + self.outlet = Port(doc="Outlet port") + + def rule_scale_var(b, *args, var=None): + return self.multiplier * var[args] + + def rule_no_scale_var(b, *args, var=None): + return var[args] + + for var_name in self.inlet.vars.keys(): + var = getattr(self.inlet, var_name) + if "flow" in var_name: + rule = partial(rule_scale_var, var=var) + else: + rule = partial(rule_no_scale_var, var=var) + self.scaled_expressions.add_component( + var_name, VarLikeExpression(var.index_set(), rule=rule) + ) + expr = getattr(self.scaled_expressions, var_name) + self.outlet.add(expr, var_name) + + def initialize_build( + blk, outlvl=idaeslog.NOTSET, optarg=None, solver=None, hold_state=False + ): + """ + Initialization routine for StreamScaler. + + Keyword Arguments: + outlvl : sets output level of initialization routine + optarg : solver options dictionary object (default=None, use + default solver options) + solver : str indicating which solver to use during + initialization (default = None, use default solver) + hold_state : flag indicating whether the initialization routine + should unfix any state variables fixed during + initialization, **default** - False. **Valid values:** + **True** - states variables are not unfixed, and a dict of + returned containing flags for which states were fixed + during initialization, **False** - state variables are + unfixed after initialization by calling the release_state + method. + + Returns: + If hold_states is True, returns a dict containing flags for which + states were fixed during initialization. + """ + + # Create solver + + # Initialize inlet state blocks + flags = blk.properties.initialize( + outlvl=outlvl, + optarg=optarg, + solver=solver, + hold_state=True, + ) + + if hold_state is True: + return flags + else: + blk.release_state(flags, outlvl=outlvl) + + def release_state(blk, flags, outlvl=idaeslog.NOTSET): + """ + Method to release state variables fixed during initialization. + + Keyword Arguments: + flags : dict containing information of which state variables + were fixed during initialization, and should now be + unfixed. This dict is returned by initialize if + hold_state = True. + outlvl : sets output level of logging + + Returns: + None + """ + blk.properties.release_state(flags, outlvl=outlvl) + + def _get_stream_table_contents(self, time_point=0): + io_dict = { + "Inlet": self.inlet, + # "Outlet": self.outlet, + } + return create_stream_table_dataframe(io_dict, time_point=time_point) + + def calculate_scaling_factors(self): + # Scaling factors for the property block are calculated automatically + super().calculate_scaling_factors() + + # Need to pass on scaling factors from the property block to the outlet + # VarLikeExpressions so arcs get scaled right + if self.multiplier.value == 0: + default = 1 + else: + default = 1 / self.multiplier.value + + scale = iscale.get_scaling_factor( + self.multiplier, default=default, warning=False + ) + for var_name in self.inlet.vars.keys(): + var = getattr(self.inlet, var_name) + outlet_expr = getattr(self.outlet, var_name) + for key, subvar in var.items(): + sf = iscale.get_scaling_factor(subvar, default=1, warning=True) + iscale.set_scaling_factor(outlet_expr[key], scale * sf) diff --git a/idaes/models/unit_models/tests/test_stream_scaler.py b/idaes/models/unit_models/tests/test_stream_scaler.py new file mode 100644 index 0000000000..608c91be4b --- /dev/null +++ b/idaes/models/unit_models/tests/test_stream_scaler.py @@ -0,0 +1,364 @@ +################################################################################# +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2024 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +################################################################################# +""" +Tests for Stream Scaler unit model. + +Author: Tanner Polley +""" + +import pytest +import pandas +from numpy import number + +from pyomo.environ import ( + check_optimal_termination, + ConcreteModel, + value, + units as pyunits, +) + +from idaes.core import FlowsheetBlock +from idaes.models.unit_models.stream_scaler import StreamScaler, StreamScalerInitializer + +from idaes.models.properties.activity_coeff_models.BTX_activity_coeff_VLE import ( + BTXParameterBlock, +) + +from idaes.models.properties import iapws95 +from idaes.models.properties.examples.saponification_thermo import ( + SaponificationParameterBlock, +) + +from idaes.core.util.model_statistics import ( + number_variables, + number_total_constraints, + number_unused_variables, + variables_set, +) +from idaes.core.util.testing import PhysicalParameterTestBlock, initialization_tester +from idaes.core.solvers import get_solver +from idaes.core.initialization import ( + BlockTriangularizationInitializer, + InitializationStatus, +) +from idaes.core.util import DiagnosticsToolbox + +# ----------------------------------------------------------------------------- +# Get default solver for testing +solver = get_solver("ipopt_v2") + + +# ----------------------------------------------------------------------------- +@pytest.mark.unit +def test_config(): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + + m.fs.properties = PhysicalParameterTestBlock() + + m.fs.unit = StreamScaler(property_package=m.fs.properties) + + # Check unit config arguments + assert len(m.fs.unit.config) == 4 + + assert not m.fs.unit.config.dynamic + assert not m.fs.unit.config.has_holdup + assert m.fs.unit.config.property_package is m.fs.properties + + assert m.fs.unit.default_initializer is StreamScalerInitializer + + +class TestSaponification(object): + @pytest.fixture(scope="class") + def sapon(self): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + m.fs.properties = SaponificationParameterBlock() + m.fs.unit = StreamScaler(property_package=m.fs.properties) + m.fs.unit.multiplier.fix(1) + + m.fs.unit.inlet.flow_vol.fix(1.0e-03) + m.fs.unit.inlet.conc_mol_comp[0, "H2O"].fix(55388.0) + m.fs.unit.inlet.conc_mol_comp[0, "NaOH"].fix(100.0) + m.fs.unit.inlet.conc_mol_comp[0, "EthylAcetate"].fix(100.0) + m.fs.unit.inlet.conc_mol_comp[0, "SodiumAcetate"].fix(1e-8) + m.fs.unit.inlet.conc_mol_comp[0, "Ethanol"].fix(1e-8) + + m.fs.unit.inlet.temperature.fix(303.15) + m.fs.unit.inlet.pressure.fix(101325.0) + return m + + @pytest.mark.build + @pytest.mark.unit + def test_build(self, sapon): + + assert hasattr(sapon.fs.unit, "inlet") + assert len(sapon.fs.unit.inlet.vars) == 4 + assert hasattr(sapon.fs.unit.inlet, "flow_vol") + assert hasattr(sapon.fs.unit.inlet, "conc_mol_comp") + assert hasattr(sapon.fs.unit.inlet, "temperature") + assert hasattr(sapon.fs.unit.inlet, "pressure") + + assert number_variables(sapon) == 9 + assert number_total_constraints(sapon) == 0 + assert number_unused_variables(sapon) == 9 + + @pytest.mark.component + def test_structural_issues(self, sapon): + dt = DiagnosticsToolbox(sapon) + dt.assert_no_structural_warnings() + + @pytest.mark.ui + @pytest.mark.unit + def test_get_performance_contents(self, sapon): + perf_dict = sapon.fs.unit._get_performance_contents() + + assert perf_dict is None + + @pytest.mark.ui + @pytest.mark.unit + def test_get_stream_table_contents(self, sapon): + stable = sapon.fs.unit._get_stream_table_contents() + + expected = pandas.DataFrame.from_dict( + { + "Units": { + "Volumetric Flowrate": getattr( + pyunits.pint_registry, "m**3/second" + ), + "Molar Concentration H2O": getattr( + pyunits.pint_registry, "mole/m**3" + ), + "Molar Concentration NaOH": getattr( + pyunits.pint_registry, "mole/m**3" + ), + "Molar Concentration EthylAcetate": getattr( + pyunits.pint_registry, "mole/m**3" + ), + "Molar Concentration SodiumAcetate": getattr( + pyunits.pint_registry, "mole/m**3" + ), + "Molar Concentration Ethanol": getattr( + pyunits.pint_registry, "mole/m**3" + ), + "Temperature": getattr(pyunits.pint_registry, "K"), + "Pressure": getattr(pyunits.pint_registry, "Pa"), + }, + "Inlet": { + "Volumetric Flowrate": 1e-3, + "Molar Concentration H2O": 55388, + "Molar Concentration NaOH": 100.00, + "Molar Concentration EthylAcetate": 100.00, + "Molar Concentration SodiumAcetate": 0, + "Molar Concentration Ethanol": 0, + "Temperature": 303.15, + "Pressure": 1.0132e05, + }, + } + ) + + pandas.testing.assert_frame_equal(stable, expected, rtol=1e-4, atol=1e-4) + + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_initialize(self, sapon): + initialization_tester(sapon) + + # No solve or numerical tests, as StreamScaler block has nothing to solve + + +class TestBTX(object): + @pytest.fixture(scope="class") + def btx(self): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + m.fs.properties = BTXParameterBlock(valid_phase="Liq") + m.fs.unit = StreamScaler(property_package=m.fs.properties) + m.fs.unit.multiplier.fix(1) + m.fs.unit.inlet.flow_mol[0].fix(5) # mol/s + m.fs.unit.inlet.temperature[0].fix(365) # K + m.fs.unit.inlet.pressure[0].fix(101325) # Pa + m.fs.unit.inlet.mole_frac_comp[0, "benzene"].fix(0.5) + m.fs.unit.inlet.mole_frac_comp[0, "toluene"].fix(0.5) + return m + + @pytest.mark.build + @pytest.mark.unit + def test_build(self, btx): + + assert hasattr(btx.fs.unit, "inlet") + assert len(btx.fs.unit.inlet.vars) == 4 + assert hasattr(btx.fs.unit.inlet, "flow_mol") + assert hasattr(btx.fs.unit.inlet, "mole_frac_comp") + assert hasattr(btx.fs.unit.inlet, "temperature") + assert hasattr(btx.fs.unit.inlet, "pressure") + + assert number_variables(btx) == 9 + assert number_total_constraints(btx) == 3 + assert number_unused_variables(btx) == 3 + + @pytest.mark.component + def test_structural_issues(self, btx): + dt = DiagnosticsToolbox(btx) + dt.assert_no_structural_warnings() + + @pytest.mark.ui + @pytest.mark.unit + def test_get_performance_contents(self, btx): + perf_dict = btx.fs.unit._get_performance_contents() + + assert perf_dict is None + + @pytest.mark.ui + @pytest.mark.unit + def test_get_stream_table_contents(self, btx): + stable = btx.fs.unit._get_stream_table_contents() + + expected = pandas.DataFrame.from_dict( + { + "Units": { + "flow_mol": getattr(pyunits.pint_registry, "mole/second"), + "mole_frac_comp benzene": getattr( + pyunits.pint_registry, "dimensionless" + ), + "mole_frac_comp toluene": getattr( + pyunits.pint_registry, "dimensionless" + ), + "temperature": getattr(pyunits.pint_registry, "kelvin"), + "pressure": getattr(pyunits.pint_registry, "Pa"), + }, + "Inlet": { + "flow_mol": 5.0, + "mole_frac_comp benzene": 0.5, + "mole_frac_comp toluene": 0.5, + "temperature": 365, + "pressure": 101325.0, + }, + } + ) + + pandas.testing.assert_frame_equal(stable, expected, rtol=1e-4, atol=1e-4) + + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_initialize(self, btx): + initialization_tester(btx) + + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_solve(self, btx): + results = solver.solve(btx) + + # Check for optimal solution + assert check_optimal_termination(results) + + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_solution(self, btx): + assert pytest.approx(5, abs=1e-3) == value(btx.fs.unit.inlet.flow_mol[0]) + assert pytest.approx(0.5, abs=1e-3) == value( + btx.fs.unit.inlet.mole_frac_comp[0, "benzene"] + ) + assert pytest.approx(0.5, abs=1e-3) == value( + btx.fs.unit.inlet.mole_frac_comp[0, "toluene"] + ) + + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_numerical_issues(self, btx): + dt = DiagnosticsToolbox(btx) + dt.assert_no_numerical_warnings() + + +# ----------------------------------------------------------------------------- +@pytest.mark.iapws +@pytest.mark.skipif(not iapws95.iapws95_available(), reason="IAPWS not available") +class TestIAPWS(object): + @pytest.fixture(scope="class") + def iapws(self): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + + m.fs.properties = iapws95.Iapws95ParameterBlock() + + m.fs.unit = StreamScaler(property_package=m.fs.properties) + + m.fs.unit.multiplier.fix(1) + m.fs.unit.inlet.flow_mol[0].fix(100) + m.fs.unit.inlet.enth_mol[0].fix(5000) + m.fs.unit.inlet.pressure[0].fix(101325) + + return m + + @pytest.mark.build + @pytest.mark.unit + def test_build(self, iapws): + assert len(iapws.fs.unit.inlet.vars) == 3 + assert hasattr(iapws.fs.unit.inlet, "flow_mol") + assert hasattr(iapws.fs.unit.inlet, "enth_mol") + assert hasattr(iapws.fs.unit.inlet, "pressure") + + assert number_variables(iapws) == 4 + assert number_total_constraints(iapws) == 0 + assert number_unused_variables(iapws) == 4 + + @pytest.mark.component + def test_structural_issues(self, iapws): + dt = DiagnosticsToolbox(iapws) + dt.assert_no_structural_warnings() + + @pytest.mark.ui + @pytest.mark.unit + def test_get_performance_contents(self, iapws): + perf_dict = iapws.fs.unit._get_performance_contents() + + assert perf_dict is None + + @pytest.mark.ui + @pytest.mark.unit + def test_get_stream_table_contents(self, iapws): + stable = iapws.fs.unit._get_stream_table_contents() + + expected = pandas.DataFrame.from_dict( + { + "Units": { + "Molar Flow": getattr(pyunits.pint_registry, "mole/second"), + "Mass Flow": getattr(pyunits.pint_registry, "kg/second"), + "T": getattr(pyunits.pint_registry, "K"), + "P": getattr(pyunits.pint_registry, "Pa"), + "Vapor Fraction": getattr(pyunits.pint_registry, "dimensionless"), + "Molar Enthalpy": getattr(pyunits.pint_registry, "J/mole"), + }, + "Inlet": { + "Molar Flow": 100, + "Mass Flow": 1.8015, + "T": 339.43, + "P": 101325, + "Vapor Fraction": 0, + "Molar Enthalpy": 5000, + }, + } + ) + + pandas.testing.assert_frame_equal(stable, expected, rtol=1e-4, atol=1e-4) + + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_initialize(self, iapws): + initialization_tester(iapws) diff --git a/idaes/models_extra/co2_capture_and_utilization/__init__.py b/idaes/models_extra/co2_capture_and_utilization/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/idaes/models_extra/co2_capture_and_utilization/unit_models/README.md b/idaes/models_extra/co2_capture_and_utilization/unit_models/README.md new file mode 100644 index 0000000000..f31b88e6d7 --- /dev/null +++ b/idaes/models_extra/co2_capture_and_utilization/unit_models/README.md @@ -0,0 +1 @@ +This directory contains the unit models for Carbon Capture and Utilization diff --git a/idaes/models_extra/co2_capture_and_utilization/unit_models/__init__.py b/idaes/models_extra/co2_capture_and_utilization/unit_models/__init__.py new file mode 100644 index 0000000000..fae1ee123d --- /dev/null +++ b/idaes/models_extra/co2_capture_and_utilization/unit_models/__init__.py @@ -0,0 +1,13 @@ +################################################################################# +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2024 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +################################################################################# +from .membrane_1d import Membrane1D, MembraneFlowPattern diff --git a/idaes/models_extra/co2_capture_and_utilization/unit_models/membrane_1d.py b/idaes/models_extra/co2_capture_and_utilization/unit_models/membrane_1d.py new file mode 100644 index 0000000000..9feb1f1e8e --- /dev/null +++ b/idaes/models_extra/co2_capture_and_utilization/unit_models/membrane_1d.py @@ -0,0 +1,301 @@ +################################################################################# +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2024 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +################################################################################# + +""" +One-dimensional membrane class for CO2 gas separation +""" + + +from enum import Enum +from pyomo.common.config import Bool, ConfigDict, ConfigValue, In +from pyomo.environ import ( + Param, + Var, + units, + Expression, +) +from pyomo.network import Port + +from idaes.core import ( + FlowDirection, + UnitModelBlockData, + declare_process_block_class, + useDefault, + MaterialFlowBasis, +) +from idaes.core.util.config import is_physical_parameter_block +from idaes.models.unit_models.mscontactor import MSContactor +from idaes.core.util.exceptions import ConfigurationError +from idaes.core.util.tables import create_stream_table_dataframe + +__author__ = "Maojian Wang" + + +class MembraneFlowPattern(Enum): + """ + Enum of supported flow patterns for membrane. + So far only support countercurrent and cocurrent flow + """ + + COUNTERCURRENT = 1 + COCURRENT = 2 + + +@declare_process_block_class("Membrane1D") +class Membrane1DData(UnitModelBlockData): + """Standard Membrane 1D Unit Model Class.""" + + CONFIG = UnitModelBlockData.CONFIG() + + Stream_Config = ConfigDict() + + Stream_Config.declare( + "property_package", + ConfigValue( + default=useDefault, + domain=is_physical_parameter_block, + description="Property package to use for given stream", + doc="""Property parameter object used to define property calculations for given stream, + **default** - useDefault. + **Valid values:** { + **useDefault** - use default package from parent model or flowsheet, + **PhysicalParameterObject** - a PhysicalParameterBlock object.}""", + ), + ) + Stream_Config.declare( + "property_package_args", + ConfigDict( + implicit=True, + description="Dict of arguments to use for constructing property package", + doc="""A ConfigDict with arguments to be passed to property block(s) + and used when constructing these, + **default** - None. + **Valid values:** { + see property package for documentation.}""", + ), + ) + + Stream_Config.declare( + "has_energy_balance", + ConfigValue( + default=True, + domain=Bool, + doc="Bool indicating whether to include energy balance for stream. Default=True.", + ), + ) + Stream_Config.declare( + "has_pressure_balance", + ConfigValue( + default=True, + domain=Bool, + doc="Bool indicating whether to include pressure balance for stream. Default=True.", + ), + ) + + CONFIG.declare( + "sweep_flow", + ConfigValue( + default=True, + domain=Bool, + doc="Bool indicating whether there is a sweep flow in the permeate side.", + description="Bool indicating whether stream has a feed Port and inlet " + "state, or if all flow is provided via mass transfer. Default=True.", + ), + ) + CONFIG.declare( + "finite_elements", + ConfigValue( + default=5, + domain=int, + description="Number of finite elements in length domain", + doc="""Number of finite elements to use when discretizing length + domain (default=5)""", + ), + ) + CONFIG.declare( + "flow_type", + ConfigValue( + default=MembraneFlowPattern.COUNTERCURRENT, + domain=In(MembraneFlowPattern), + description="Flow configuration of membrane", + doc="""Flow configuration of membrane + MembraneFlowPattern.COCURRENT - feed and sweep flows from 0 to 1 + MembraneFlowPattern.COUNTERCURRENT - feed side flows from 0 to 1 and sweep side flows from 1 to 0 (default)""", + ), + ) + + for side_name in ["feed", "sweep"]: + CONFIG.declare( + side_name + "_side", + Stream_Config(), + ) + + def build(self): + """ + This is a one-dimensional model for gas separation in CO₂ capture applications. + The model will be discretized in the flow direction, and it supports two flow patterns: + counter-current flow and co-current flow. The model was customized for gas-phase separation + in CO₂ capture with a single-layer design. If a multi-layer design is needed, multiple units + can be connected for this application. The two sides of the membrane are called the feed side + and sweep side. The sweep stream inlet is optional. The driving force across the membrane is the + partial pressure difference in this gas separation application. Additionally, the energy balance + assumes that temperature remains constant on each side of the membrane. + + """ + super().build() + + feed_dict = dict(self.config.feed_side) + sweep_dict = dict(self.config.sweep_side) + + feed_dict["flow_direction"] = FlowDirection.forward + if self.config.flow_type == MembraneFlowPattern.COCURRENT: + sweep_dict["flow_direction"] = FlowDirection.forward + elif self.config.flow_type == MembraneFlowPattern.COUNTERCURRENT: + sweep_dict["flow_direction"] = FlowDirection.backward + else: + raise ConfigurationError( + f"{self.name} Membrane1D only supports cocurrent and " + "countercurrent flow patterns, but flow_type configuration" + " argument was set to {config.flow_type}." + ) + + if self.config.sweep_flow is False: + sweep_dict["has_feed"] = False + + streams_dict = {"feed_side": feed_dict, "sweep_side": sweep_dict} + self.mscontactor = MSContactor( + streams=streams_dict, + number_of_finite_elements=self.config.finite_elements, + ) + + self.feed_side_inlet = Port(extends=self.mscontactor.feed_side_inlet) + self.feed_side_outlet = Port(extends=self.mscontactor.feed_side_outlet) + if self.config.sweep_flow is True: + self.sweep_side_inlet = Port(extends=self.mscontactor.sweep_side_inlet) + self.sweep_side_outlet = Port(extends=self.mscontactor.sweep_side_outlet) + + self._make_geometry() + self._make_performance() + + def _make_geometry(self): + + self.area = Var( + initialize=100, units=units.cm**2, doc="Area per cell (or finite element)" + ) + + self.length = Var(initialize=100, units=units.cm, doc="The membrane length") + self.cell_length = Expression(expr=self.length / self.config.finite_elements) + + self.cell_area = Var(initialize=100, units=units.cm**2, doc="The membrane area") + + @self.Constraint() + def area_per_cell(self): + return self.cell_area == self.area / self.config.finite_elements + + def _make_performance(self): + feed_side_units = ( + self.config.feed_side.property_package.get_metadata().derived_units + ) + crossover_component_list = list( + set(self.mscontactor.feed_side.component_list) + & set(self.mscontactor.sweep_side.component_list) + ) + + self.permeance = Var( + self.flowsheet().time, + self.mscontactor.elements, + crossover_component_list, + initialize=1, + doc="Values in Gas Permeance Unit (GPU)", + units=units.dimensionless, + ) + + self.gpu_factor = Param( + default=10e-8 / 13333.2239, + units=units.m / units.s / units.Pa, + mutable=True, + # This is a coefficient that will convert the unit of permeability from GPU to SI units for further calculation" + ) + + p_units = feed_side_units.PRESSURE + + @self.Constraint( + self.flowsheet().time, + self.mscontactor.elements, + crossover_component_list, + doc="permeability calculation", + ) + def permeability_calculation(self, t, s, m): + feed_side_state = self.mscontactor.feed_side[t, s] + if feed_side_state.get_material_flow_basis() is MaterialFlowBasis.molar: + mb_units = feed_side_units.FLOW_MOLE + rho = self.mscontactor.feed_side[t, s].dens_mol + elif feed_side_state.get_material_flow_basis() is MaterialFlowBasis.mass: + mb_units = feed_side_units.FLOW_MASS + rho = self.mscontactor.feed_side[t, s].dens_mass + else: + raise TypeError( + "This model only supports MaterialFlowBasis equal to molar or mass" + ) + + return self.mscontactor.material_transfer_term[ + t, s, "feed_side", "sweep_side", m + ] == -units.convert( + ( + rho + * self.gpu_factor + * self.permeance[t, s, m] + * self.cell_area + * ( + self.mscontactor.feed_side[t, s].pressure + * self.mscontactor.feed_side[t, s].mole_frac_comp[m] + - units.convert( + self.mscontactor.sweep_side[t, s].pressure, to_units=p_units + ) + * self.mscontactor.sweep_side[t, s].mole_frac_comp[m] + ) + ), + to_units=mb_units, + ) + + @self.Constraint( + self.flowsheet().time, + self.mscontactor.elements, + doc="isothermal constraint", + ) + def isothermal_constraint(self, t, s): + return ( + self.mscontactor.feed_side[t, s].temperature + == self.mscontactor.sweep_side[t, s].temperature + ) + + def _get_stream_table_contents(self, time_point=0): + if self.config.sweep_flow: + return create_stream_table_dataframe( + { + "Feed Inlet": self.feed_side_inlet, + "Feed Outlet": self.feed_side_outlet, + "Permeate Inlet": self.sweep_side_inlet, + "Permeate Outlet": self.sweep_side_outlet, + }, + time_point=time_point, + ) + else: + return create_stream_table_dataframe( + { + "Feed Inlet": self.feed_side_inlet, + "Feed Outlet": self.feed_side_outlet, + "Permeate Outlet": self.sweep_side_outlet, + }, + time_point=time_point, + ) diff --git a/idaes/models_extra/co2_capture_and_utilization/unit_models/tests/__init__.py b/idaes/models_extra/co2_capture_and_utilization/unit_models/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/idaes/models_extra/co2_capture_and_utilization/unit_models/tests/test_membrane_1d.py b/idaes/models_extra/co2_capture_and_utilization/unit_models/tests/test_membrane_1d.py new file mode 100644 index 0000000000..8c129ebffe --- /dev/null +++ b/idaes/models_extra/co2_capture_and_utilization/unit_models/tests/test_membrane_1d.py @@ -0,0 +1,304 @@ +################################################################################# +# The Institute for the Design of Advanced Energy Systems Integrated Platform +# Framework (IDAES IP) was produced under the DOE Institute for the +# Design of Advanced Energy Systems (IDAES). +# +# Copyright (c) 2018-2024 by the software owners: The Regents of the +# University of California, through Lawrence Berkeley National Laboratory, +# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon +# University, West Virginia University Research Corporation, et al. +# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md +# for full copyright and license information. +################################################################################# +""" +Tests for Membrane 1D model +""" +__author__ = "Maojian Wang" + +# pylint: disable=unused-import +import pytest + +from pyomo.environ import ( + check_optimal_termination, + assert_optimal_termination, + ConcreteModel, + value, +) +from idaes.core import FlowsheetBlock +from idaes.core.util.model_statistics import ( + number_variables, + number_total_constraints, + number_unused_variables, +) +from idaes.core.solvers import get_solver +from idaes.core.initialization import ( + BlockTriangularizationInitializer, +) +from idaes.core.util import DiagnosticsToolbox +from idaes.models_extra.power_generation.properties.natural_gas_PR import ( + get_prop, + EosType, +) +from idaes.models.properties.modular_properties.base.generic_property import ( + GenericParameterBlock, +) +from idaes.models_extra.co2_capture_and_utilization.unit_models import ( + Membrane1D, + MembraneFlowPattern, +) + +# ----------------------------------------------------------------------------- +# Get default solver for testing +solver = get_solver() + + +# ----------------------------------------------------------------------------- +@pytest.mark.unit +def test_config_countercurrent(): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + + m.fs.properties = GenericParameterBlock( + **get_prop(["CO2", "H2O", "N2"], ["Vap"], eos=EosType.IDEAL), + doc="Key flue gas property parameters", + ) + + m.fs.unit = Membrane1D( + finite_elements=3, + dynamic=False, + sweep_flow=True, + flow_type=MembraneFlowPattern.COUNTERCURRENT, + feed_side={"property_package": m.fs.properties}, + sweep_side={"property_package": m.fs.properties}, + ) + + # Check unit config arguments + assert len(m.fs.unit.config) == 7 + assert not m.fs.unit.config.dynamic + assert not m.fs.unit.config.has_holdup + + +@pytest.mark.unit +def test_congif_cocurrent(): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + + m.fs.properties = GenericParameterBlock( + **get_prop(["CO2", "H2O", "N2"], ["Vap"], eos=EosType.IDEAL), + doc="Key flue gas property parameters", + ) + + m.fs.unit = Membrane1D( + finite_elements=3, + dynamic=False, + sweep_flow=True, + flow_type=MembraneFlowPattern.COCURRENT, + feed_side={"property_package": m.fs.properties}, + sweep_side={"property_package": m.fs.properties}, + ) + + # Check unit config arguments + assert len(m.fs.unit.config) == 7 + assert not m.fs.unit.config.dynamic + assert not m.fs.unit.config.has_holdup + + +class TestMembrane: + @pytest.fixture(scope="class") + def membrane(self): + m = ConcreteModel() + m.fs = FlowsheetBlock(dynamic=False) + + m.fs.properties = GenericParameterBlock( + **get_prop(["CO2", "H2O", "N2"], ["Vap"], eos=EosType.IDEAL), + doc="Key flue gas property parameters", + ) + m.fs.unit = Membrane1D( + finite_elements=3, + dynamic=False, + sweep_flow=True, + flow_type=MembraneFlowPattern.COUNTERCURRENT, + feed_side={"property_package": m.fs.properties}, + sweep_side={"property_package": m.fs.properties}, + ) + + m.fs.unit.permeance[:, :, "CO2"].fix(1500) + m.fs.unit.permeance[:, :, "H2O"].fix(1500 / 25) + m.fs.unit.permeance[:, :, "N2"].fix(1500 / 25) + m.fs.unit.area.fix(100) + m.fs.unit.length.fix(10) + + m.fs.unit.feed_side_inlet.flow_mol[0].fix(100) + m.fs.unit.feed_side_inlet.temperature[0].fix(365) + m.fs.unit.feed_side_inlet.pressure[0].fix(120000) + m.fs.unit.feed_side_inlet.mole_frac_comp[0, "N2"].fix(0.76) + m.fs.unit.feed_side_inlet.mole_frac_comp[0, "CO2"].fix(0.13) + m.fs.unit.feed_side_inlet.mole_frac_comp[0, "H2O"].fix(0.11) + + m.fs.unit.sweep_side_inlet.flow_mol[0].fix(0.01) + m.fs.unit.sweep_side_inlet.temperature[0].fix(300) + m.fs.unit.sweep_side_inlet.pressure[0].fix(51325) + m.fs.unit.sweep_side_inlet.mole_frac_comp[0, "H2O"].fix(0.9986) + m.fs.unit.sweep_side_inlet.mole_frac_comp[0, "CO2"].fix(0.0003) + m.fs.unit.sweep_side_inlet.mole_frac_comp[0, "N2"].fix(0.0001) + + return m + + @pytest.mark.build + @pytest.mark.unit + def test_build(self, membrane): + assert hasattr(membrane.fs.unit, "feed_side_inlet") + assert len(membrane.fs.unit.feed_side_inlet.vars) == 4 + assert hasattr(membrane.fs.unit.feed_side_inlet, "flow_mol") + assert hasattr(membrane.fs.unit.feed_side_inlet, "mole_frac_comp") + assert hasattr(membrane.fs.unit.feed_side_inlet, "temperature") + assert hasattr(membrane.fs.unit.feed_side_inlet, "pressure") + + assert hasattr(membrane.fs.unit, "sweep_side_inlet") + assert len(membrane.fs.unit.sweep_side_inlet.vars) == 4 + assert hasattr(membrane.fs.unit.sweep_side_inlet, "flow_mol") + assert hasattr(membrane.fs.unit.sweep_side_inlet, "mole_frac_comp") + assert hasattr(membrane.fs.unit.sweep_side_inlet, "temperature") + assert hasattr(membrane.fs.unit.sweep_side_inlet, "pressure") + + assert hasattr(membrane.fs.unit, "feed_side_outlet") + assert len(membrane.fs.unit.feed_side_outlet.vars) == 4 + assert hasattr(membrane.fs.unit.feed_side_outlet, "flow_mol") + assert hasattr(membrane.fs.unit.feed_side_outlet, "mole_frac_comp") + assert hasattr(membrane.fs.unit.feed_side_outlet, "temperature") + assert hasattr(membrane.fs.unit.feed_side_outlet, "pressure") + + assert hasattr(membrane.fs.unit, "sweep_side_outlet") + assert len(membrane.fs.unit.sweep_side_outlet.vars) == 4 + assert hasattr(membrane.fs.unit.sweep_side_outlet, "flow_mol") + assert hasattr(membrane.fs.unit.sweep_side_outlet, "mole_frac_comp") + assert hasattr(membrane.fs.unit.sweep_side_outlet, "temperature") + assert hasattr(membrane.fs.unit.sweep_side_outlet, "pressure") + + assert hasattr(membrane.fs.unit, "mscontactor") + assert hasattr(membrane.fs.unit, "permeability_calculation") + assert hasattr(membrane.fs.unit, "isothermal_constraint") + + assert number_variables(membrane) == 157 + assert number_total_constraints(membrane) == 89 + assert number_unused_variables(membrane) == 28 + + @pytest.mark.component + def test_structural_issues(self, membrane): + dt = DiagnosticsToolbox(membrane) + dt.assert_no_structural_warnings() + + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_solve(self, membrane): + initializer = BlockTriangularizationInitializer(constraint_tolerance=2e-5) + initializer.initialize(membrane.fs.unit) + results = solver.solve(membrane) + # Check for optimal solution + assert_optimal_termination(results) + + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_numerical_issues(self, membrane): + dt = DiagnosticsToolbox(membrane) + dt.assert_no_numerical_warnings() + + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_feed_solution(self, membrane): + assert pytest.approx(99.99, abs=1e-2) == value( + membrane.fs.unit.feed_side_outlet.flow_mol[0] + ) + assert pytest.approx(0.1299, abs=1e-4) == value( + membrane.fs.unit.feed_side_outlet.mole_frac_comp[0, "CO2"] + ) + assert pytest.approx(0.11, abs=1e-4) == value( + membrane.fs.unit.feed_side_outlet.mole_frac_comp[0, "H2O"] + ) + assert pytest.approx(0.76, abs=1e-4) == value( + membrane.fs.unit.feed_side_outlet.mole_frac_comp[0, "N2"] + ) + + assert pytest.approx(365, abs=1e-2) == value( + membrane.fs.unit.feed_side_outlet.temperature[0] + ) + assert pytest.approx(120000, abs=1e-2) == value( + membrane.fs.unit.feed_side_outlet.pressure[0] + ) + + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_sweep_side_solution(self, membrane): + assert pytest.approx(0.01006, abs=1e-4) == value( + membrane.fs.unit.sweep_side_outlet.flow_mol[0] + ) + assert pytest.approx(0.0070, abs=1e-4) == value( + membrane.fs.unit.sweep_side_outlet.mole_frac_comp[0, "CO2"] + ) + assert pytest.approx(0.99120, abs=1e-4) == value( + membrane.fs.unit.sweep_side_outlet.mole_frac_comp[0, "H2O"] + ) + assert pytest.approx(0.001710, abs=1e-4) == value( + membrane.fs.unit.sweep_side_outlet.mole_frac_comp[0, "N2"] + ) + + assert pytest.approx(365, abs=1e-2) == value( + membrane.fs.unit.sweep_side_outlet.temperature[0] + ) + assert pytest.approx(51325.0, abs=1e-2) == value( + membrane.fs.unit.sweep_side_outlet.pressure[0] + ) + + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_enthalpy_balance(self, membrane): + + assert ( + abs( + value( + ( + membrane.fs.unit.feed_side_inlet.flow_mol[0] + * membrane.fs.unit.mscontactor.feed_side_inlet_state[ + 0 + ].enth_mol_phase["Vap"] + + membrane.fs.unit.sweep_side_inlet.flow_mol[0] + * membrane.fs.unit.mscontactor.sweep_side_inlet_state[ + 0 + ].enth_mol_phase["Vap"] + - membrane.fs.unit.feed_side_outlet.flow_mol[0] + * membrane.fs.unit.mscontactor.feed_side[0, 3].enth_mol_phase[ + "Vap" + ] + - membrane.fs.unit.sweep_side_outlet.flow_mol[0] + * membrane.fs.unit.mscontactor.sweep_side[0, 1].enth_mol_phase[ + "Vap" + ] + ) + ) + ) + <= 1e-6 + ) + + @pytest.mark.solver + @pytest.mark.skipif(solver is None, reason="Solver not available") + @pytest.mark.component + def test_material_balance(self, membrane): + + assert ( + abs( + value( + ( + membrane.fs.unit.feed_side_inlet.flow_mol[0] + + membrane.fs.unit.sweep_side_inlet.flow_mol[0] + - membrane.fs.unit.feed_side_outlet.flow_mol[0] + - membrane.fs.unit.sweep_side_outlet.flow_mol[0] + ) + ) + ) + <= 1e-3 + ) diff --git a/idaes/ver.py b/idaes/ver.py index a6b79f80d2..598dfc7140 100644 --- a/idaes/ver.py +++ b/idaes/ver.py @@ -184,7 +184,7 @@ def git_hash(): pass #: Package's version as an object -package_version = Version(2, 7, 0, "development", 0, gh) +package_version = Version(2, 8, 0, "development", 0, gh) #: Package's version as a simple string __version__ = str(package_version)