Source code for idaes.core.base.unit_model

#################################################################################
# The Institute for the Design of Advanced Energy Systems Integrated Platform
# Framework (IDAES IP) was produced under the DOE Institute for the
# Design of Advanced Energy Systems (IDAES).
#
# Copyright (c) 2018-2023 by the software owners: The Regents of the
# University of California, through Lawrence Berkeley National Laboratory,
# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon
# University, West Virginia University Research Corporation, et al.
# All rights reserved.  Please see the files COPYRIGHT.md and LICENSE.md
# for full copyright and license information.
#################################################################################
"""
Base class for unit models
"""

from pyomo.environ import check_optimal_termination
from pyomo.common.config import ConfigValue
from pyomo.network import Port

from idaes.core.base.process_base import (
    declare_process_block_class,
    ProcessBlockData,
    useDefault,
)
from idaes.core.base.property_base import StateBlock
from idaes.core.base.control_volume_base import (
    ControlVolumeBlockData,
    FlowDirection,
    MaterialBalanceType,
)
from idaes.core.util.exceptions import (
    BurntToast,
    ConfigurationError,
    BalanceTypeNotSupportedError,
    InitializationError,
)
from idaes.core.util.tables import create_stream_table_dataframe
import idaes.logger as idaeslog
from idaes.core.solvers import get_solver
from idaes.core.util.config import DefaultBool
from idaes.core.initialization import SingleControlVolumeUnitInitializer


__author__ = "John Eslick, Qi Chen, Andrew Lee"


__all__ = ["UnitModelBlockData", "UnitModelBlock"]

# Set up logger
_log = idaeslog.getLogger(__name__)


