L-infinity

Computational fluid dynamics, multiphase flows, machine learning, OpenFOAM

25 Nov 2020

An almost minimal example of an object-oriented library in OpenFOAM

Computational Fluid Dynamics (CFD) models often share the same data and differ in how this data is processed, which organizes them naturally into hierarchies. Hierarchical models are designed using Object Oriented Design/Prorgramming, and OpenFOAM CFD models are designed this way as well. OOD enables selecting the model at runtime, using the input from a file or a command line. This post covers how OpenFOAM does this for a small set of simple models that implement implicit surfaces.

The class hierarchy in this example library model implicit surfaces, such as a plane, a sphere, an ellipsoid, a sinc function, etc. Each surface computes a value and a gradient at some point $\mathbf{x} \in \mathbb{R}^3$ using different data members. The models are selected in a pre-processing application using command line arguments.

Disclaimer

I’m linking a lot to the extended Code Guide in this post, so

This offering is not approved or endorsed by OpenCFD Limited, producer and distributor of the OpenFOAM software via www.openfoam.com, and owner of the OPENFOAM® and OpenCFD® trade marks.

Command line arguments in OpenFOAM applications

In this example, the test application is actually a preprocessing application that sets values given by implicit surfaces at cell centers or cell corner points in an unstructurd mesh in OpenFOAM.

To select different surfaces based on their names, we need to be able to pass the surface name as an argument of the OpenFOAM application.

In OpenFOAM, the argList class is used for storing and processing command line arguments, that we want to use here for the runtime type selection. In any OpenFOAM application, argList options can be extended using the argList::addOption static member function

addOption(
    const word& optName,
    const string&  param = "",
    const string& usage = "",
    bool advanced = false 
);		

In an application, we can then write

        
    int main(int argc, char *argv[])
    {
        argList::addOption
        (
            "surfaceType",
            "Surface type name.",
            "plane, sphere, ellipsoid, sinc, sincScaled"
        );

Once this is done, the information given to the addOption static member function is available when the -help option is called for the applicatoin

-surfaceType <Surface type name.>
             plane, sphere, ellipsoid, sinc, sincScaled

Besides its name, each surface works with different parameters, so an option to parse surface parameters is added to the application, together with the options that read the names of fields set by the application.

    argList::addOption
    (
        "surfaceParams",
        "Surface parameters.",
        "Surface parameters:\
for a plane the position and normal '(px py pz) (nx ny nz)',\
for a sphere the center and radius '(cx cy cz) r',\
for an ellipsoid center and half-axes vectors '(cx cy cz) (ax ay az)'." 
    );

    argList::addOption
    (
        "volFieldName",
        "field name",
        "Name of the vol field to be calculated."
    );

    argList::addOption
    (
        "pointFieldName",
        "field name",
        "Name of the point field to be calculated."
    );

The weird spacing of surfaceParams makes sure the -help information looks nice

-surfaceParams <Surface parameters.>
                Surface parameters:for a plane the position and normal '(px
                py pz) (nx ny nz)',for a sphere the center and radius '(cx
                cy cz) r',for an ellipsoid center and half-axes vectors '(cx
                cy cz) (ax ay az)'.

Stream constructors

Obviously, each surface has parameters of different types (scalars or vectors) in different combinations. To handle this, the classes that implement the surfaces are given ITstream constructors - this is the type returned by argList when processing option parameters in OpenFOAM.

In an application, we can “lookup” the values of the -surfaceParams option used in the command line

    args.lookup("surfaceParams")

and “argList::lookup” returns an ITstream object.

Note: OpenFOAM uses the stream abstraction to serialize objects when doing Input / Output. More importantly, uses streams (see Pstream) for message-passing parallel program implementation.

Like any other input stream, data tokens from the input token stream ITstream can be “streamed into” variables. For example, we can initialize a sphere like this

    sphere::sphere(ITstream is)
    {
        is >> center_; 
        is >> radius_; 
    }

The question may arise: how does the stream operator >> know how to insert data from the stream object is into the vector center_ or a scalar radius_? The short answer is: don’t worry about this if you are building your models using existing OpenFOAM classes: many available OpenFOAM classes are programmed to support this. If you are programming your own data type completely from scratch (without using primitives such as scalars, vectors, tensors, etc.), you need to define the operator >> that works with input/output streams and your new type, in other words, you have to serialize your new type yourself.

Type information

