LIBPF® SDK Developer manual

Introduction

Intended audience

This document addresses those who wish to develop models with LIBPF® (model developers).

Scope

This document is the developer manual for LIBPF® (LIBrary for Process Flowsheeting) Software Development Kit (SDK) version 1.1;

Prerequisites

Definitions

workflow

Objects and classes

LIBPF® is based on a object-oriented approach, thus every real physical entity (object) has an instance of a simulated object. Common things among simulated object can be generalized in a class.

Class is a technical C++ term that defines a model for the object creation, which includes attributes and methods which are shared among all created objects.

The following table shows and example relation among real object, simulated objects and classes:

REAL OBJECT SIMULATED OBJECT (INSTANCE) C++ CLASS
Reaction R101.reactions[0] that is the 0-th element of the “reactions” vector of the “R101” reactor
Flash (also reactive) with many inlets and one mixed phase outlet Buffer D201, Reactor R100 FlashDrum
Flash (also reactive) with many inlets and one outlet for each separated phase Reactor R200, Phase separator S105 FlashSplitterDrum
Chemical components Methane, ethane, ammonia ComponentDippr
Biomass component Woody biomass, Oily biomass ComponentSolid
Phase Solid, vapor, liquid phase Phase
Material stream with two phases (vapor and solid) with ideal behavior Feed, product, purge, makeup StreamIdealSolidVapor

Relations between classes

There can be three type of relation between classes:

Note: it is possible to build a process model (deriving from FlowSheet) following “has-a” relations, but the unit operations need to be declared in the class declaration. For example:

class Jasper01 : public Model, public FlowSheet {
private:
  static std::string type_;
public:
  FlashDrum E101C; // Biomass pre heater cold side - biomass
  FlashSplitterDrum<StreamIdealLiquidVapor> R101; // Gassificatore monostadio
  FlashDrum R102; // Tar cracker
  FlashDrum E102H; // Air pre heater hot side - syngas
  FlashDrum E101H; // Biomass pre heater hot side - syngas
  FlashSplitterDrum<StreamIdealLiquidVapor> E103; // Vapor generator
  FlashDrum ICE; // Internal combustion engine
  Compressor B101; // Air blower
  FlashDrum E102C // Air pre heater cold side - air

  StreamSolid S02; // Alimentazione biomassa
  StreamSolid S03; // Biomassa preriscaldata
  StreamVapor S06; // Raw syngas depolverato
  StreamVapor S07; // Syngas clean and very hot
  StreamVapor S08; // Syngas hot
  StreamVapor S09; // Syngas quite hot
  StreamLiquid S10; // Condensate
  StreamVapor S12; // Syngas cold
  StreamVapor S13; // Air feed to gasification
  StreamVapor S15; // Air feed to gasification compressed
  StreamVapor S17; // Air feed to gasification hot
  StreamSolid S21; // Ash to solid treatment
  StreamVapor S28; // Flue gas
  StreamVapor S29; // Air to ICE
  Jasper01(long cid);
  void setup(void);
  const std::string &type(void) const { return type_; }
}; // Jasper01

In practice, this approach is not practical, and a between the FlowSheet and the streams / unit operations a class relation “uses-a” is preferred.

Creating new classes

Model instances are created by instantiating classes, so when you need a custom model you need to create a class for that.

The first thing to do is to find a name for the new class. According to the identifier naming conventions in the LIBPF® Coding Standard class names should be upper CamelCase, and the order of the words should be by decreasing importance.

This constraint is mandatory for:

For flowsheets, this rule is not mandatory and the FlowSheet prefix is tipically skipped.

For example, these are suitable names for reactions: ReactionDecomposition, ReactionAaaaa, ReactionTest, while these are not correct: DecompositionReaction, reaction_aaaaa, Reactiontest.

Once you have chosen a suitable name, there are three step to follow to create the new class:

  1. declaration written in the “header files” with the “.h” extension;

  2. definition, implementation of the methods, written in the “source files” with the “.cc” extension;

  3. instantiation: a simulated object is built up based on one class and it starts to exist in memory (lifetime).