[docs]@declare_process_block_class("UnitModelBlock") class UnitModelBlockData(ProcessBlockData): """ This is the class for process unit operations models. These are models that would generally appear in a process flowsheet or superstructure. """ # Set default initializer default_initializer = SingleControlVolumeUnitInitializer # Create Class ConfigBlock CONFIG = ProcessBlockData.CONFIG() CONFIG.declare( "dynamic", ConfigValue( default=useDefault, domain=DefaultBool, description="Dynamic model flag", doc="""Indicates whether this model will be dynamic or not, **default** = useDefault. **Valid values:** { **useDefault** - get flag from parent (default = False), **True** - set as a dynamic model, **False** - set as a steady-state model.}""", ), ) CONFIG.declare( "has_holdup", ConfigValue( default=useDefault, domain=DefaultBool, description="Holdup construction flag", doc="""Indicates whether holdup terms should be constructed or not. Must be True if dynamic = True, **default** - False. **Valid values:** { **useDefault** - get flag from parent (default = False), **True** - construct holdup terms, **False** - do not construct holdup terms}""", ), )
[docs] def build(self): """ General build method for UnitModelBlockData. This method calls a number of sub-methods which automate the construction of expected attributes of unit models. Inheriting models should call `super().build`. Args: None Returns: None """ super(UnitModelBlockData, self).build() # Add a placeholder for initialization order self._initialization_order = [] # Set up dynamic flag and time domain self._setup_dynamics()
[docs] def model_check(blk): """ This is a general purpose initialization routine for simple unit models. This method assumes a single ControlVolume block called controlVolume and tries to call the model_check method of the controlVolume block. If an AttributeError is raised, the check is passed. More complex models should overload this method with a model_check suited to the particular application, especially if there are multiple ControlVolume blocks present. Args: None Returns: None """ # Run control volume block model checks try: blk.controlVolume.model_check() except AttributeError: pass
[docs] def add_port(self, name, block, doc=None): """ This is a method to build Port objects in a unit model and connect these to a specified StateBlock. Keyword Args: name : name to use for Port object. block : an instance of a StateBlock to use as the source to populate the Port object doc : doc string for Port object, default = None. Returns: A Pyomo Port object and associated components. """ # Create Port try: port, ref_name_list = block.build_port(doc) except AttributeError: raise ConfigurationError( f"{self.name} block object provided to add_port method is not an " f"instance of a StateBlock object (does not have a build_port method)." ) # Add Port and References to unit model self.add_component(name, port) for ref, cname in ref_name_list: ref_name = block.get_port_reference_name(cname, name) self.add_component(ref_name, ref) return port
[docs] def add_inlet_port(self, name=None, block=None, doc=None): """ This is a method to build inlet Port objects in a unit model and connect these to a specified control volume or state block. The name and block arguments are optional, but must be used together. i.e. either both arguments are provided or neither. Keyword Args: name : name to use for Port object (default = "inlet"). block : an instance of a ControlVolume or StateBlock to use as the source to populate the Port object. If a ControlVolume is provided, the method will use the inlet state block as defined by the ControlVolume. If not provided, method will attempt to default to an object named control_volume. doc : doc string for Port object (default = "Inlet Port") Returns: A Pyomo Port object and associated components. """ if block is None: # Check that name is None if name is not None: raise ConfigurationError( "{} add_inlet_port was called without a block argument" " but a name argument was provided. Either both " "a name and a block must be provided or neither.".format(self.name) ) else: name = "inlet" # Try for default ControlVolume name try: block = self.control_volume except AttributeError: raise ConfigurationError( "{} add_inlet_port was called without a block argument" " but no default ControlVolume exists " "(control_volume). Please provide block to which the " "Port should be associated.".format(self.name) ) else: # Check that name is not None if name is None: raise ConfigurationError( "{} add_inlet_port was called with a block argument, " "but a name argument was not provided. Either both " "a name and a block must be provided or neither.".format(self.name) ) if doc is None: doc = "Inlet Port" # Determine type of source block if isinstance(block, ControlVolumeBlockData): # Work out if this is a 0D or 1D block try: # Try 0D first sblock = block.properties_in port, ref_name_list = sblock.build_port(doc) except AttributeError: # Otherwise a 1D control volume try: sblock = block.properties # Need to determine correct subset of indices to add to Port # Look at private attributes on control volume # pylint: disable-next=protected-access if block._flow_direction == FlowDirection.forward: _idx = block.length_domain.first() # pylint: disable-next=protected-access elif block._flow_direction == FlowDirection.backward: _idx = block.length_domain.last() port, ref_name_list = sblock.build_port( doc, slice_index=(slice(None), _idx) ) except AttributeError: raise ConfigurationError( f"{self.name} - control volume does not have expected " f"names for StateBlocks. Please check that the control " f"volume was constructed correctly." ) else: # Assume a StateBlock indexed only by time sblock = block port, ref_name_list = sblock.build_port(doc) # Add Port and References to unit model self.add_component(name, port) for ref, cname in ref_name_list: ref_name = sblock.get_port_reference_name(cname, name) self.add_component(ref_name, ref) return port
[docs] def add_outlet_port(self, name=None, block=None, doc=None): """ This is a method to build outlet Port objects in a unit model and connect these to a specified control volume or state block. The name and block arguments are optional, but must be used together. i.e. either both arguments are provided or neither. Keyword Args: name : name to use for Port object (default = "outlet"). block : an instance of a ControlVolume or StateBlock to use as the source to populate the Port object. If a ControlVolume is provided, the method will use the outlet state block as defined by the ControlVolume. If not provided, method will attempt to default to an object named control_volume. doc : doc string for Port object (default = "Outlet Port") Returns: A Pyomo Port object and associated components. """ if block is None: # Check that name is None if name is not None: raise ConfigurationError( "{} add_outlet_port was called without a block " "argument but a name argument was provided. Either " "both a name and a block must be provided or neither.".format( self.name ) ) else: name = "outlet" # Try for default ControlVolume name try: block = self.control_volume except AttributeError: raise ConfigurationError( "{} add_outlet_port was called without a block " "argument but no default ControlVolume exists " "(control_volume). Please provide block to which the " "Port should be associated.".format(self.name) ) else: # Check that name is not None if name is None: raise ConfigurationError( "{} add_outlet_port was called with a block argument, " "but a name argument was not provided. Either both " "a name and a block must be provided or neither.".format(self.name) ) if doc is None: doc = "Outlet Port" # Determine type of source block if isinstance(block, ControlVolumeBlockData): # Work out if this is a 0D or 1D block try: # Try 0D first sblock = block.properties_out port, ref_name_list = sblock.build_port(doc) except AttributeError: # Otherwise a 1D control volume try: sblock = block.properties # Need to determine correct subset of indices to add to Port # Look at private attribute on control volume # pylint: disable-next=protected-access if block._flow_direction == FlowDirection.backward: _idx = block.length_domain.first() # pylint: disable-next=protected-access elif block._flow_direction == FlowDirection.forward: _idx = block.length_domain.last() port, ref_name_list = sblock.build_port( doc, slice_index=(slice(None), _idx) ) except AttributeError: raise ConfigurationError( f"{self.name} - control volume does not have expected " f"names for StateBlocks. Please check that the control " f"volume was constructed correctly." ) else: # Assume a StateBlock indexed only by time sblock = block port, ref_name_list = sblock.build_port(doc) # Add Port and References to unit moodel self.add_component(name, port) for ref, cname in ref_name_list: ref_name = sblock.get_port_reference_name(cname, name) self.add_component(ref_name, ref) return port
[docs] def add_state_material_balances(self, balance_type, state_1, state_2): """ Method to add material balances linking two State Blocks in a Unit Model. This method is not intended to replace Control Volumes, but to automate writing material balances linking isolated State Blocks in those models where this is required. Args: balance_type - a MaterialBalanceType Enum indicating the type of material balances to write state_1 - first State Block to be linked by balances state_2 - second State Block to be linked by balances Returns: None """ # Confirm that both state blocks stem from the same parameter block if not isinstance(state_1, StateBlock): raise ConfigurationError( "{} state_1 argument to add_state_material_balances " "was not an instance of a State Block.".format(self.name) ) if not isinstance(state_2, StateBlock): raise ConfigurationError( "{} state_2 argument to add_state_material_balances " "was not an instance of a State Block.".format(self.name) ) # Check that no constraint with the same name exists # We will only support using this method once per Block if hasattr(self, "state_material_balances"): raise AttributeError( "{} a set of constraints named state_material_balances " "already exists in the current UnitModel. To avoid " "confusion, add_state_material_balances is only supported " "once per UnitModel.".format(self.name) ) # Get a representative time point for testing rep_time = self.flowsheet().time.first() if state_1[rep_time].params is not state_2[rep_time].params: raise ConfigurationError( "{} add_state_material_balances method was provided with " "State Blocks are not linked to the same " "instance of a Physical Parameter Block. This method " "only supports linking State Blocks from the same " "Physical Parameter Block.".format(self.name) ) if balance_type == MaterialBalanceType.useDefault: balance_type = state_1[rep_time].default_material_balance_type() phase_list = state_1.phase_list component_list = state_1.component_list pc_set = state_1.phase_component_set if balance_type == MaterialBalanceType.componentPhase: # TODO : Should we include an optional phase equilibrium term here # to allow for systems where a phase-transition may occur? @self.Constraint( self.flowsheet().time, pc_set, doc="State material balances", ) def state_material_balances(b, t, p, j): return state_1[t].get_material_flow_terms(p, j) == state_2[ t ].get_material_flow_terms(p, j) elif balance_type == MaterialBalanceType.componentTotal: @self.Constraint( self.flowsheet().time, component_list, doc="State material balances", ) def state_material_balances(b, t, j): return sum( state_1[t].get_material_flow_terms(p, j) for p in phase_list if (p, j) in pc_set ) == sum( state_2[t].get_material_flow_terms(p, j) for p in phase_list if (p, j) in pc_set ) elif balance_type == MaterialBalanceType.total: @self.Constraint( self.flowsheet().time, doc="State material balances", ) def state_material_balances(b, t): return sum( state_1[t].get_material_flow_terms(p, j) for p, j in pc_set ) == sum(state_2[t].get_material_flow_terms(p, j) for p, j in pc_set) elif balance_type == MaterialBalanceType.elementTotal: raise BalanceTypeNotSupportedError( "{} add_state_material_balances does not support " "MaterialBalanceType.elementTotal.".format(self.name) ) elif balance_type == MaterialBalanceType.none: raise BalanceTypeNotSupportedError( "{} add_state_material_balances does not support " "MaterialBalanceType.None.".format(self.name) ) else: raise BurntToast( "{} add_state_material_balances received an unexpected " "argument for balance_type. This should never happen. Please " "contact the IDAES developers with this bug.".format(self.name) )
def _get_stream_table_contents(self, time_point=0): """ Assume unit has standard configuration of 1 inlet and 1 outlet. Developers should overload this as appropriate. """ try: return create_stream_table_dataframe( {"Inlet": self.inlet, "Outlet": self.outlet}, time_point=time_point ) except AttributeError: raise ConfigurationError( f"Unit model {self.name} does not have the standard Port " f"names (inlet and outlet). Please contact the unit model " f"developer to develop a unit specific stream table." )
[docs] def initialize(blk, *args, **kwargs): """ Initialization routine for Unit Model objects and associated components. This method is intended for initializing IDAES unit models and any modeling components attached to them, such as costing blocks. This method iterates through all objects in blk._initialization_order and deactivates them, followed by calling blk.initialize_build. Finally, it iterates through all objects in blk._initialization_order in reverse and re-activates these whilst calling the associated initialize method. Currently, parsing of arguments to the initialize method of attached blocks is hard coded - this will be addressed in a future PR. Currently, the method supports the following attached components: * UnitModelCostingBlocks Args: costing_args - dict arguments to be passed to costing block initialize method For other arguments, see the initialize_unit method. """ # Get any arguments for costing if provided cost_args = kwargs.pop("costing_args", {}) # Get the costing block if present # TODO: Clean up in IDAES v2.0 init_order = blk._initialization_order if hasattr(blk, "costing") and blk.costing not in blk._initialization_order: # Fallback for older style costing init_order.append(blk.costing) # If costing block exists, deactivate for c in init_order: c.deactivate() # Remember to collect flags for fixed vars flags = blk.initialize_build(*args, **kwargs) # If costing block exists, activate and initialize for c in init_order: c.activate() if hasattr(c, "initialize"): c.initialize(**cost_args) # Return any flags returned by initialize_build return flags
[docs] def initialize_build( blk, state_args=None, outlvl=idaeslog.NOTSET, solver=None, optarg=None ): """ This is a general purpose initialization routine for simple unit models. This method assumes a single ControlVolume block called controlVolume, and first initializes this and then attempts to solve the entire unit. More complex models should overload this method with their own initialization routines, Keyword Arguments: state_args : a dict of arguments to be passed to the property package(s) to provide an initial state for initialization (see documentation of the specific property package) (default = {}). outlvl : sets output level of initialization routine optarg : solver options dictionary object (default=None, use default solver options) solver : str indicating which solver to use during initialization (default = None, use default IDAES solver) Returns: None """ if optarg is None: optarg = {} # Set solver options init_log = idaeslog.getInitLogger(blk.name, outlvl, tag="unit") solve_log = idaeslog.getSolveLogger(blk.name, outlvl, tag="unit") opt = get_solver(solver, optarg) # --------------------------------------------------------------------- # Initialize control volume block flags = blk.control_volume.initialize( outlvl=outlvl, optarg=optarg, solver=solver, state_args=state_args, ) init_log.info_high("Initialization Step 1 Complete.") # --------------------------------------------------------------------- # Solve unit with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc: results = opt.solve(blk, tee=slc.tee) init_log.info_high( "Initialization Step 2 {}.".format(idaeslog.condition(results)) ) # --------------------------------------------------------------------- # Release Inlet state blk.control_volume.release_state(flags, outlvl) if not check_optimal_termination(results): raise InitializationError( f"{blk.name} failed to initialize successfully. Please check " f"the output logs for more information." ) init_log.info("Initialization Complete: {}".format(idaeslog.condition(results))) return None
[docs] def del_component(self, name_or_object): """ Delete a component from this block. Need to introduce code to handle un-registering costing blocks """ obj = self.component(name_or_object) # TODO: See if Pyomo can give us a call-back to do this try: # If this is a costing block, need to unregister it obj.del_costing() except AttributeError: pass super().del_component(obj)
[docs] def fix_initialization_states(self): """ Attempts to fix inlet states by iterating over all Ports and looking for "inlet" in the name. More complex models may need to overload this with a model-specific method. Returns: None """ for p in self.component_objects(Port, descend_into=False): if "inlet" in p.name: # Assume name is correct and fix all variables inside p.fix()