Default runtime type selection in OpenFOAM works with type names. We give it a name of our model, and it returns the object of this model. The type name information can be added to each class using the TypeName macro.

    class implicitSurface
    {
        public: 

            TypeName("implicitSurface");

and for the derived classes


    class ellipsoid : public implicitSurface
    {
        vector center_; 
        vector axes_; 
        vector axesSqr_;

        void setAxesSqr(const vector& axes);

        public:

            TypeName ("ellipsoid");

the definition of the TypeName macro is in typeInfo.H.

The declarations generated by the TypeName macro must be paired by the definition macro, for the ellipsoid

    defineTypeNameAndDebug(ellipsoid, false);

Note: The TypeName macro adds a virtual function virtual word type() const to the class where it is used, so the class is required to have a virtual destructor if TypeName is used.

Runtime selection (RTS) table

When implementing your classes, separate the class interface (declaration of the class) from its implementation. However, this is a “do what I say, not what I do example”, where I’ve placed all the declarations in one single file, and all the definitions in another file. The reason: these classes are very small and I’m on them alone currently.

The type information is not enough for making sure the models can be selected at runtime. In the parent class, at the root of the hierarchy where the runtime selection starts, a macro is required to declare a so-called Runtime Selection Table (RTS). The inner workings of the RTS are not important: the takaway message is that the table is an object that can map a string (the type name, read from the command line or a configuration dictionary file) to an object of the model we want to use.

In this example, the runtime selection table is added to the “implicitSurface” abstract base class

    declareRunTimeSelectionTable
    (
        autoPtr,
        implicitSurface, 
        ITstream, 
        (
            ITstream is
        ), 
        (is)
    );

This macro is defined in “runTimeSelectionTables.H” and it specifies a couple of things. First Foam::autoPtr is the smart pointer to our model that will be returned in the model selection process. Second, implicitSurface is the class that stores the RTS table (the base class in the selection hierarchy), ITstream is the name of the constructor, and the rest are strings used to forward constructor arguments.

The declarations given by this macro need to be paired by definitions generated by corresponding definition macro for the RTS table

    defineRunTimeSelectionTable(implicitSurface, ITstream);

This macro is defined in “addToRunTimeSelectionTable.H”.

Next, a static builder member function (in OpenFOAM its called New) needs to be declared and defined for the class where the RTS table declared

    static autoPtr<implicitSurface> New(
        const word& name, 
        ITstream is
    );

The job of implicitSurface::New is to look up the name of the model in the RTS table and select its constructor function, then pas the ITstream argument to the constructor function and return the model we want,

autoPtr<implicitSurface> implicitSurface::New 
(
    const word& name, 
    ITstream is
)
{
    // Find the constructor pointer for the model in the constructor table.
    ITstreamConstructorTable::iterator cstrIter =
        ITstreamConstructorTablePtr_->find(name);

    // If the constructor pointer is not found in the table.
    if (cstrIter == ITstreamConstructorTablePtr_->end())
    {
        FatalErrorIn (
            "AI::implicitSurface::New(const word&, ITstream&&)"
        )   << "Unknown implicitSurface type "
            << name << nl << nl
            << "Valid implicitSurfaces are : " << endl
            << ITstreamConstructorTablePtr_->sortedToc()
            << exit(FatalError);
    }

    // Construct the model and return the autoPtr to the object. 
    return autoPtr<implicitSurface>
        (cstrIter()(is));
}

When writing your own classes, modify the declaration of the RTS table depending on the type of arguments passed over to the pointer of the constructor function cstrIter in New, the rest keep as it is, because it is black magick.

Note: if you ever want to look into what those macros do, you can expand them, just know that sometimes when you look into the abyss, the abyss looks back into you. :)

Here’s a snapshot of the source code as a summary. The declaration of the implicitSurface

    class implicitSurface
    {
        public: 

            TypeName("implicitSurface");

            declareRunTimeSelectionTable
            (
                autoPtr,
                implicitSurface, 
                ITstream, 
                (
                    ITstream is
                ), 
                (is)
            );

            static autoPtr<implicitSurface> New(
                const word& name, 
                ITstream is
            );

            implicitSurface() = default;

            implicitSurface(ITstream) {};

            virtual ~implicitSurface() = default;

            virtual scalar value(const vector&) const = 0;
            virtual scalar operator()(const vector&) const = 0;
            virtual vector grad(const vector&) const = 0;
    };

definition of the implicitSurface

// * * * * * * * * * * * * * * Static Members * * * * * * * * * * * * * //

defineTypeNameAndDebug(implicitSurface, false);
defineRunTimeSelectionTable(implicitSurface, ITstream);

autoPtr<implicitSurface> implicitSurface::New 
(
    const word& name, 
    ITstream is
)
{
    // Find the constructor pointer for the model in the constructor table.
    ITstreamConstructorTable::iterator cstrIter =
        ITstreamConstructorTablePtr_->find(name);

    // If the constructor pointer is not found in the table.
    if (cstrIter == ITstreamConstructorTablePtr_->end())
    {
        FatalErrorIn (
            "AI::implicitSurface::New(const word&, ITstream&&)"
        )   << "Unknown implicitSurface type "
            << name << nl << nl
            << "Valid implicitSurfaces are : " << endl
            << ITstreamConstructorTablePtr_->sortedToc()
            << exit(FatalError);
    }

    // Construct the model and return the autoPtr to the object. 
    return autoPtr<implicitSurface>
        (cstrIter()(is));
}

Declaration of the plane

class plane : public implicitSurface
{
    vector position_; 
    vector normal_; 

    public:

        TypeName ("plane");

        plane() = default;

        plane(vector position, vector normal);

        plane(ITstream is);

        virtual ~plane() = default;

        virtual scalar value(const vector& x) const;

        virtual scalar operator()(const vector& x) const;
        
        virtual vector grad(const vector& x) const;

        vector position() const;

        vector normal() const;
};

and the definition of the plane

// * * * * * * * * * * * * Class plane  * * * * * * * * * * * //

// * * * * * * * * * * * * * * Static Members * * * * * * * * * * * * * //

defineTypeNameAndDebug(plane, false);
addToRunTimeSelectionTable(implicitSurface, plane, ITstream);

// * * * * * * * * * * * * * * Constructors  * * * * * * * * * * * * * * //

plane::plane(vector position, vector normal)
: 
    position_(position), 
    normal_(normal)
{
    normal_ /= Foam::mag(normal_);
}

plane::plane(ITstream is)
{
    is >> position_; 
    is >> normal_; 
}

// * * * * * * * * * * * * * Member Functions * * * * * * * * * * * * * //

scalar plane::value(const vector& x) const
{
    return Foam::dot(x - position_, normal_);
}

scalar plane::operator()(const vector& x) const
{
    return value(x); 
}

vector plane::grad(const vector& x) const
{
    return normal_; 
}

vector plane::position() const
{
    return position_; 
}

vector plane::normal() const
{
    return normal_; 
}

The declaration of “implicitSurface” and it’s definition are also on GitLab.

Selecting the models

Inside the application, we can now select the model via the command line

    autoPtr<AI::implicitSurface> surfPtr = 
        AI::implicitSurface::New(surfaceType, args.lookup("surfaceParams"));

The surfaceType finds the clone function of the model in the RTS table and passes the ITstream object, taken from the args object, as the argument to the cloning function (constructor pointer, clone function pointer). This constructor then uses the stream to initialize its data members, just like in the snippet above for the plane surface.

This is how any other model is “selected” in the application code, the only difference being the base (root) RTS class, smart pointer, and constructor arguments. For example, the turbulence models are selected in createFields.H of the simpleFoam solver like this:

autoPtr<incompressible::turbulenceModel> turbulence
(
    incompressible::turbulenceModel::New(U, phi, laminarTransport)
);

Putting the library together

Now we wrote a bunch of classes that implement implicit surfaces and used macros to enable their selection at runtime using their type names. The next step is the building of an OpenFOAM library.

Regardless if you are coding in a separate repository with the folder structure similar to the one used in OpenFOAM, or you’re already programming within OpenFOAM in your feature git branch, the library files belong together in the library folder and the application files are put together in the application folder.

I’m using a small separate repository, so I can get away with using relative paths, if you are working within OpenFOAM, you can use its environmental variables to point the application to your library, to get a list of those, run

?> env | grep WM 
?> env | grep FOAM

OpenFOAM uses the wmake build system, that requires a Make folder that contains two files:

1. files: list of files to be will be compiled 
2. options: paths to include directories, 
    a list of libraries to dynamically link against 

The Make/files and Make/options are common for libraries and applications.

Building the library

In my repository, the library is in src/implicitSurfaces, with Make/files

    implicitSurfaces.C

    LIB = $(FOAM_USER_LIBBIN)/libimplicitSurfaces

So, the file that contains the implementation of all the small model classes is called implicitSurfaces.C and it is compiled into a libimplicitSurfaces.so library, stored in the OpenFOAM user directory for library binaries $FOAM_USER_LIBBIN. This directory should be used for building user libraries instead of $FOAM_LIBBIN, because on some systems, writing in $FOAM_LIBBIN may require root rights.

The Make/options are

    EXE_INC = \
        -DAI_DEBUG \
        -I$(LIB_SRC)/OpenFOAM/lnInclude 

    LIB_LIBS = \
        -lOpenFOAM 

The -DAI_DEBUG defines the AI_DEBUG flag that I use for debugging in this project, that’s an example of how Make/options can be used to pass options to the compiler.

The wmake build system creates lnInclude directory: a directory that contains symbolic links to all *.C and *.H files in all the sub-directries of the library directory. This simplifies the inclusion of declaration files, that would otherwise require a path to each sub-directory. The library only requires libOpenFOAM.so library, so that’s what it links agaist in the last line.

Building the application that uses the library

Once the library is compiled, we want to use it in an executable application. To do this, the application must know where to find the files that belong to the library. Like above for OpenFOAM, we need a path to the libraries lnInclude directory in Make/options, and we need to specify that we want the application to link dynamically with our library, so Make/options look like this

    EXE_INC = \
        -I$(LIB_SRC)/finiteVolume/lnInclude \
        -I$(LIB_SRC)/meshTools/lnInclude \
        -I../../../src/implicitSurfaces/lnInclude \

    EXE_LIBS = \
        -lfiniteVolume \
        -lmeshTools \
        -L$(FOAM_USER_LIBBIN) \
        -limplicitSurfaces

Here a relative path to implicitSurfaces/lnInclude is used and OpenFOAM requires it to be specified that the library is located in $FOAM_USER_LIBBIN, so -L$FOAM_USER_LIBBIN is used to tell the linker about this directory.

Access to the code

The example “implicitSurface” library is available on GitLab, and the preprocessing application as well.