Declaration example:

class ReactionReformingC7H16 : public ReactionYield {
private:
  static std::string type_;
public:
  ReactionReformingC7H16_Ref(Options options);
  virtual const std::string &type(void) const {
    return type_;
  }
}; // class ReactionReformingC7H16

Definition example:

std::string ReactionReformingC7H16::type_("ReactionReformingC7H16");

ReactionReformingC7H16::ReactionReformingC7H16(Options options):  PersistentInterface(options.id()), Persistent(options.id()), ModelInterface(options.id()), ReactionYield(options.id()) {
  I("keycomp")->set_val(components.lookup("tar"));
  *Q("coeff", "tar") = -1.0;
  *Q("coeff", "H2O") = -7.0;
  *Q("coeff", "CO") = 7.0;
  *Q("coeff", "H2") = 15.0;
} // ReactionReformingC7H16::ReactionReformingC7H16

In the simplest model, at least the following members/methods must be defined:

The FlowSheet objects require also the following function (but the dummy { } implementation is sometimes given):

LIBPF® already has a large number pre-defined classes, declared in the respective header files (“phases.h”, “streams.h”, “units.h”, “reactions.h”).

The corresponding definitions are already compiled within the library files (“libPF.lib” and the debug version “libPF_D.lib”). In order to use such classes, it is sufficient to call (include) the header file and then instantiate the object.

Instantiation and Object Factory

There are two ways you can instantiate an object of a certain type:

  1. static instantiation: if the type is a C++ class and it is known at compile time:

     ReactionOxidationC myReaction(Libpf::User::Defaults("r1", "carbon oxydation reaction"));
    
  2. dynamic instantiation: via the object factory if the type is identified by a string known at run-time:

     Node *node = nodeFactory.create("ReactionOxidationC", Libpf::User::Defaults("r1", "carbon oxydation reaction"));
    

    or in case of a composed object (“use-a” case):

     FlashDrum R101(Libpf::User::Defaults("R", "Equilibrium reactor")
       ("nReactions", 5)
       ("embeddedTypeReactions[0]", "ReactionCHNO_9")
       ("embeddedTypeReactions[1]", "ReactionOxidationC")
       ("embeddedTypeReactions[2]", "ReactionReformingEquilibriumCH4")
       ("embeddedTypeReactions[3]", "ReactionSynthesisEquilibriumNH3")
       ("embeddedTypeReactions[4]", "ReactionWaterGasShiftEquilibrium"));
    

In this case, the R101 reactive flash drum is declared at compile time, but the details about how it is build are defined at run-time.

In particular, the reactions that take place in R101 are defined using a currying syntax supplying the nReactions integer parameter and the values of the required elements in the embeddedTypeReactions string vector.

At run-time, the constructor of R101 (FlashDrum::FlashDrum) will instantiate five reactions of type ReactionCHNO_9, ReactionOxidationC, ReactionReformingEquilibriumCH4, ReactionSynthesisEquilibriumNH3 and ReactionWaterGasShiftEquilibrium.

Ultimately it is the constructors of those reaction classes that are called, but to decide which constructor to call based on the supplied strings, an object factory is required.

The object factory is a particular object which instantiates an object of a certain class when is given the class name as a string:

string “ReactionReformingC7H16” → Object Factory → instance of an object of type ReactionReformingC7H16

The object factory must be manually instructed about the association between strings and classes; this association is already set for the predefined classes in LIBPF®, and must be set by the user for the new classes.

