Tutorial 3 – Advanced Flowsheets

Introduction

The previous flowsheets have demonstrated how to construct a flowsheet for a simple process with two units. However, actual processes of interest are generally far more complicated and involve many units and potential many different sets of property calculations.

In this tutorial we will we will add heat exchangers after each of the CSTRs from the previous tutorials to reduce the temperature of the reactant streams. For this, we will need to include a second property package for the cooling water to be used in the heat exchangers.

../../_images/2cstrshx.png

This tutorial will teach you how to:

  • Add multiple property packages to a flowsheet,
  • Assign a default property package to use within a flowsheet
  • Assign different property packages to different unit models
  • Create and utilize sub-flowsheets
  • Adjust variable bounds
  • Use the model_check utility

Initial Setup

Firstly, we need to import the necessary libraries we will use for our Flowsheet. This is much the same as for the last tutorial, with the addition of importing the IDAES Heat Exchanger, as well as a property package for the cooling water. A set of property calculations has been prepared for this and is available at idaes/examples/core/tutorials/tutorial_3_H2O_properties.py.

from pyomo.environ import ConcreteModel, SolverFactory

from idaes.core import FlowsheetBlock, Stream

import tutorial_1_properties as rxn_properties
import tutorial_3_H2O_properties as H2O_properties

from idaes.models import Feed, CSTR, HeatExchanger, Product

Next, we need to create our ConcreteModel and Flowsheet.

m = ConcreteModel()
m.fs = FlowsheetBlock(dynamic=False)

Processes with Multiple Property Packages

Most real chemical processes involve multiple groups of streams which have limited interaction with other streams. Some common examples are hot and cold utility streams which never come into direct contact with the other process streams, yet play an important role in the overall process performance.

These different groups of streams likely contain different chemical components and have different thermophysical properties which need to be calculated. Thus, we often want to include multiple sets of property calculations within a single flowsheet to represent these different groups of streams. The IDAES framework fully supports flowsheets with multiple property packages, and facilitates assigning different property packages to different unit models.

Multiple property packages can be assigned to a flowsheet in the same manner as before, as long as each has a unique name. For this tutorial, we will name our property packages rxn_properties and H2O_properties.

m.fs.rxn_properties = rxn_properties.PropertyParameterBlock()
m.fs.H2O_properties = H2O_properties.PropertyParameterBlock()

IDAES Flowsheets also support the assignment of a default property package, which is used by unit models if they are not assigned a property package at creation. The default property package for a flowsheet can be set by assigning the value of flowsheet.config.default_property_package to the desired property parameter block. For our flowsheet, let us assign m.fs.rxn_properties as the default property package.

m.fs.config.default_property_package = m.fs.rxn_properties

Next, let us add the Feed Block, first reactor and first heat exchanger to our flowsheet, which we will call Feed, Tank1 and HX1. Adding the Feed Block and CSTRs has been covered in the previous tutorials, so we will focus on the heat exchanger models here. Like the other models, the heat exchanger model can also be given a number of arguments as instructions on how to construct the model. The most important of these are the property packages for each side of the heat exchanger; side_1_property_package and side_2_property_package. We will not worry about the other options in this tutorial.

As we have assigned rxn_properties as the default property package for our flowsheet, we do not need to assign it whilst adding unit models to our flowsheet (as long as they are to use this property package). Thus, for the CSTR and side 1 of our heat exchanger, we do not need to assign a property package. Thus, all we need to do to add Feed, Tank and HX1 is the following:

m.fs.Feed = Feed()
m.fs.Tank1 = CSTR()
m.fs.HX1 = HeatExchanger(side_2_property_package=m.fs.H2O_properties)

Subflowsheets

Next, we need to add the remaining CSTR and heat exchanger to our model. Rather than attach them directly to our flowsheet, we will instead create a sub-flowsheet to contain them in order to demonstrate how to use these. IDAES flowsheet blocks (both FlowsheetBlock and FlowsheetBlockData) can be used to as a top level flowsheet or as a sub-flowsheet within a higher level flowsheet. Flowsheets can be nested to any depth desired, however some parts of the core code that need to search the model tree have an arbitrary depth limit of 20 (e.g. unit models will only search up 20 levels of flowsheets looking for a default property package).

