diff --git a/docs/reference_guides/core/control_volume_0d.rst b/docs/reference_guides/core/control_volume_0d.rst index da9dad07f7..bd0c73b926 100644 --- a/docs/reference_guides/core/control_volume_0d.rst +++ b/docs/reference_guides/core/control_volume_0d.rst @@ -282,3 +282,31 @@ A single pressure balance is written for the entire mixture. .. math:: 0 = s_{pressure} \times P_{in, t} - s_{pressure} \times P_{out, t} + s_{pressure} \times \Delta P_t + s_{pressure} \times \Delta P_{custom, t} The :math:`\Delta P_{custom, t}` term allows the user to provide custom terms which will be added into the pressure balance. + + +Extended 0D Control Volume Class +-------------------------------- + +The ExtendedControlVolume0DBlock block builds upon ControlVolume0DBlock by adding some new balance options. It is envisioned that this will +merge with ControlVolume0DBlock, however to ensure backward compatibility these additions have been kept separate until unit models can +be updated to restrict (or allow) these new options if necessary. The core functionality is the same as for ControlVolume0DBlock, with the +addition of one extra energy balance type; isothermal. + +.. module:: idaes.core.base.extended_control_volume0d + +.. autoclass:: ExtendedControlVolume0DBlock + :members: + +.. autoclass:: ExtendedControlVolume0DBlockData + :members: + +add_isothermal_constraint +^^^^^^^^^^^^^^^^^^^^^^^^^ + +A constraint equating temperature at the inlet and outlet of the control volume is written. + +**Constraints** + +`isothermal_constraint(t)`: + +.. math:: T_{in, t} == T_{out, t} diff --git a/docs/reference_guides/core/control_volume_1d.rst b/docs/reference_guides/core/control_volume_1d.rst index da7aa57bb5..3118f37557 100644 --- a/docs/reference_guides/core/control_volume_1d.rst +++ b/docs/reference_guides/core/control_volume_1d.rst @@ -311,3 +311,33 @@ The :math:`\Delta P_{custom, t, x}` term allows the user to provide custom terms `pressure_linking_constraint(t, x)`: This constraint is an internal constraint used to link the pressure terms in the StateBlocks into a single indexed variable. This is required as Pyomo.DAE requires a single indexed variable to create the associated DerivativeVars and their numerical expansions. + + +Extended 1D Control Volume Class +-------------------------------- + +The ExtendedControlVolume1DBlock block builds upon ControlVolume1DBlock by adding some new balance options. It is envisioned that this will +merge with ControlVolume1DBlock, however to ensure backward compatibility these additions have been kept separate until unit models can +be updated to restrict (or allow) these new options if necessary. The core functionality is the same as for ControlVolume1DBlock, with the +addition of one extra energy balance type; isothermal. + +.. module:: idaes.core.base.extended_control_volume1d + +.. autoclass:: ExtendedControlVolume1DBlock + :members: + +.. autoclass:: ExtendedControlVolume1DBlockData + :members: + +add_isothermal_constraint +^^^^^^^^^^^^^^^^^^^^^^^^^ + +A constraint equating temperature along the length domain of the control volume is written. + +**Constraints** + +`isothermal_constraint(t, x)`: + +.. math:: T_{t, x-1} == T_{t, x} + +This constraint is skipped at the inlet to the control volume. diff --git a/idaes/core/__init__.py b/idaes/core/__init__.py index 59b7b036c0..7a36b78f7f 100644 --- a/idaes/core/__init__.py +++ b/idaes/core/__init__.py @@ -33,6 +33,8 @@ ) from .base.control_volume0d import ControlVolume0DBlock from .base.control_volume1d import ControlVolume1DBlock, DistributedVars +from .base.extended_control_volume0d import ExtendedControlVolume0DBlock +from .base.extended_control_volume1d import ExtendedControlVolume1DBlock from .base.phases import ( Phase, LiquidPhase, diff --git a/idaes/core/base/control_volume0d.py b/idaes/core/base/control_volume0d.py index 5eb8afa2e1..c177d504ca 100644 --- a/idaes/core/base/control_volume0d.py +++ b/idaes/core/base/control_volume0d.py @@ -1352,6 +1352,16 @@ def add_total_energy_balances(self, *args, **kwargs): "add_total_energy_balances.".format(self.name) ) + def add_isothermal_constraint(self, *args, **kwargs): + """ + Requires ExtendedControlVolume0D + """ + raise BalanceTypeNotSupportedError( + f"{self.name} ControlVolume0D does not support isothermal energy balances. " + "Please consider using ExtendedControlVolume0D in your model if you require " + "support for isothermal balances." + ) + def add_total_pressure_balances(self, has_pressure_change=False, custom_term=None): """ This method constructs a set of 0D pressure balances indexed by time. diff --git a/idaes/core/base/control_volume1d.py b/idaes/core/base/control_volume1d.py index ab98d373bb..03889f2d34 100644 --- a/idaes/core/base/control_volume1d.py +++ b/idaes/core/base/control_volume1d.py @@ -1737,6 +1737,16 @@ def add_total_energy_balances(self, *args, **kwargs): "add_total_energy_balances.".format(self.name) ) + def add_isothermal_constraint(self, *args, **kwargs): + """ + Requires ExtendedControlVolume1D + """ + raise BalanceTypeNotSupportedError( + f"{self.name} ControlVolume1D does not support isothermal energy balances. " + "Please consider using ExtendedControlVolume1D in your model if you require " + "support for isothermal balances." + ) + def add_total_pressure_balances(self, has_pressure_change=False, custom_term=None): """ This method constructs a set of 1D pressure balances indexed by time. diff --git a/idaes/core/base/control_volume_base.py b/idaes/core/base/control_volume_base.py index 0b50fb6639..b8dc520b73 100644 --- a/idaes/core/base/control_volume_base.py +++ b/idaes/core/base/control_volume_base.py @@ -71,6 +71,7 @@ class EnergyBalanceType(Enum): enthalpyTotal = 2 energyPhase = 3 energyTotal = 4 + isothermal = 5 # Enumerate options for momentum balances @@ -618,6 +619,8 @@ def add_energy_balances(self, balance_type=EnergyBalanceType.useDefault, **kwarg eb = self.add_total_energy_balances(**kwargs) elif balance_type == EnergyBalanceType.energyPhase: eb = self.add_phase_energy_balances(**kwargs) + elif balance_type == EnergyBalanceType.isothermal: + eb = self.add_isothermal_constraint(**kwargs) else: raise ConfigurationError( "{} invalid balance_type for add_energy_balances." @@ -843,6 +846,18 @@ def add_total_energy_balances(self, *args, **kwargs): "developer of the ControlVolume class you are using.".format(self.name) ) + def add_isothermal_constraint(self, *args, **kwargs): + """ + Method for adding an isothermal constraint to the control volume. + + See specific control volume documentation for details. + """ + raise NotImplementedError( + f"{self.name} control volume class has not implemented a method for " + "add_isothermal_constraint. Please contact the " + "developer of the ControlVolume class you are using." + ) + def add_phase_pressure_balances(self, *args, **kwargs): """ Method for adding pressure balances indexed by diff --git a/idaes/core/base/extended_control_volume0d.py b/idaes/core/base/extended_control_volume0d.py new file mode 100644 index 0000000000..e82b6842f1 --- /dev/null +++ b/idaes/core/base/extended_control_volume0d.py @@ -0,0 +1,112 @@ +################################################################################# +# 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. +################################################################################# +""" +0D Control Volume class with support for isothermal energy balance. +""" + +__author__ = "Andrew Lee" + +from pyomo.environ import Constraint, Expression + +# Import IDAES cores +from idaes.core.base.control_volume0d import ControlVolume0DBlockData +from idaes.core import declare_process_block_class +from idaes.core.util.exceptions import ConfigurationError + +import idaes.logger as idaeslog + +_log = idaeslog.getLogger(__name__) + + +@declare_process_block_class( + "ExtendedControlVolume0DBlock", + doc=""" + ExtendedControlVolume0DBlock is an extension of the ControlVolume0D + block with support for isothermal conditions in place of a formal + energy balance.""", +) +class ExtendedControlVolume0DBlockData(ControlVolume0DBlockData): + """ + Extended 0-Dimensional (Non-Discretized) ControlVolume Class + + This class extends the existing ControlVolume0DBlockData class + with support for isothermal energy balances. + """ + + def add_isothermal_constraint( + self, + has_heat_of_reaction: bool = False, + has_heat_transfer: bool = False, + has_work_transfer: bool = False, + has_enthalpy_transfer: bool = False, + custom_term: Expression = None, + ) -> Constraint: + """ + This method constructs an isothermal constraint for the control volume. + + Arguments are supported for compatibility with other forms but must be False + or None otherwise an Exception is raised. + + Args: + has_heat_of_reaction: whether terms for heat of reaction should + be included in enthalpy balance + has_heat_transfer: whether terms for heat transfer should be + included in enthalpy balances + has_work_transfer: whether terms for work transfer should be + included in enthalpy balances + has_enthalpy_transfer: whether terms for enthalpy transfer due to + mass transfer should be included in enthalpy balance. This + should generally be the same as the has_mass_transfer + argument in the material balance methods + custom_term: a Python method which returns Pyomo expressions representing + custom terms to be included in enthalpy balances. + Method should accept time and phase list as arguments. + + Returns: + Constraint object representing isothermal constraint + """ + if has_heat_transfer: + raise ConfigurationError( + f"{self.name}: isothermal energy balance option requires that has_heat_transfer is False. " + "If you are trying to solve for heat duty to achieve isothermal operation, please use " + "a full energy balance and add a constraint to equate inlet and outlet temperatures." + ) + if has_work_transfer: + raise ConfigurationError( + f"{self.name}: isothermal energy balance option requires that has_work_transfer is False. " + "If you are trying to solve for work under isothermal operation, please use " + "a full energy balance and add a constraint to equate inlet and outlet temperatures." + ) + if has_enthalpy_transfer: + raise ConfigurationError( + f"{self.name}: isothermal energy balance option does not support enthalpy transfer." + ) + if has_heat_of_reaction: + raise ConfigurationError( + f"{self.name}: isothermal energy balance option requires that has_heat_of_reaction is False. " + "If you are trying to solve for heat duty to achieve isothermal operation, please use " + "a full energy balance and add a constraint to equate inlet and outlet temperatures." + ) + if custom_term is not None: + raise ConfigurationError( + f"{self.name}: isothermal energy balance option does not support custom terms." + ) + + # Add isothermal constraint + @self.Constraint( + self.flowsheet().time, doc="Isothermal constraint - replaces energy balance" + ) + def isothermal_constraint(b, t): + return b.properties_in[t].temperature == b.properties_out[t].temperature + + return self.isothermal_constraint diff --git a/idaes/core/base/extended_control_volume1d.py b/idaes/core/base/extended_control_volume1d.py new file mode 100644 index 0000000000..af47e6e0a2 --- /dev/null +++ b/idaes/core/base/extended_control_volume1d.py @@ -0,0 +1,121 @@ +################################################################################# +# 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. +################################################################################# +""" +1D Control Volume class with support for isothermal energy balance. +""" + +__author__ = "Andrew Lee" + +# Import Pyomo libraries +from pyomo.environ import Constraint, Expression + +# Import IDAES cores +from idaes.core.base.control_volume1d import ControlVolume1DBlockData +from idaes.core import declare_process_block_class +from idaes.core.util.exceptions import ConfigurationError + +import idaes.logger as idaeslog + +_log = idaeslog.getLogger(__name__) + + +@declare_process_block_class( + "ExtendedControlVolume1DBlock", + doc=""" + ExtendedControlVolume1DBlock is an extension of the ControlVolume1D + block with support for isothermal conditions in place of a formal + energy balance.""", +) +class ExtendedControlVolume1DBlockData(ControlVolume1DBlockData): + """ + Extended 1-Dimensional ControlVolume Class + + This class extends the existing ControlVolume1DBlockData class + with support for isothermal energy balances. + """ + + def add_isothermal_constraint( + self, + has_heat_of_reaction: bool = False, + has_heat_transfer: bool = False, + has_work_transfer: bool = False, + has_enthalpy_transfer: bool = False, + custom_term: Expression = None, + ) -> None: + """ + This method constructs an isothermal constraint for the control volume. + + Arguments are supported for compatibility with other forms but must be False + or None otherwise an Exception is raised. + + Args: + has_heat_of_reaction: whether terms for heat of reaction should + be included in enthalpy balance + has_heat_transfer: whether terms for heat transfer should be + included in enthalpy balances + has_work_transfer: whether terms for work transfer should be + included in enthalpy balances + has_enthalpy_transfer: whether terms for enthalpy transfer due to + mass transfer should be included in enthalpy balance. This + should generally be the same as the has_mass_transfer + argument in the material balance methods + custom_term: a Python method which returns Pyomo expressions representing + custom terms to be included in enthalpy balances. + Method should accept time and phase list as arguments. + + Returns: + Constraint object representing isothermal constraints + """ + if has_heat_transfer: + raise ConfigurationError( + f"{self.name}: isothermal energy balance option requires that has_heat_transfer is False. " + "If you are trying to solve for heat duty to achieve isothermal operation, please use " + "a full energy balance and add a constraint to equate inlet and outlet temperatures." + ) + if has_work_transfer: + raise ConfigurationError( + f"{self.name}: isothermal energy balance option requires that has_work_transfer is False. " + "If you are trying to solve for work under isothermal operation, please use " + "a full energy balance and add a constraint to equate inlet and outlet temperatures." + ) + if has_enthalpy_transfer: + raise ConfigurationError( + f"{self.name}: isothermal energy balance option does not support enthalpy transfer. " + ) + if has_heat_of_reaction: + raise ConfigurationError( + f"{self.name}: isothermal energy balance option requires that has_heat_of_reaction is False. " + "If you are trying to solve for heat duty to achieve isothermal operation, please use " + "a full energy balance and add a constraint to equate inlet and outlet temperatures." + ) + if custom_term is not None: + raise ConfigurationError( + f"{self.name}: isothermal energy balance option does not support custom terms. " + ) + + # Add isothermal constraint + @self.Constraint( + self.flowsheet().time, + self.length_domain, + doc="Isothermal constraint - replaces energy balances", + ) + def isothermal_constraint(b, t, x): + if x == b.length_domain.first(): + return Constraint.Skip + + return ( + b.properties[t, b.length_domain.prev(x)].temperature + == b.properties[t, x].temperature + ) + + return self.isothermal_constraint diff --git a/idaes/core/base/tests/test_control_volume_0d.py b/idaes/core/base/tests/test_control_volume_0d.py index 42b8c1b7cc..4b083430c8 100644 --- a/idaes/core/base/tests/test_control_volume_0d.py +++ b/idaes/core/base/tests/test_control_volume_0d.py @@ -2280,6 +2280,20 @@ def test_add_total_energy_balances(): m.fs.cv.add_total_energy_balances() +@pytest.mark.unit +def test_add_isothermal_energy_balances(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ControlVolume0DBlock(property_package=m.fs.pp) + + m.fs.cv.add_state_blocks(has_phase_equilibrium=True) + + with pytest.raises(BalanceTypeNotSupportedError): + m.fs.cv.add_isothermal_constraint() + + # ----------------------------------------------------------------------------- # Test add total pressure balances @pytest.mark.unit diff --git a/idaes/core/base/tests/test_control_volume_1d.py b/idaes/core/base/tests/test_control_volume_1d.py index b92d56c300..073ab5cce0 100644 --- a/idaes/core/base/tests/test_control_volume_1d.py +++ b/idaes/core/base/tests/test_control_volume_1d.py @@ -3442,6 +3442,26 @@ def test_add_total_energy_balances(): m.fs.cv.add_total_energy_balances() +@pytest.mark.unit +def test_add_isothermal_energy_balances(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ControlVolume1DBlock( + property_package=m.fs.pp, + transformation_method="dae.finite_difference", + transformation_scheme="BACKWARD", + finite_elements=10, + ) + + m.fs.cv.add_geometry() + m.fs.cv.add_state_blocks(has_phase_equilibrium=True) + + with pytest.raises(BalanceTypeNotSupportedError): + m.fs.cv.add_isothermal_constraint() + + # ----------------------------------------------------------------------------- # Test add total pressure balances @pytest.mark.unit diff --git a/idaes/core/base/tests/test_control_volume_base.py b/idaes/core/base/tests/test_control_volume_base.py index 7c282db1a1..9bd2b53e6e 100644 --- a/idaes/core/base/tests/test_control_volume_base.py +++ b/idaes/core/base/tests/test_control_volume_base.py @@ -55,7 +55,7 @@ def test_material_balance_type(): @pytest.mark.unit def test_energy_balance_type(): - assert len(EnergyBalanceType) == 6 + assert len(EnergyBalanceType) == 7 # Test that error is raised when given non-member with pytest.raises(AttributeError): diff --git a/idaes/core/base/tests/test_extended_control_volume_0d.py b/idaes/core/base/tests/test_extended_control_volume_0d.py new file mode 100644 index 0000000000..7bf71cca1b --- /dev/null +++ b/idaes/core/base/tests/test_extended_control_volume_0d.py @@ -0,0 +1,168 @@ +################################################################################# +# 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 ExtendedControlVolumeBlockData. + +Author: Andrew Lee +""" +import pytest +from pyomo.environ import ConcreteModel, Constraint, units +from pyomo.util.check_units import assert_units_consistent + +from idaes.core import ( + ExtendedControlVolume0DBlock, + FlowsheetBlockData, + declare_process_block_class, +) +from idaes.core.util.exceptions import ( + ConfigurationError, +) +from idaes.core.util.testing import ( + PhysicalParameterTestBlock, +) + + +# ----------------------------------------------------------------------------- +# Mockup classes for testing +@declare_process_block_class("Flowsheet") +class _Flowsheet(FlowsheetBlockData): + def build(self): + super(_Flowsheet, self).build() + + +@pytest.mark.unit +def test_add_isothermal_constraint(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume0DBlock(property_package=m.fs.pp) + + m.fs.cv.add_geometry() + m.fs.cv.add_state_blocks(has_phase_equilibrium=False) + + cons = m.fs.cv.add_isothermal_constraint() + + assert cons is m.fs.cv.isothermal_constraint + assert isinstance(m.fs.cv.isothermal_constraint, Constraint) + assert len(m.fs.cv.isothermal_constraint) == 1 + assert str(m.fs.cv.isothermal_constraint[0].expr) == str( + m.fs.cv.properties_in[0].temperature == m.fs.cv.properties_out[0].temperature + ) + + assert_units_consistent(m.fs.cv) + + +@pytest.mark.unit +def test_add_isothermal_constraint_dynamic(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=True, time_set=[0, 1, 2, 3], time_units=units.s) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume0DBlock(property_package=m.fs.pp) + + m.fs.cv.add_geometry() + m.fs.cv.add_state_blocks(has_phase_equilibrium=False) + + m.fs.cv.add_isothermal_constraint() + + assert isinstance(m.fs.cv.isothermal_constraint, Constraint) + assert len(m.fs.cv.isothermal_constraint) == 4 + for t in m.fs.time: + assert str(m.fs.cv.isothermal_constraint[t].expr) == str( + m.fs.cv.properties_in[t].temperature + == m.fs.cv.properties_out[t].temperature + ) + + assert_units_consistent(m.fs.cv) + + +@pytest.mark.unit +def test_add_isothermal_constraint_heat_transfer(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume0DBlock(property_package=m.fs.pp) + + with pytest.raises( + ConfigurationError, + match="fs.cv: isothermal energy balance option requires that has_heat_transfer is False. " + "If you are trying to solve for heat duty to achieve isothermal operation, please use " + "a full energy balance and add a constraint to equate inlet and outlet temperatures.", + ): + m.fs.cv.add_isothermal_constraint(has_heat_transfer=True) + + +@pytest.mark.unit +def test_add_isothermal_constraint_work_transfer(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume0DBlock(property_package=m.fs.pp) + + with pytest.raises( + ConfigurationError, + match="fs.cv: isothermal energy balance option requires that has_work_transfer is False. " + "If you are trying to solve for work under isothermal operation, please use " + "a full energy balance and add a constraint to equate inlet and outlet temperatures.", + ): + m.fs.cv.add_isothermal_constraint(has_work_transfer=True) + + +@pytest.mark.unit +def test_add_isothermal_constraint_enthalpy_transfer(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume0DBlock(property_package=m.fs.pp) + + with pytest.raises( + ConfigurationError, + match="fs.cv: isothermal energy balance option does not support enthalpy transfer.", + ): + m.fs.cv.add_isothermal_constraint(has_enthalpy_transfer=True) + + +@pytest.mark.unit +def test_add_isothermal_constraint_heat_of_rxn(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume0DBlock(property_package=m.fs.pp) + + with pytest.raises( + ConfigurationError, + match="fs.cv: isothermal energy balance option requires that has_heat_of_reaction is False. " + "If you are trying to solve for heat duty to achieve isothermal operation, please use " + "a full energy balance and add a constraint to equate inlet and outlet temperatures.", + ): + m.fs.cv.add_isothermal_constraint(has_heat_of_reaction=True) + + +@pytest.mark.unit +def test_add_isothermal_constraint_custom_term(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume0DBlock(property_package=m.fs.pp) + + with pytest.raises( + ConfigurationError, + match="fs.cv: isothermal energy balance option does not support custom terms.", + ): + m.fs.cv.add_isothermal_constraint(custom_term="foo") diff --git a/idaes/core/base/tests/test_extended_control_volume_1d.py b/idaes/core/base/tests/test_extended_control_volume_1d.py new file mode 100644 index 0000000000..90725bd940 --- /dev/null +++ b/idaes/core/base/tests/test_extended_control_volume_1d.py @@ -0,0 +1,241 @@ +################################################################################# +# 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 ExtendedControlVolumeBlockData. + +Author: Andrew Lee +""" +import pytest +from pyomo.environ import ConcreteModel, Constraint, units +from pyomo.util.check_units import assert_units_consistent + +from idaes.core import ( + ExtendedControlVolume1DBlock, + FlowsheetBlockData, + declare_process_block_class, +) +from idaes.core.util.exceptions import ( + ConfigurationError, +) +from idaes.core.util.testing import ( + PhysicalParameterTestBlock, +) +from idaes.core.util.model_diagnostics import DiagnosticsToolbox + + +# ----------------------------------------------------------------------------- +# Mockup classes for testing +@declare_process_block_class("Flowsheet") +class _Flowsheet(FlowsheetBlockData): + def build(self): + super(_Flowsheet, self).build() + + +@pytest.mark.unit +def test_add_isothermal_constraint(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume1DBlock( + property_package=m.fs.pp, + transformation_method="dae.finite_difference", + transformation_scheme="BACKWARD", + finite_elements=4, + ) + + m.fs.cv.add_geometry() + m.fs.cv.add_state_blocks(has_phase_equilibrium=False) + cons = m.fs.cv.add_isothermal_constraint() + m.fs.cv.apply_transformation() + + assert cons is m.fs.cv.isothermal_constraint + + assert isinstance(m.fs.cv.isothermal_constraint, Constraint) + assert ( + len(m.fs.cv.isothermal_constraint) == (5 - 1) * 1 + ) # x==0 so (5-1) spatial points and 1 time point + + assert (0, 0) not in m.fs.cv.isothermal_constraint + assert str(m.fs.cv.isothermal_constraint[0, 0.25].expr) == str( + m.fs.cv.properties[0, 0].temperature == m.fs.cv.properties[0, 0.25].temperature + ) + assert str(m.fs.cv.isothermal_constraint[0, 0.5].expr) == str( + m.fs.cv.properties[0, 0.25].temperature + == m.fs.cv.properties[0, 0.5].temperature + ) + assert str(m.fs.cv.isothermal_constraint[0, 0.75].expr) == str( + m.fs.cv.properties[0, 0.5].temperature + == m.fs.cv.properties[0, 0.75].temperature + ) + assert str(m.fs.cv.isothermal_constraint[0, 1].expr) == str( + m.fs.cv.properties[0, 0].temperature == m.fs.cv.properties[0, 1].temperature + ) + + # Fix inlet and check for unit consistency and structural singularities + m.fs.cv.properties[0, 0].temperature.fix() + dt = DiagnosticsToolbox(m) + dt.assert_no_structural_warnings() + + +@pytest.mark.unit +def test_add_isothermal_constraint_dynamic(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=True, time_set=[0, 1, 2, 3], time_units=units.s) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume1DBlock( + property_package=m.fs.pp, + transformation_method="dae.finite_difference", + transformation_scheme="BACKWARD", + finite_elements=4, + ) + + m.fs.cv.add_geometry() + m.fs.cv.add_state_blocks(has_phase_equilibrium=False) + m.fs.cv.add_isothermal_constraint() + m.fs.cv.apply_transformation() + + assert isinstance(m.fs.cv.isothermal_constraint, Constraint) + assert ( + len(m.fs.cv.isothermal_constraint) == (5 - 1) * 4 + ) # x==0 so (5-1) spatial points and 4 time points + + for t in m.fs.time: + assert (t, 0) not in m.fs.cv.isothermal_constraint + assert str(m.fs.cv.isothermal_constraint[t, 0.25].expr) == str( + m.fs.cv.properties[t, 0].temperature + == m.fs.cv.properties[t, 0.25].temperature + ) + assert str(m.fs.cv.isothermal_constraint[t, 0.5].expr) == str( + m.fs.cv.properties[t, 0.25].temperature + == m.fs.cv.properties[t, 0.5].temperature + ) + assert str(m.fs.cv.isothermal_constraint[t, 0.75].expr) == str( + m.fs.cv.properties[t, 0.5].temperature + == m.fs.cv.properties[t, 0.75].temperature + ) + assert str(m.fs.cv.isothermal_constraint[t, 1].expr) == str( + m.fs.cv.properties[t, 0].temperature == m.fs.cv.properties[t, 1].temperature + ) + + # Fix inlet and check for unit consistency and structural singularities + m.fs.cv.properties[:, 0].temperature.fix() + dt = DiagnosticsToolbox(m) + dt.assert_no_structural_warnings() + + +@pytest.mark.unit +def test_add_isothermal_constraint_heat_transfer(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume1DBlock( + property_package=m.fs.pp, + transformation_method="dae.finite_difference", + transformation_scheme="BACKWARD", + finite_elements=10, + ) + + with pytest.raises( + ConfigurationError, + match="fs.cv: isothermal energy balance option requires that has_heat_transfer is False. " + "If you are trying to solve for heat duty to achieve isothermal operation, please use " + "a full energy balance and add a constraint to equate inlet and outlet temperatures.", + ): + m.fs.cv.add_isothermal_constraint(has_heat_transfer=True) + + +@pytest.mark.unit +def test_add_isothermal_constraint_work_transfer(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume1DBlock( + property_package=m.fs.pp, + transformation_method="dae.finite_difference", + transformation_scheme="BACKWARD", + finite_elements=10, + ) + + with pytest.raises( + ConfigurationError, + match="fs.cv: isothermal energy balance option requires that has_work_transfer is False. " + "If you are trying to solve for work under isothermal operation, please use " + "a full energy balance and add a constraint to equate inlet and outlet temperatures.", + ): + m.fs.cv.add_isothermal_constraint(has_work_transfer=True) + + +@pytest.mark.unit +def test_add_isothermal_constraint_enthalpy_transfer(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume1DBlock( + property_package=m.fs.pp, + transformation_method="dae.finite_difference", + transformation_scheme="BACKWARD", + finite_elements=10, + ) + + with pytest.raises( + ConfigurationError, + match="fs.cv: isothermal energy balance option does not support enthalpy transfer.", + ): + m.fs.cv.add_isothermal_constraint(has_enthalpy_transfer=True) + + +@pytest.mark.unit +def test_add_isothermal_constraint_heat_of_rxn(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume1DBlock( + property_package=m.fs.pp, + transformation_method="dae.finite_difference", + transformation_scheme="BACKWARD", + finite_elements=10, + ) + + with pytest.raises( + ConfigurationError, + match="fs.cv: isothermal energy balance option requires that has_heat_of_reaction is False. " + "If you are trying to solve for heat duty to achieve isothermal operation, please use " + "a full energy balance and add a constraint to equate inlet and outlet temperatures.", + ): + m.fs.cv.add_isothermal_constraint(has_heat_of_reaction=True) + + +@pytest.mark.unit +def test_add_isothermal_constraint_custom_term(): + m = ConcreteModel() + m.fs = Flowsheet(dynamic=False) + m.fs.pp = PhysicalParameterTestBlock() + + m.fs.cv = ExtendedControlVolume1DBlock( + property_package=m.fs.pp, + transformation_method="dae.finite_difference", + transformation_scheme="BACKWARD", + finite_elements=10, + ) + + with pytest.raises( + ConfigurationError, + match="fs.cv: isothermal energy balance option does not support custom terms.", + ): + m.fs.cv.add_isothermal_constraint(custom_term="foo") diff --git a/idaes/core/util/model_diagnostics.py b/idaes/core/util/model_diagnostics.py index f701677d47..ab401f29aa 100644 --- a/idaes/core/util/model_diagnostics.py +++ b/idaes/core/util/model_diagnostics.py @@ -100,6 +100,13 @@ deactivated_objectives_set, variables_in_activated_constraints_set, variables_not_in_activated_constraints_set, + number_activated_greybox_equalities, + number_deactivated_greybox_equalities, + activated_greybox_block_set, + deactivated_greybox_block_set, + greybox_block_set, + unfixed_greybox_variables, + greybox_variables, degrees_of_freedom, large_residuals_set, variables_near_bounds_set, @@ -486,7 +493,10 @@ def __init__(self, model: BlockData, **kwargs): "model argument must be an instance of a Pyomo BlockData object " "(either a scalar Block or an element of an indexed Block)." ) - + if len(greybox_block_set(model)) != 0: + raise NotImplementedError( + "Model contains Greybox models, which are not supported by Diagnostics toolbox at the moment" + ) self._model = model self.config = CONFIG(kwargs) @@ -1746,7 +1756,12 @@ def report_structural_issues(self, stream=None): # Potential evaluation errors # TODO: High Index? + if len(greybox_block_set(self._model)) != 0: + raise NotImplementedError( + "Model contains Greybox models, which are not supported by Diagnostics toolbox at the moment" + ) stats = _collect_model_statistics(self._model) + warnings, next_steps = self._collect_structural_warnings() cautions = self._collect_structural_cautions() @@ -1790,7 +1805,6 @@ def report_numerical_issues(self, stream=None): """ if stream is None: stream = sys.stdout - jac, nlp = get_jacobian(self._model, scaled=False) warnings, next_steps = self._collect_numerical_warnings(jac=jac, nlp=nlp) @@ -1935,7 +1949,10 @@ def __init__(self, model: BlockData, **kwargs): "model argument must be an instance of a Pyomo BlockData object " "(either a scalar Block or an element of an indexed Block)." ) - + if len(greybox_block_set(model)) != 0: + raise NotImplementedError( + "Model contains Greybox models, which are not supported by Diagnostics toolbox at the moment" + ) self._model = model self.config = SVDCONFIG(kwargs) @@ -2377,7 +2394,10 @@ def __init__(self, model, **kwargs): "model argument must be an instance of a Pyomo BlockData object " "(either a scalar Block or an element of an indexed Block)." ) - + if len(greybox_block_set(model)) != 0: + raise NotImplementedError( + "Model contains Greybox models, which are not supported by Diagnostics toolbox at the moment" + ) self._model = model self.config = DHCONFIG(kwargs) @@ -3475,7 +3495,10 @@ def __init__(self, model, **kwargs): "model argument must be an instance of a Pyomo BlockData object " "(either a scalar Block or an element of an indexed Block)." ) - + if len(greybox_block_set(model)) != 0: + raise NotImplementedError( + "Model contains Greybox models, which are not supported by Diagnostics toolbox at the moment" + ) self.config = self.CONFIG(kwargs) self._model = model @@ -4402,8 +4425,8 @@ def _collect_model_statistics(model): f"(External: {len(ext_fixed_vars_in_constraints)})" ) stats.append( - f"{TAB}Activated Equality Constraints: {len(activated_equalities_set(model))} " - f"(Deactivated: {len(deactivated_equalities_set(model))})" + f"{TAB}Activated Equality Constraints: {len(activated_equalities_set(model))+number_activated_greybox_equalities(model)} " + f"(Deactivated: {len(deactivated_equalities_set(model))+number_deactivated_greybox_equalities(model)})" ) stats.append( f"{TAB}Activated Inequality Constraints: {len(activated_inequalities_set(model))} " @@ -4414,6 +4437,21 @@ def _collect_model_statistics(model): f"(Deactivated: {len(deactivated_objectives_set(model))})" ) + # Only show graybox info if they are present + if len(greybox_block_set(model)) != 0: + stats.append(f"{TAB}GreyBox Statistics") + stats.append( + f"{TAB* 2}Activated GreyBox models: {len(activated_greybox_block_set(model))} " + f"(Deactivated: {len(deactivated_greybox_block_set(model))})" + ) + stats.append( + f"{TAB* 2}Activated GreyBox Equalities: {number_activated_greybox_equalities(model)} " + f"(Deactivated: {number_deactivated_greybox_equalities(model)})" + ) + stats.append( + f"{TAB* 2}Free Variables in Activated GreyBox Equalities: {len(unfixed_greybox_variables(model))} (Fixed: {len(greybox_variables(model)-unfixed_greybox_variables(model))})" + ) + return stats diff --git a/idaes/core/util/model_statistics.py b/idaes/core/util/model_statistics.py index d237be3a52..fc1a7f80d4 100644 --- a/idaes/core/util/model_statistics.py +++ b/idaes/core/util/model_statistics.py @@ -25,6 +25,7 @@ from pyomo.core.expr import identify_variables from pyomo.common.collections import ComponentSet from pyomo.common.deprecation import deprecation_warning +from pyomo.contrib.pynumero.interfaces.external_grey_box import ExternalGreyBoxBlock import idaes.logger as idaeslog @@ -115,6 +116,104 @@ def activated_blocks_set(block): return block_set +def greybox_block_set(block): + """ + Function to return ComponentSet of all Greybox Blocks components in a + model. + + Args: + block : model to be studied + + Returns: + A ComponentSet including all GreyBox Block components in block + (including block itself) + """ + block_set = ComponentSet() + for grey_box in activated_block_component_generator( + block, ctype=ExternalGreyBoxBlock + ): + block_set.add(grey_box) + + return block_set + + +def activated_greybox_block_set(block): + """ + Function to return ComponentSet of activated Greybox Blocks components in a + model. + + Args: + block : model to be studied + + Returns: + A ComponentSet including all GreyBox Block components in block + (including block itself) + """ + block_set = ComponentSet() + for grey_box in greybox_block_set(block): + if grey_box.active: + block_set.add(grey_box) + + return block_set + + +def deactivated_greybox_block_set(block): + """ + Function to return ComponentSet of deactivated Greybox Blocks components in a + model. + + Args: + block : model to be studied + + Returns: + A ComponentSet including all GreyBox Block components in block + (including block itself) + """ + return greybox_block_set(block) - activated_greybox_block_set(block) + + +def number_deactivated_greybox_block(block): + """ + Function to return a Number of deactivated Greybox Blocks components in a + model. + + Args: + block : model to be studied + + Returns: + number of deactivated greybox blocks + """ + return len(deactivated_greybox_block_set(block)) + + +def number_greybox_blocks(block): + """ + Function to return a number of Greybox Blocks components in a + model. + + Args: + block : model to be studied + + Returns: + number of activated greybox blocks + """ + return len(greybox_block_set(block)) + + +def number_activated_greybox_blocks(block): + """ + Function to return a Number of activated Greybox Blocks components in a + model. + + Args: + block : model to be studied + + Returns: + number of activated greybox blocks + """ + return len(activated_greybox_block_set(block)) + + def number_activated_blocks(block): """ Method to return the number of activated Block components in a model. @@ -188,6 +287,8 @@ def total_constraints_set(block): def number_total_constraints(block): """ Method to return the total number of Constraint components in a model. + This will include the number of constraints provided by Greybox models using + the number_activated_greybox_equalities function. Args: block : model to be studied @@ -195,7 +296,11 @@ def number_total_constraints(block): Returns: Number of Constraint components in block """ - return sum(1 for _ in activated_block_component_generator(block, ctype=Constraint)) + number_standard_constraints = sum( + 1 for _ in activated_block_component_generator(block, ctype=Constraint) + ) + number_greybox_constraints = number_activated_greybox_equalities(block) + return number_standard_constraints + number_greybox_constraints def activated_constraints_generator(block): @@ -272,7 +377,8 @@ def deactivated_constraints_set(block): def number_deactivated_constraints(block): """ Method to return the number of deactivated Constraint components in a - model. + model. This will include number of deactivated equalities in a Greybox models + using number_deactivated_greybox_equalities function. Args: block : model to be studied @@ -280,7 +386,9 @@ def number_deactivated_constraints(block): Returns: Number of deactivated Constraint components in block """ - return sum(1 for _ in deactivated_constraints_generator(block)) + standard_equalities = sum(1 for _ in deactivated_constraints_generator(block)) + greybox_equalities = number_deactivated_greybox_equalities(block) + return standard_equalities + greybox_equalities # ------------------------------------------------------------------------- @@ -317,7 +425,7 @@ def total_equalities_set(block): def number_total_equalities(block): """ Method to return the total number of equality Constraint components in a - model. + model. This will include the number of activated equalities Greybox using the number_activated_greybox_equalities function. Args: block : model to be studied @@ -325,7 +433,9 @@ def number_total_equalities(block): Returns: Number of equality Constraint components in block """ - return sum(1 for _ in total_equalities_generator(block)) + standard_equalities = sum(1 for _ in total_equalities_generator(block)) + greybox_equalities = number_activated_greybox_equalities(block) + return standard_equalities + greybox_equalities def activated_equalities_generator(block): @@ -369,7 +479,7 @@ def activated_equalities_set(block): def number_activated_equalities(block): """ Method to return the number of activated equality Constraint components in - a model. + a model. This will include number of equalities in Greybox model using number_activated_greybox_equalities function. Args: block : model to be studied @@ -377,7 +487,53 @@ def number_activated_equalities(block): Returns: Number of activated equality Constraint components in block """ - return sum(1 for _ in activated_equalities_generator(block)) + return sum( + 1 for _ in activated_equalities_generator(block) + ) + number_activated_greybox_equalities(block) + + +def number_activated_greybox_equalities(block) -> int: + """ + Function to compute total number of equality constraints for all GreyBox objects in this block. + + A GreyBox model is always assumed to be 0DOFs where each output[i]==f(inputs) + where f is GreyBox model, this should be true regardless if + GreyBox model is doing internal optimization or not, as every output + is calculated through the GreyBox internal model using provided inputs. + + Args: + block : pyomo concrete model or pyomo block + + Returns: + Number of equality constraints in all GreyBox objects on the provided block + """ + equalities = 0 + for grey_box in activated_greybox_block_set(block): + equalities += len(grey_box.outputs) + equalities += grey_box.get_external_model().n_equality_constraints() + return equalities + + +def number_deactivated_greybox_equalities(block) -> int: + """ + Function to compute total number of equality constraints for all GreyBox objects in this block. + + A GreyBox model is always assumed to be 0DOFs where each output[i]==f(inputs) + where f is GreyBox model, this should be true regardless if + GreyBox model is doing internal optimization or not, as every output + is calculated through a the GreyBox internal model using provided inputs. + + Args: + block : pyomo concrete model or pyomo block + + Returns: + Number of equality constraints in all GreyBox objects on the provided block + """ + equalities = 0 + for grey_box in deactivated_greybox_block_set(block): + equalities += len(grey_box.outputs) + equalities += grey_box.get_external_model().n_equality_constraints() + return equalities def deactivated_equalities_generator(block): @@ -415,7 +571,7 @@ def deactivated_equalities_set(block): def number_deactivated_equalities(block): """ Method to return the number of deactivated equality Constraint components - in a model. + in a model. This will include the number of deactivated equality constraints in Greybox models. Args: block : model to be studied @@ -423,7 +579,9 @@ def number_deactivated_equalities(block): Returns: Number of deactivated equality Constraint components in block """ - return sum(1 for _ in deactivated_equalities_generator(block)) + standard_equalities = sum(1 for _ in deactivated_equalities_generator(block)) + greybox_equalities = number_deactivated_greybox_equalities(block) + return standard_equalities + greybox_equalities # ------------------------------------------------------------------------- @@ -529,7 +687,7 @@ def deactivated_inequalities_generator(block): block : model to be studied Returns: - A generator which returns all indeactivated equality Constraint + A generator which returns all deactivated equality Constraint components block """ for c in total_inequalities_generator(block): @@ -580,11 +738,14 @@ def variables_set(block): Returns: A ComponentSet including all Var components in block """ - return ComponentSet( - _iter_indexed_block_data_objects( - block, ctype=Var, active=True, descend_into=True - ) - ) + var_set = ComponentSet() + for var in _iter_indexed_block_data_objects( + block, ctype=Var, active=True, descend_into=True + ): + var_set.add(var) + for var in greybox_variables(block): + var_set.add(var) + return var_set def number_variables(block): @@ -615,6 +776,10 @@ def fixed_variables_generator(block): ): if v.fixed: yield v + # include greybox variables in set + for v in greybox_variables(block): + if v.fixed: + yield v def fixed_variables_set(block): @@ -825,6 +990,9 @@ def variables_in_activated_constraints_set(block): ): for v in identify_variables(c.body): var_set.add(v) + # include any vars in greyboxes + for v in greybox_variables(block): + var_set.add(v) return var_set @@ -898,6 +1066,9 @@ def variables_in_activated_equalities_set(block): for c in activated_equalities_generator(block): for v in identify_variables(c.body): var_set.add(v) + # include any vars in greyboxes + for v in greybox_variables(block): + var_set.add(v) return var_set @@ -1027,7 +1198,7 @@ def unfixed_variables_in_activated_equalities_set(block): block : model to be studied Returns: - A ComponentSet including all unfixed Var components which appear within + A ComponentSet of all unfixed Var components which appear within activated equality Constraints in block """ var_set = ComponentSet() @@ -1037,6 +1208,69 @@ def unfixed_variables_in_activated_equalities_set(block): return var_set +def unfixed_greybox_variables(block): + """ + Function to return a ComponentSet of all unfixed Var in GreyBoxModels + + Args: + block : model to be studied + + Returns: + A ComponentSet of all unfixed Var components which appear in Greybox models + """ + var_set = ComponentSet() + for var in greybox_variables(block): + if not var.fixed: + var_set.add(var) + return var_set + + +def greybox_variables(block): + """ + Function to return a ComponentSet of all Var in GreyBoxModels + + Args: + block : model to be studied + + Returns: + A ComponentSet of all Var components which appear within + activated Greybox model blocks + """ + var_set = ComponentSet() + for grey_box in activated_greybox_block_set(block): + for in_var in grey_box.inputs: + var_set.add(grey_box.inputs[in_var]) + for out_var in grey_box.outputs: + var_set.add(grey_box.outputs[out_var]) + return var_set + + +def number_of_unfixed_greybox_variables(block): + """ + Function to return a number of unfixed variables in grey box + Args: + block : model to be studied + + Returns: + number of unfixed greybox variables + """ + + return len(unfixed_greybox_variables(block)) + + +def number_of_greybox_variables(block): + """ + Function to return a number of variables in grey box + Args: + block : model to be studied + + Returns: + number of greybox variables + """ + + return len(greybox_variables(block)) + + def number_unfixed_variables_in_activated_equalities(block): """ Method to return the number of unfixed Var components which appear within @@ -1112,7 +1346,7 @@ def number_unused_variables(block): block : model to be studied Returns: - Number of Var components which do not appear within any activagted + Number of Var components which do not appear within any activated Constraints in block """ return len(unused_variables_set(block)) @@ -1574,6 +1808,17 @@ def report_statistics(block, ostream=None): f"{number_deactivated_blocks(block)}) \n" ) ostream.write(f"No. Expressions: " f"{number_expressions(block)} \n") + if number_activated_greybox_blocks(block) != 0: + ostream.write( + f"No. Activated GreyBox Blocks: {number_activated_greybox_blocks(block)} \n" + ) + ostream.write(f"No. GreyBox Variables: {number_of_greybox_variables(block)} \n") + ostream.write( + f"No. Fixed GreyBox Variables: {number_of_greybox_variables(block)-number_of_unfixed_greybox_variables(block)} \n" + ) + ostream.write( + f"No. GreyBox Equalities: {number_activated_greybox_equalities(block)} \n" + ) ostream.write(header + "\n") ostream.write("\n") diff --git a/idaes/core/util/tests/test_model_diagnostics.py b/idaes/core/util/tests/test_model_diagnostics.py index e10f8a7c57..d5d4c21d60 100644 --- a/idaes/core/util/tests/test_model_diagnostics.py +++ b/idaes/core/util/tests/test_model_diagnostics.py @@ -17,6 +17,11 @@ from io import StringIO import math import os +from copy import deepcopy +from pyomo.contrib.pynumero.interfaces.external_grey_box import ( + ExternalGreyBoxBlock, + ExternalGreyBoxModel, +) import re from unittest import TestCase @@ -468,6 +473,70 @@ def test_fixed_variables(self): assert stats[7] == " Activated Inequality Constraints: 0 (Deactivated: 0)" assert stats[8] == " Activated Objectives: 0 (Deactivated: 0)" + def test_with_greybox_variables(self): + """non functional graybox model added to m fixture, to test DOFs + + GreyBoxModel has 3 inputs and 2 outputs calculated an unknown function, + input a1 and a2 are bound by equality constraint through internal graybox model + """ + + class BasicGrayBox(ExternalGreyBoxModel): + def input_names(self): + return ["a1", "a2", "a3"] + + def output_names(self): + return ["o1", "o2"] + + def equality_constraint_names(self): + return ["a_sum"] + + def evaluate_equality_constraints(self): + a1 = self._input_values[0] + a2 = self._input_values[1] + return [a1 * 0.5 + a2] + + m = ConcreteModel() + + m.gb = ExternalGreyBoxBlock(external_model=BasicGrayBox()) + m.gb_inactive = ExternalGreyBoxBlock(external_model=BasicGrayBox()) + m.gb_inactive.deactivate() + m.a1 = Var(initialize=1) + m.a1.fix() + m.gb.inputs["a2"].unfix() + m.gb.inputs["a3"].fix() + m.a1_eq = Constraint(expr=m.a1 == m.gb.inputs["a1"]) + m.o1 = Var(initialize=1) + m.o1_eq = Constraint(expr=m.o1 == m.gb.outputs["o1"]) + m.o1.fix() + stats = _collect_model_statistics(m) + for k in stats: + print(k) + print(stats, len(stats)) + m.display() + tab = " " * 4 + assert len(stats) == 13 + assert stats[0] == f"{tab}Activated Blocks: 1 (Deactivated: 0)" + assert ( + stats[1] == f"{tab}Free Variables in Activated Constraints: 4 (External: 0)" + ) + assert stats[2] == f"{tab*2}Free Variables with only lower bounds: 0" + assert stats[3] == f"{tab*2}Free Variables with only upper bounds: 0" + assert stats[4] == f"{tab*2}Free Variables with upper and lower bounds: 0" + assert ( + stats[5] + == f"{tab}Fixed Variables in Activated Constraints: 3 (External: 0)" + ) + assert stats[6] == f"{tab}Activated Equality Constraints: 5 (Deactivated: 3)" + assert stats[7] == f"{tab}Activated Inequality Constraints: 0 (Deactivated: 0)" + assert stats[8] == f"{tab}Activated Objectives: 0 (Deactivated: 0)" + assert stats[9] == f"{tab}GreyBox Statistics" + assert stats[10] == f"{tab*2}Activated GreyBox models: 1 (Deactivated: 1)" + assert stats[11] == f"{tab*2}Activated GreyBox Equalities: 3 (Deactivated: 3)" + assert ( + stats[12] + == f"{tab*2}Free Variables in Activated GreyBox Equalities: 4 (Fixed: 1)" + ) + @pytest.mark.solver class TestDiagnosticsToolbox: @@ -527,6 +596,30 @@ def model(self): return m + @pytest.mark.component + def test_with_grey_box(self): + + class BasicGrayBox(ExternalGreyBoxModel): + def input_names(self): + return ["a1", "a2", "a3"] + + def output_names(self): + return ["o1", "o2"] + + def equality_constraint_names(self): + return ["a_sum"] + + def evaluate_equality_constraints(self): + a1 = self._input_values[0] + a2 = self._input_values[1] + return [a1 * 0.5 + a2] + + m = ConcreteModel() + + m.gb = ExternalGreyBoxBlock(external_model=BasicGrayBox()) + with pytest.raises(NotImplementedError): + DiagnosticsToolbox(model=m) + @pytest.mark.component def test_display_external_variables(self, model): dt = DiagnosticsToolbox(model=model.b) @@ -1842,6 +1935,30 @@ def test_svd_callback_domain(self, dummy_problem): svd = SVDToolbox(dummy_problem, svd_callback=dummy_callback2) assert svd.config.svd_callback is dummy_callback2 + @pytest.mark.component + def test_with_grey_box(self): + + class BasicGrayBox(ExternalGreyBoxModel): + def input_names(self): + return ["a1", "a2", "a3"] + + def output_names(self): + return ["o1", "o2"] + + def equality_constraint_names(self): + return ["a_sum"] + + def evaluate_equality_constraints(self): + a1 = self._input_values[0] + a2 = self._input_values[1] + return [a1 * 0.5 + a2] + + m = ConcreteModel() + + m.gb = ExternalGreyBoxBlock(external_model=BasicGrayBox()) + with pytest.raises(NotImplementedError): + SVDToolbox(model=m) + @pytest.mark.unit def test_init(self, dummy_problem): svd = SVDToolbox(dummy_problem) @@ -2291,6 +2408,30 @@ def test_init(self, model): assert dh.degenerate_set == {} assert dh.irreducible_degenerate_sets == [] + @pytest.mark.component + def test_with_grey_box(self): + + class BasicGrayBox(ExternalGreyBoxModel): + def input_names(self): + return ["a1", "a2", "a3"] + + def output_names(self): + return ["o1", "o2"] + + def equality_constraint_names(self): + return ["a_sum"] + + def evaluate_equality_constraints(self): + a1 = self._input_values[0] + a2 = self._input_values[1] + return [a1 * 0.5 + a2] + + m = ConcreteModel() + + m.gb = ExternalGreyBoxBlock(external_model=BasicGrayBox()) + with pytest.raises(NotImplementedError): + DegeneracyHunter2(model=m) + @pytest.mark.unit def test_get_solver(self, model): dh = DegeneracyHunter2(model, solver="ipopt", solver_options={"maxiter": 50}) @@ -2548,6 +2689,30 @@ def model(self): return m + @pytest.mark.component + def test_with_grey_box(self): + + class BasicGrayBox(ExternalGreyBoxModel): + def input_names(self): + return ["a1", "a2", "a3"] + + def output_names(self): + return ["o1", "o2"] + + def equality_constraint_names(self): + return ["a_sum"] + + def evaluate_equality_constraints(self): + a1 = self._input_values[0] + a2 = self._input_values[1] + return [a1 * 0.5 + a2] + + m = ConcreteModel() + + m.gb = ExternalGreyBoxBlock(external_model=BasicGrayBox()) + with pytest.raises(NotImplementedError): + IpoptConvergenceAnalysis(model=m) + @pytest.mark.unit def test_init(self, model): ca = IpoptConvergenceAnalysis(model) diff --git a/idaes/core/util/tests/test_model_statistics.py b/idaes/core/util/tests/test_model_statistics.py index 209ac4af1c..618e9666a6 100644 --- a/idaes/core/util/tests/test_model_statistics.py +++ b/idaes/core/util/tests/test_model_statistics.py @@ -31,6 +31,10 @@ from idaes.core.util.model_statistics import * from idaes.core.util.model_statistics import _iter_indexed_block_data_objects +from pyomo.contrib.pynumero.interfaces.external_grey_box import ( + ExternalGreyBoxBlock, + ExternalGreyBoxModel, +) @pytest.mark.unit @@ -685,6 +689,68 @@ def test_degrees_of_freedom(m): assert degrees_of_freedom(m.b2) == -1 +@pytest.mark.unit +def test_degrees_of_freedom_with_graybox(): + """non functional graybox model added to m fixture, to test DOFs + + GreyBoxModel has 3 inputs and 2 outputs calculated an unknown function, + input a1 and a2 are bound by equality constraint through internal graybox model""" + + class BasicGrayBox(ExternalGreyBoxModel): + def input_names(self): + return ["a1", "a2", "a3"] + + def output_names(self): + return ["o1", "o2"] + + def equality_constraint_names(self): + return ["a_sum"] + + def evaluate_equality_constraints(self): + a1 = self._input_values[0] + a2 = self._input_values[1] + return [a1 * 0.5 + a2] + + m = ConcreteModel() + + m.gb = ExternalGreyBoxBlock(external_model=BasicGrayBox()) + m.gb_inactive = ExternalGreyBoxBlock(external_model=BasicGrayBox()) + m.gb_inactive.deactivate() + # test counting functions + assert number_greybox_blocks(m) == 2 + assert number_deactivated_greybox_block(m) == 1 + assert number_activated_greybox_blocks(m) == 1 + assert number_of_greybox_variables(m) == 5 + assert number_of_unfixed_greybox_variables(m) == 5 + assert number_activated_greybox_equalities(m) == 3 + assert number_variables_in_activated_constraints(m) == 5 + # verify DOFS works on stand alone greybox + assert degrees_of_freedom(m) == 2 + m.gb.inputs.fix() + m.gb.inputs["a1"].unfix() + assert number_of_unfixed_greybox_variables(m) == 3 + assert degrees_of_freedom(m) == 0 + m.gb.outputs.fix() + assert degrees_of_freedom(m) == -2 + m.gb.outputs.unfix() + + # verify DOFs works on greybox connected to other vars on a model via constraints + m.a1 = Var(initialize=1) + m.a1.fix() + m.gb.inputs["a2"].unfix() + m.a1_eq = Constraint(expr=m.a1 == m.gb.inputs["a1"]) + assert degrees_of_freedom(m) == 0 + m.o1 = Var(initialize=1) + m.o1_eq = Constraint(expr=m.o1 == m.gb.outputs["o1"]) + m.o1.fix() + assert degrees_of_freedom(m) == -1 + assert number_variables_in_activated_constraints(m) == 7 + assert number_total_constraints(m) == 5 + assert number_total_equalities(m) == 5 + assert number_deactivated_equalities(m) == 3 + assert number_deactivated_constraints(m) == 3 + + @pytest.mark.unit def test_large_residuals_set(m): # Initialize derivative var values so no errors occur