Instructions on how to pass this information to the [object factory+(sdk_reference/class_node_factory.html) typically take this form:

NodeFactory nodeFactory;
nodeFactory.registerType<Pasteur>("Pasteur", // type name
  "Generic continuous pasteurization process", // type description
  "flowsheet", // type category
  true, // can be instantiated as a to-level model (Case)
  { }, // integer options
  { Libpf::User::StringOption("processType", "adjusts temperature and holding time", "HTST15", "processType"),
    Libpf::User::StringOption("feedType", "sets a predefined composition for the fluid to be processed", "chocolateIceCream", "feedType") }, // string options
  "Pasteur", 80.0, 80.0); // icon and icon size

the string that you pass as an argument to the function NodeFactory::registerType must be the same that is put into the static member type_ for that class.

Creating model instances

LIBPF® models are designed to be built from a saved state in the persistency database, or from scratch.

In the first case, the only parameter that must be passed to the constructor is the database id of the object that must be retrieved - all the rest will be loaded from the database (the database id is an interger value returned by the Node::insert function when the object was inserted in the database).

In the second case the parameters that must be passed to the constructor are the tag and description, plus all the options (strings and integers) required to fully configure the construction of the object itself and of its sub-objects.

For example, a reactor could have as options the reactions:

reactions = {“ReactionOxidationH2”, “ReactionOxidationCO”}

Alternatively a distillation column might have 10 equilibrium stages and feed to the 5th plate, requiring two integer values for nStages and nFeed:

nStages = 10
nFeed = 5

To handle these two use cases you would need two different constructors:

Reactor::Reactor(std::string tag,
  std::string description,
  std::list<std::string> reactionList = {"ReactionOxidationH2",
                                         "ReactionOxidationCO"});
Column::Column(std::string tag,
  std::string description,
  std::list<std::string, std::int> options = { {"nStages", 10}, {"FeedStage", 5}});

In addition you would need a constructor taking the database id as the only integer parameter, to handle the case when you want to build from a saved state:

Reactor::Reactor(int id);
Column::Column(sint id);

However, providing constructors with all those signatures would be impractical.

Although C++14 supports delegating constructors (or chained constructors) that allow to designate a default constructor that does all the hard work, the object factory would still have to support all possible signatures !

The solution used in LIBPF® instead relies on a single constructor signature (this signature has been simplified in this example):

Reactor::Reactor(Defaults defaults, int id);
Column::Column(Defaults defaults, int id);

The Defaults object allows you to pass all the options that should be used to create an object from scratch, including tag and description. Furthermore the Default object can be appended any number of additional options, using a currying syntax exploiting overloads of the:

operator()

The vector options are passed in a “flattened” way, where arrays of options are passed as an integer setting the size of the array, and the list of array elements.

The list of reactions above is represented by the value 2 is assigned to the integer parameter nReactions and by two values in the array of string embeddedTypeReactions:

nReactions = 2
embeddedTypeReactions[0] = "ReactionOxidationH2"
embeddedTypeReactions[1] = "ReactionOxidationCO"

Using this formalism the calls to construct the objects of the two previous examples are expressed in LIBPF® in this way:

Reactor reactor(Defaults("reactor", "reactor description")
  ("nReactions", 2)
  ("embeddedTypeReactions[0]", "ReactionOxidationH2")
  ("embeddedTypeReactions[1]", "ReactionOxidationCO"));

Column column(Defaults("column", "column description"))
  ("nStages", 10)
  ("FeedStage", 5));

If the two objects must be reconstructed from persistent storage, and assuming that id in the database equal to 777 and 888, the calls will be respectively:

Reactor reactor(Defaults, 777);
Column column(Defaults, 888);

The Defaults object also provides functionality to forward options to sub-objects, and setting default values.

The typical scenario in LIBPF® is that the top-level object, of type derived from the class FlowSheet, contains streams and unit operations, some of which may in turn be of types derived from the class FlowSheet and contain other objects.

If at the time of construction of the top-level object you want to set an option that must apply to a certain object contained in this hierarchy, all the constructors must properly forward that option.

For example, suppose a type object contains two objects, aa of type Aa and ab of type Ab; the latter in turn contains the sub-objects aba, abb and abc of respective types Aba, Abb and Abc. Let’s assume that you want to pass the option nStage = 2 to the abc.

The call to the constructor of the highest level will have this form:

A a(Defaults("", "")("nStage", 2));

The A constructor will forward the nStage option to all its sub-objects and through to all sub-sub… objects, possibly overwriting any default value it may have.