When a FlowsheetBlock (or a class which inherits from FlowsheetBlockData) is created, it checks to see whether it has a parent object which contains a time domain (parent.time). If so, it assumes that it is a sub-flowsheet, and makes use of the parents time domain and dynamic flag, rather than creating new ones. Thus, a consistent time domain is maintained throughout all the sub-flowsheets and associated models. Each sub-flowsheet is self-contained, and can contain any of the elements a top-level flowsheet can, including property packages, unit models and connections. Sub-flowsheets may also access any component their parent (or higher level flowsheets) contains. Sub-flowsheets may also specify a different default property package from their parent, which will be used by any unit model within the sub-flowsheet in preference to that of their parent.

Creating a sub-flowsheet is done in the same manner as creating a top-level flowsheet, by creating an instance of FlowsheetBlock (or a class inheriting from FlowsheetBlockData). As a sub-flowsheet inherits its time domain and dynamic status from the parent flowsheet, it is not necessary to specify the dynamic argument in this case. For our example, we will also assign a different default property package for our sub-flowsheet, in this case m.fs.H2O_properties. This can be done in the same way as for a top-level flowsheet, however if the property package already exists (such as in this case where we have already added it to the top-level flowsheet) the default property package can be passed as an argument when creating the sub-flowsheet.

m.fs.block = FlowsheetBlock(default_property_package=m.fs.H2O_properties)

The default_property_package argument sets the default_property_package flag directly during the construction of the flowsheet.

Next, we can add the second CSTR (Tank2) and Heat Exchanger (HX2) to our new flowsheet in the same way that we would normally. As we have set H20_properties as our default properties of the subflowsheet, in this case we need to specify a property package for Tank2 and side 1 of HX2. Remember to assign these to m.fs.block.

m.fs.block.Tank2 = CSTR(property_package=m.fs.rxn_properties)
m.fs.block.HX2 = HeatExchanger(side_1_property_package=m.fs.rxn_properties)

Finally, let us add the Product Block to the Flowsheet. We could add this to either flowsheet, but for this tutorial we will add it to the top-level flowhseet (fs).

m.fs.Product = Product()

Finally, we can call post_transform_build on our main flowsheet to finish the construction of our model. This will automatically call post_transform_build on our sub-flowsheet.

m.fs.post_transform_build()

Connecting Units

Now that we have added all the unit models and sub-flowsheets to our main flowsheet, we can start connecting the units together. We will start with the units in the top-levle flowsheet:

m.fs.stream_1 = Stream(source=m.fs.Feed.outlet,
                       destination=m.fs.Tank1.inlet)

m.fs.stream_2 = Stream(source=m.fs.Tank1.outlet,
                       destination=m.fs.HX1.side_1_inlet)

We can connect units across different sub-flowsheets in exactly the same way as we connect units within a single flowsheet, just with the inclusion of the extra flowsheet leyer:

m.fs.stream_3 = Stream(source=m.fs.HX1.side_1_outlet,
                       destination=m.fs.block.Tank2.inlet)

When connecting units within subflowsheets (e.g. Tank2 and HX2), be careful where you place the connecting Constraint. The constraint can be placed in the subflowsheet that contains the unit models, or in any higher level flowsheet, and the model will be constructed successfully. However, placing the connecting constraints in higher level flowsheets may become confusing, so we recommend keeping the connecting constraints with the sub-flowsheet which contains the units being connected. For our example, we would place the constraint in m.fs.block as shown below:

m.fs.block.stream_4 = Stream(source=m.fs.block.Tank2.outlet,
                             destination=m.fs.block.HX2.side_1_inlet)

m.fs.block.stream_5 = Stream(source=m.fs.block.HX2.side_1_outlet,
                             destination=m.fs.Product.inlet)

Setting Inlet Conditions

