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/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)