Note: in a future release of LIBPF® a filter functionality will be implemented, which will send the options only selectively to certain sub-objects. In the preceding example, if you woulf prefer to forward the nStage = 2 option only to the abs sub-sub-oobject, rather than to all sub-objects, the syntax would become:

A a(Defaults("", "")("ab:abc:nStage", 2));

As for default values, if an option is not specified, the value is -1 for the integer options and the empty string "" for the string options.

All the examples until now have offered insight into the user of built-in or custom models. But how does this option-passing mechanism impact model developers ?

First of all as a model developer you are not likely to instantiate the models directly, but rather though the object factory or even more likely using the FlowSheet::addUnit and FlowSheet::addStream member functions.

Using those functions the reactor and column instantiations become:

addUnit("Reactor", Defaults("reactor", "reactor description")
  ("nReactions", 2)
  ("embeddedTypeReactions[0]", "ReactionOxidationH2")
  ("embeddedTypeReactions[1]", "ReactionOxidationCO"));

addUnit("Column", Defaults("column", "column description"))
  ("nStages", 10)
  ("FeedStage", 5));

Note how the type is passed as first argument, but the subsequent arguments are unchanged w.r.t. the direct command.

Second, any constructor must assume that it may be called during the construction of a higher-level object. Even if you are writing a complex process model of a gas cleanup, it may happen that later somebody else integrates it in an even larger model.

For this reason, if the model developer wants to set a default value, but at the same time allow the model user overwriting it, she can use the relay function, which returns a copy of the Defaults object, with overridden tag and description.

This allows the options set at a higher level to be propagated to the sub-objects. Using Defaults::relay the reactor and column instantiations become:

addUnit("Reactor", defaults.relay("reactor", "reactor description")
  ("nReactions", 2)
  ("embeddedTypeReactions[0]", "ReactionOxidationH2")
  ("embeddedTypeReactions[1]", "ReactionOxidationCO"));

addUnit("Column", defaults.relay("column", "column description"))
  ("nStages", 10)
  ("FeedStage", 5));

Abstract or semi-abstract objects ( mixins ) that are used to implement some of the functionality of complex objects, but which do not contain variables or options that have to be retrieved from persistency can have constructors with simpler signatures.

These simpler constructor signatures can be used for any class that will never be instantiated by the ObjectFactory.

Errors and warnings

If while running (run-time) a recoverable error is encountered (i.e., that allows to continue the calculation), this is recorded in the unit operation or in the stream that has caused.

If the error is unrecoverable an exception is thrown (see Exceptions).

Recoverable errors can be of two types:

Browsing objects and sub-objects

Because the objects actually instantiated at run-time are dynamic (i.e. they have a uses-a relationship with sub-objects), it is not possible to identify at run-time some member or sub-object using only the C++ operators . (dot) and -> (arrow).

Everything would be easier if a flowsheet had a reactor member (has-a relationship) with in turn some reaction members (has-a relationship) (Note: non-working pseudo-code):

class Reaction {
public:
  double z;
};

class Reactor {
public:
  Reaction R1, R2;
};

class Flowsheet {
public:
  Reactor R100;
};

Flowhseet F;
std::cout << F.R100.R

However, if the flowsheet and the reactor are constructed dynamically (and then use the uses-a relationship with the reactor and the reactions respectively ), there will be no object named R100 within F or R1 and R2 objects inside of R100, but there will be only unnamed pointers, as in:

class Reaction {
  std::vector<double *> variables;
};

class Reactor : public Unit {
  std::vector<reaction *> reactions;
};

class Flowsheet {
  std::vector<Unit *> units;
};

The LIBPF® library provides access methods common to all models (they are methods of the semi-abstract class Object) that allow you to browse the variable members (Quantities, Strings and Integers) and the sub-object members. This functionality is reflection, i.e. the capability of a computer program to observe its own type structure (types, sizes, member layout, member function signatures, inheritance) at run-time.

In the preceding example, the variable z in the first reaction of reactor R100 could be found with the statement:

std::cout << F.at("R100:R1").Q("z");

or more compact:

std::cout << F.Q("R100:R1.z");

Compared to native C++ syntax, there are some differences:

