Tutorial 5 – Modifying Unit Models

Introduction

In the previous tutorial we, we added a set of Variables and Constraints to two CSTRs to simulate pressure driven flow. In cases where we would like to make repeated use of the same modifications to a model, it would be convenient to create a new model with these modification instead of making the changes to each instance of a simpler model. This tutorial will teach you how to take an existing model and add modifications to it such that it can be used repeatedly in a flowsheet.

For this tutorial, we will create a new model for a CSTR with pressure driven flow.

This tutorial will teach you about:

  • Pyomo component structure,
  • building on an existing unit model (class inheritance),
  • replacing existing methods (overloading), and
  • adding new components to a unit model.

Pyomo Component Structure

The first thing we need to cover before we go further is to briefly introduce Pyomo’s underlying component structure, and how IDAES interacts with this. When we add a Pyomo component to a model, we call the Component class along with some arguments (for example, Block). Behind the scenes, the Pyomo Component class then checks whether the component is indexed or not, and calls either SimpleComponent or IndexedComponent as appropriate (i.e. SimpleBlock or IndexedBlock). This class then calls upon a ComponentData class (i.e. BlockData) to populate the component with the necessary information. This is shown in the figure below.

../../_images/tutorial_5_figure.png

As model developers in IDAES, we are most interested in the BlockData class, as this is where we write the instructions for a given instance of a Block - i.e. the Variables and Constraints that make up our models. We would prefer not the have to worry about the other three classes, as these are instructions on how to construct the underlying objects (and are standard across all our models).

IDAES handles all of this for us using the declare_process_block_class decorator, so that we only need to write the BlockData object for our model. The remaining three class are handled through meta-class, and those who wish to know more should read the documentation on process_block. For the rest of us, it is important to keep this behavior in mind, as it affects the underlying class structure we are working with.

First Steps

The first steps as always are to import the necessary components from the Pyomo and IDAES libraries. First, we will need to import Var from Pyomo and declare_process_block_class from IDAES.

from pyomo.environ import Var
from idaes.core import declare_process_block_class

We also need to import the model we are going to use as the basis for our new model, which in our case is CSTR. However, the class we want to modify is the CSTRData class (the BlockData class associated with the CSTR model) instead of CSTR (the equivalent Block class for a CSTR).

from idaes.models.cstr import CSTRData

Inheriting from Existing Models

The next step is to create a new set of model classes for our CSTR with pressure driven flow, which we will call CSTR2 for this tutorial. To do this, we need to declare a new class (which will form our BlockData class) which inherits from CSTRData, and to apply the declare_process_block_class decorator to it. This is done as shown below:

@declare_process_block_class("CSTR2")
class CSTR2Data(CSTRData):

The first line above is the decorator, which creates a new set of meta-classes with the name CSTR2 for us, based on the class declared in the second line. This handles all the work of setting up the CSTR2, SimpleCSTR2 and IndexedCSTR2 classes for us, so that we don’t need to worry about them. The second line declares our new CSTR2Data class, which inherits from CSTRData (as indicated by the argument in the parentheses).

The build Method

In order to populate our model with the necessary Variables and Constraints, we need to point Pyomo to a set of instructions for creating these. For this, we create a method named build, which is called by default whenever we add a model to a Flowsheet. This build method belongs in the CSTR2Data class.

For our new pressure-driven CSTR model, our build method needs to do two things:

  1. construct the base CSTR model, and,
  2. add the Constraints for pressure-driven flow.

The base CSTR model which we have inherited from has its own build method, which contains the instructions for constructing a standard CSTR. Rather than rewrite all these instructions, we can instead call the original CSTR model’s build method to do this for us. We do this by making use of Python’s super() method, which allows us to access the methods of our parent class. We will also call a second method (which we haven’t written yet) to add the pressure-driven flow constraints. All of this is shown below:

def build(self):
    super(_CSTRData, self).build()

    self.add_pressure_driven_flow()

Adding New Methods, Variables and Constraints

Next, we will declare the new add_pressure_driven_flow method, which will contain the instructions for adding the Variables and Constraints we need for pressure-driven flow.

def add_pressure_Driven_flow(self):

Here, self is a standard placeholder for the object to which the method belongs (in this case our new model). Within this method, we can now write the code necessary to declare the new Variables and Constraints much like we did in Tutorial 3.

We need to add the following:

  • height, area, volume_flow and flow_coeff variables,
  • \(V_t = A \times h_t\) Constraint,
  • \(F_{vol,t} = F_{mol,t} \times \rho_{mol,t}\) Constraint, and,
  • \(F_{vol,t} = C_t \times h_t\) Constraint.

All of this is shown below (along with the method declaration - note the indentation).

def add_pressure_driven_flow(self):
    self.height = Var(self.time,
                      initialize=1.0,
                      doc="Depth of fluid in tank [m]")
    self.area = Var(initialize=1.0,
                    doc="Cross-sectional area of tank [m^2]")

    self.volume_flow = Var(self.time,
                           initialize=4.2e5,
                           doc="Volumetric flow leaving tank")

    self.flow_coeff = Var(self.time,
                          initialize=5e-5,
                          doc="Tank outlet flow coefficient")

    def geometry(b, t):
        return b.volume[t] == b.area*b.height[t]
    self.geometry = Constraint(self.time, doc="Tank geometry constraint", rule=geometry)

    def volume_flow_calculation(b, t):
        return b.volume_flow[t] == (
                    b.holdup.properties_out[t].flow_mol /
                    b.holdup.properties_out[t].dens_mol_phase['Liq'])
    self.volume_flow_calculation = Constraint(self.time, doc="Flow volume constraint", rule=volume_flow_calculation)

    def outlet_flowrate(b, t):
        return b.volume_flow[t] == b.flow_coeff[t]*b.height[t]
    self.outlet_flowrate = Constraint(self.time, doc="Outlet flow correlation", rule=outlet_flowrate)

Replacing Other Methods

The above is all we need to change for this tutorial, however with more complex models the are other methods that we might need to replace. The three most important ones are:

  • post_transform_build
  • model_check
  • initialize

In our case, the existing versions of these we inherit from CSTR are sufficient for our this tutorial, however in many cases we will need to make changes to these as well. If there is anything that needs to be done after the DAE transformation (such as adding new inlets or outlets), this needs to go in post_transform_build. We can also write model_check and initialize methods customized for our new model if needed.

Using Our New Model

To put our new model to use, all we need to do is import our CSTR2 class (the one created by the decorator), and use it in the same way as we have used CSTR previously. Try to repeat Tutorial 3 using our new class instead - all you should need to do is use CSTR2 in place of CSTR (and skip the part on adding the new Variables and Constraints - our new model does this for us).