From here, everything follows in the same way as in the earlier tutorials. First, we need to set our inlet and design conditions. We will use the same inlet and design conditions for Tank1 from the previous tutorials (don’t forget that Tank2 is now in a sub-flowsheet).

m.fs.Feed.fix('flow_mol_comp', comp='a', value=1.0)
m.fs.Feed.fix('flow_mol_comp', comp='b', value=2.0)
m.fs.Feed.fix('flow_mol_comp', comp='c', value=0.1)
m.fs.Feed.fix('flow_mol_comp', comp='d', value=0.0)
m.fs.Feed.fix('flow_mol_comp', comp='e', value=0.0)
m.fs.Feed.fix('flow_mol_comp', comp='f', value=0.0)
m.fs.Feed.fix('temperature', value=303.15)
m.fs.Feed.fix('pressure', value=101325)

m.fs.Tank1.volume.fix(10.0)
m.fs.Tank1.heat.fix(0.0)

m.fs.block.Tank2.volume.fix(10.0)
m.fs.block.Tank2.heat.fix(0.0)

For the heat exchangers, we will set the following inlet conditions for the side_2_inlet in both HX1 and HX2. As we do not have a Feed Block for these stream, we will set the value of the varaibles directly through the Port object. The conditions we will use are:

  • flow_mol = 5 [mol/s]
  • temperature = 303.15 [K]
  • pressure = 101325.0 [Pa]

One important thing to note is that IDAES Port objects (and many other things) are always indexed by time (even for steady-state models). Thus, when specifying conditions we need to include the time index(es) at which we wish to fix the value. For our steady-state model, there is only a single time index of 0 which we could use to specify the inlet conditions. However, more generally we use Pyomo slice notation (:) to set the value at all time indexes.

To fix an variable in a Connecotr object, we need to use the form:

m.fs.Tank1.inlet[:].vars[“variable name”].fix(value)

If the variable we are trying to fix is indexed by something (for example a flow rate which is indexed by component), we also add the index of the variable as follows:

m.fs.Tank1.inlet[:].vars[“variable name”][index].fix(value)

Thus, we need to write the follwing for our Heat Exchanger inlets:

m.fs.HX1.side_2_inlet[:].vars["flow_mol"].fix(5.0)
m.fs.HX1.side_2_inlet[:].vars["temperature"].fix(303.15)
m.fs.HX1.side_2_inlet[:].vars["pressure"].fix(101325.0)

m.fs.block.HX2.side_2_inlet[:].vars["flow_mol"].fix(5.0)
m.fs.block.HX2.side_2_inlet[:].vars["temperature"].fix(303.15)
m.fs.block.HX2.side_2_inlet[:].vars["pressure"].fix(101325.0)

We also need to set the volume for both sides of each heat exchanger, plus the heat_transfer_coefficient and heat_transfer_area variables. Let us use the following conditions (don’t forget to set them for both heat exchangers).

  • heat transfer coefficient = 100 [W/m^2.K]
  • heat transfer area = 2.0 [m^2]
m.fs.HX1.heat_transfer_coefficient.fix(100.0)
m.fs.HX1.heat_transfer_area.fix(2.0)

m.fs.block.HX2.heat_transfer_coefficient.fix(100.0)
m.fs.block.HX2.heat_transfer_area.fix(2.0)

Using the Model Check Utility and Setting Variable Bounds

Models of chemical processes are generally very complex, and even simple mistakes can make a problem difficult or impossible to solve. In order to assist users with solving models (and debugging them when they fail), the IDAES framework contains tools which allow model developers to write simple tests for common problems that may arise when using their models. Some examples of potential model tests include:

  • checking for variables with values set outside of the variable bounds,
  • “sanity checks” for model behavior - e.g. a compressor with a negative pressure change.

IDAES flowsheets contain a method called model_check which searches through all the unit models attached to the flowsheet and calls the models model_check method (if it exists). The developer of each unit model (and each submodel and property package) is responsible for writing any model checks which makes sense for their particular model.