For example, if the list of components is: [H2, H2O, O2]:

Q("reaction[0].z")
Q("x[H2]")
Q("mdotcomps[H2O]")
Q("coeff[0,O2]")

or:

Q("x[0]")
Q("mdotcomps[1]")
Q("coeff[0,2]")

The Q, S, I, and at functions can be invoked at any level of the hierarchy of objects and objects, and the rule of scoping applies.

Always referring to the previous example:

- ```F.at("R100")``` returns a reference to the reactor R100

- ```F.at("R100").at("R[0]")``` or ```F.at("R101:R[0]")``` returns a reference to the first reaction within reactor R100

- ```F.at("R100:R[0]").Q("z")``` or ```F.at("R100").Q("R[0].z")``` or ```F.Q("R100:R[0].z")``` returns a reference to the variable z in the first reaction inside the reactor R100.

The reflection mechanism requires each Object to hold internal maps between string tags and variables / sub-objects. All automatic variables and all sub-objects must be registered with those maps.

To make sure automatic variables are registered for the reflection mechanism, the default, empty-list constructor must be disabled for all variable types.

In this way member variables must be set up (tag, description, parent=this and default value) in the constructor initializer list.

Adding them the maps used for reflection must be done in the constructor body with the overloaded addVariable member function:

Phase::Phase(...) : mdot("mdot", "mass flow", this) ... {
  addVariable(mdot);
  ...
}

To avoid repeating the variable name twice (as identificator and as string passed to the tag constructor parameter), and repeating the this parameter, syntactic sugar in the form of a DEFINE macro is provided:

#define DEFINE(tag, description) tag(#tag, description, this)

With this, the Phase constructor above can be written:

Phase::Phase(...) : DEFINE(mdot, "mass flow") ... {
  addVariable(mdot);
  ...
}

Utility classes

Diagnostics

The diagnostic macro prints the diagnostic message passed as second argument to std::cout only if the sum of verbosityGlobal + verbosityFile + verbosityLocal + verbosityInstance is larger or equal to the verbosity level passed as first parameter.

The meaning of the four verbosity levels is set at compile time as follows:

  1. verbosityGlobal is a global variable that holds for the entire executable, which can be directly manipulated ay any time

  2. there is one verbosityFile constant static variable in each compilation unit

  3. there is one verbosityLocal constant static variable in each function

  4. there is one verbosityInstance variable in each class instance, and it can be manipulated via the setVerbosity() member function

The diagnostic message can contain strings and objects chained with the « operator as in a statement involving the std::cout stream (this exploits the lower priority of the « stream operator with respect to the comma operator).

Example:

diagnostic(0, "test: " << test);

The tipical verbosity levels are:

Exceptions

If at run-time a fatal error is encountered, a C++ exception is thrown.

All exceptions thrown by the LIBPF® library are objects derived from the class Error: e.g. ErrorBrowsing for errors related to object browsing, ErrorUnit for errors related to units of measurement etc.

The main purpose of these classes is to report detailed error information.

To facilitate the analysis of errors, the main stuff is typically included in a try / catch block:

try {
  // main stuff
} catch {...}

When a function catches an exception from a function call, it appends to the error object that receives from the exception some additional information about the current function, and then rethrows the exception.

For example:

void SampleClass::sampleMethod(void) {
  try {
    // main stuff
  } // try
  catch (Error &e) {
    e.append(CURRENT_FUNCTION);
    throw;
  } // catch
} // SampleClass::sampleMethod

The stack unwinding is finally performed by the main function, which reports the detailed informations to std::cerr:

int main() {
  try {
    // main stuff
    return 0;
  } // try
  catch (Error &e) {
    e.append(CURRENT_FUNCTION);
    std::cerr << "****************************** Fatal LIBPF error! ******************************" << std::endl;
    std::cerr << e.message() << std::endl;
    return -1;
  } // catch
} // main

A typical stack unwinding message looks like this:

