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