All the core IDAES unit models contain model checks, and the H2O_properties package contains a simple model_check method which checks that the temperature and pressure variables fall within the bounds set for those variables. Let’s call the model_check method on our main flowsheet (m.fs) and see the results (you will need to run the flowsheet to see the results).

m.fs.model_check()

You should see an output saying “INFO - idaes.core - Executing model checks.”. This indicates that the model_check method has been called, and will be followed by any outputs from the model_check methods. In this case, there should be no other outputs, as all the checks for our model should pass.

In order to demonstrate a model check which fails, let’s change the lower bound on temperature in one of our heat exchangers to 310.0 K (which is higher than our fixed inlet value). AS discussed in the last tutorial, the upper and lower bounds of a variable can be set using the setub and setlb methods respectively, which all Pyomo variables have. To set the lower bound of the side 2 inlet temperature to HX1, we use the following code (this needs to go before the model_check call):

m.fs.HX1.side_2_inlet[:].vars["temperature"].setlb(310.0)

CAUTION - users should be careful when changing preset variable bounds in any model, especially when trying to widen the bounds. Many models are fitted to a limited range of data, and bounds are set to prevent these from being extrapolated beyond the fitted region where the quality of the fit may not be guaranteed (or in many cases, where the fit is known to be bad). Users should keep this in mind when looking at and adjusting bounds in models they did not create.

Another thing users should be aware of is that bounds are set independently for each instance of a model. In our current example, there are multiple instances of the H2O_properties model throughout our flowsheet (2 instances in each unit model to be precise, for a total of 8). When we changed the lower bound above, we changes the bound in only one of these instances, and the remaining 7 instances still have the old bound set.

Now that we have changed the lower bound on temperature, let us run the model checks again. This time we would expect to see a warning that our inlet temperature has a value lower than the lower bound.

We should now see a second line in the output from the model_check method, which says “ERROR - idaes.unit_model.properties - fs.HX1.side_2.properties_in[0.0] Temperature set below lower bound.”.

The first part of the message tells us the severity of the problem found, in this case “ERROR”. IDAES model checks use the following levels for model checks:

  • ERROR - indicates an issue that is likely to cause a problem when solving the model (e.g. variables out of bounds),
  • WARNING - indicates something that may be wrong, but may still be solvable (e.g. a compressor with negative pressure increase),
  • INFO - message for information only.

The second part of the message indicates which part of the IDAES framework raised the issue. This is mostly useful for debugging by advanced users and won’t be covered here.

The important part of the output comes from the last two parts of the message. The third part tell us which part of our particular model the issue came from, in this case fs.HX1.side_2.properties_in[0.0]. We already know that fs.HX1 is our first heat exchanger, and side_2.properties_in[0.0] indicates that the issue came from the inlet properties for side 2 of this heat exchanger (at time 0.0). More detail on understanding the internal structure of IDAES unit models will be given in the next tutorial.

The final part of the message was written by the developer of the H2O_properties package, and tells us what caused the issue - in this case the lower bound of the temperature variable. Hopefully this message will make it clear what needs to be fixed, however the quality of these messages depends on the model developer.

Before we continue, let us change the lower bound on temperature back to its original value (298.15 K) so that our model is feasible.

Initializing and Solving the Model

Next we need to initialize our model. We will use the same procedure as in previous tutorials, sequentially initializing units using the outlet of the previous unit (the side_2 inlets of the Heat Exchangers are fixed, so we do not need to provide conditions for these).

m.fs.Feed.initialize()
m.fs.Tank1.initialize(state_args={
        "flow_mol_comp": {
            "a": m.fs.Feed.outlet[0].vars["flow_mol_comp"]["a"].value,
            "b": m.fs.Feed.outlet[0].vars["flow_mol_comp"]["b"].value,
            "c": m.fs.Feed.outlet[0].vars["flow_mol_comp"]["c"].value,
            "d": m.fs.Feed.outlet[0].vars["flow_mol_comp"]["d"].value,
            "e": m.fs.Feed.outlet[0].vars["flow_mol_comp"]["e"].value,
            "f": m.fs.Feed.outlet[0].vars["flow_mol_comp"]["f"].value},
        "pressure": m.fs.Feed.outlet[0].vars["pressure"].value,
        "temperature": m.fs.Feed.outlet[0].vars["temperature"].value})