***************************** Fatal LIBPF error! *****************************
Error was thrown by function: GenericQuantity<valuetype>::operator=
Error type: Unit of measurement error - destination already has incompatible units: <kg s^-1> <kmol s^-1>
Called from function: SaltPepper::setup
Called from function: main

This informations should help locating the source of the problem.

Value

LIBPF® performs all mathematical operations on floating-point numbers using instead of the default C++ type double a special class Value that provides units of measurements and analytical derivatives.

Units of measurements are checked at run-time, and if any incorrect operation is tried an exception of type ErrorUnit is thrown; see LIBPF® supported units of measurements for a list of units.

Value is actually a typedef for GenericValue<valuetype> with valuetype == double.

It is possible (but currently not supported) to create objects of type GenericValue<float> for calculations in single precision floating point, GenericValue<std::complex> for calculations with complex numbers and GenericValue<std::vector <double>> for calculations with vectors.

Assignaments in model user code should preferably happen via the Value::set method:

Q("R101:reactions[2].conv")->set(0.3);
Q("S10:Tphase.mdot")->set(300.0, “kg/h”);

The Value::set method marks the variable as input, this will automatically make it visible in the User Interface.

Alternatively it is also possible to use the plain C++ assignment:

Q("R101:reactions[2].conv") = Value(0.3);
L-value                     = R-value

The C++ assignment is an asymmetrical binary operator: the left side must be an L-value while the right side can be an R-value - see Value (computer science); L-value and R-value must be of the same type that is in this case Value, or there must be an automatic conversion between the type of the R-value to the L-type, which is invoked implicitly if you write (dimensionless values):

Q("R101:reactions[2].conv") = 0.3;

The best practice is to avoid the implicit conversions and call explicitely the Value(0.3) constructor; in the case of values with dimensions, the explicit constructor is mandatory to avoid an exception be thrown for incompatible units of measurement.

Computation of derivatives

The 1st order derivatives can be calculated in one step together with the evaluation of each expression, with a technique known as FADOO (Forward Automatic Differentiation with Operator Overloading). The technique is described in the ADOL-C documentation, in the chapter describing the Tapeless forward differentiation.

Consider a function f: R^2 -> R^2.

When the function is evaluated numerically, it takes a vector of two floating point numbers for the independent variables [x_1, x_2], and returns the vector of two floating point numbers for independent variables [f_1, f_2].

To have in the same pass also the 1st order analytical derivatives with the FADOO approach, the function is fed with a vector of two more complex objects instead than simple floating-point numbers for each of the independent variables: for example, the first independent variable x_1 provides a data structure that contains a floating point number for the value of the variable, plus an array of floating point numbers with size equal to the total number of independent variables (in this case, 2) initialized with the derivative of the first variable w.r.t. to all other variables.

Finally:

x_1 = [value, [dx_1/dx_1, dx_1/dx_2] ] = [x_1_value, [1, 0]]
x_1 = [value, [dx_2/dx_1, dx_2/dx_2] ] = [x_2_value, [0, 1]]

At this point any mathematical operation performed to evaluate the function f must be overloaded so that it operates on these data structures in place of the simple floating-point numbers, and propagates the derivative information while doing the evaluation. In this way at the end of the evaluation of all expressions within f, the derivatives of the results w.r.t. the independent variables can be found in the results data structure.

without with

This is an example calculation in LIBPF®:

BaseActive::rollContext(2); // this sets the number of derivatives to be calculated to 2
P.setActive(0);
T.setActive(1);
v = R * T / P;
diagnostic(0, "Molar volume with UOM = " << v);
diagnostic(0, "Molar volume with derivatives = " << v.printFull());
BaseActive::unRollContext();

This should be the output:

main * Molar volume with UOM = 24.4652 kmol^-1 m^3
main * Molar volume with derivatives = 24.4652 [-0.000241453 0.0820567 ]

which means that the derivative of the volume w.r.t. the 1st variable (the pressure) is -0.000241453 whereas the derivative of the volume w.r.t. the 2nd variable (the temperature) is 0.0820567.

How to get on

  1. Read the LIBPF® SDK Classes overview

  2. Browse the LIBPF® SDK C++ reference manual