m.fs.HX1.initialize(state_args_1={
        "flow_mol_comp": {
            "a": m.fs.Tank1.outlet[0].vars["flow_mol_comp"]["a"].value,
            "b": m.fs.Tank1.outlet[0].vars["flow_mol_comp"]["b"].value,
            "c": m.fs.Tank1.outlet[0].vars["flow_mol_comp"]["c"].value,
            "d": m.fs.Tank1.outlet[0].vars["flow_mol_comp"]["d"].value,
            "e": m.fs.Tank1.outlet[0].vars["flow_mol_comp"]["e"].value,
            "f": m.fs.Tank1.outlet[0].vars["flow_mol_comp"]["f"].value},
        "pressure": m.fs.Tank1.outlet[0].vars["pressure"].value,
        "temperature": m.fs.Tank1.outlet[0].vars["temperature"].value})

m.fs.block.Tank2.initialize(state_args={
    "flow_mol_comp": {
        "a": m.fs.HX1.side_1_outlet[0].vars["flow_mol_comp"]["a"].value,
        "b": m.fs.HX1.side_1_outlet[0].vars["flow_mol_comp"]["b"].value,
        "c": m.fs.HX1.side_1_outlet[0].vars["flow_mol_comp"]["c"].value,
        "d": m.fs.HX1.side_1_outlet[0].vars["flow_mol_comp"]["d"].value,
        "e": m.fs.HX1.side_1_outlet[0].vars["flow_mol_comp"]["e"].value,
        "f": m.fs.HX1.side_1_outlet[0].vars["flow_mol_comp"]["f"].value},
    "pressure": m.fs.HX1.side_1_outlet[0].vars["pressure"].value,
    "temperature": m.fs.HX1.side_1_outlet[0].vars["temperature"].value})
m.fs.block.HX2.initialize(state_args_1={
    "flow_mol_comp": {
        "a": m.fs.block.Tank2.outlet[0].vars["flow_mol_comp"]["a"].value,
        "b": m.fs.block.Tank2.outlet[0].vars["flow_mol_comp"]["b"].value,
        "c": m.fs.block.Tank2.outlet[0].vars["flow_mol_comp"]["c"].value,
        "d": m.fs.block.Tank2.outlet[0].vars["flow_mol_comp"]["d"].value,
        "e": m.fs.block.Tank2.outlet[0].vars["flow_mol_comp"]["e"].value,
        "f": m.fs.block.Tank2.outlet[0].vars["flow_mol_comp"]["f"].value},
    "pressure": m.fs.block.Tank2.outlet[0].vars["pressure"].value,
    "temperature": m.fs.block.Tank2.outlet[0].vars["temperature"].value})

Now that our model is initialized, we can create a solver and use it to solve our flowsheet.

solver = SolverFactory('ipopt')
results = solver.solve(m, tee=True)

Finally, let’s display the both of the outlets from HX2 (side_1_outlet and side_2_outlet) and check our results.

print(results)

m.fs.block.HX2.side_1_outlet.display()
m.fs.block.HX2.side_2_outlet.display()

The expected conditions are:

Side 1

  • flow_mol_comp[“a”] = 0.275 [mol/s]
  • flow_mol_comp[“b”] = 1.047 [mol/s]
  • flow_mol_comp[“c”] = 0.323 [mol/s]
  • flow_mol_comp[“d”] = 0.233 [mol/s]
  • flow_mol_comp[“e”] = 0.010 [mol/s]
  • flow_mol_comp[“f”] = 0.487 [mol/s]
  • pressure – 101325.0 [Pa]
  • temperature = 324.82 [K]

Side 2

  • flow_mol = 5.0 [mol/s]
  • pressure = 101325.0 [Pa]
  • temperature = 318.79 [K]