action functions
|
Please note that the library has not been "officially" released. While we continue to work on the documentation, these web pages are likely to contain broken links and documents in draft form. Please send an email to if you wish to be informed of the library's "official" release. |
The purpose of this document is to provide a "quick" introduction to the fundamental objects in oomph-lib. Assuming that you
oomph-lib. You should also consult the extensive list of example codes distributed with the library.oomph-lib, its discretisation needs to be specified in a Problem object. This usually involves the specification of the Mesh and the element types, followed by the application of the boundary conditions. If these steps are performed in the Problem constructor, the driver code itself can be as simple as: int main() { //Build the problem DemoPoissonProblem problem; //Solve the problem, using Newton's method problem.newton_solve(); }
The amount of work required to represent a given PDE as a Problem object is problem-dependent:
oomph-lib's existing Mesh objects and element types are sufficient to discretise the problem, the generation of a fully-functional Problem object is straightforward. We shall discuss this case in the section How to build a Problem. oomph-lib provides the required element type but none of the available meshes are suitable, you will have to write your own Mesh object. We shall discuss this in the section How to build a Mesh. It is also possible to create oomph-lib meshes based on the output from third-party (commercial) mesh generators. This is illustrated in another example. oomph-lib does not provide any suitable elements. In this case you will need to write your own element. When implementing elements in oomph-lib the element formulation is generally sub-divided into (at least) two levels. Geometric elements implement the element geometry (e.g. 2D quad elements, 3D brick elements, etc); these geometric elements act as base classes for specific FiniteElements that implement the discretisation of particular PDEs. We discuss the implementation of the two types of elements in two separate sections:
|
oomph-lib library. [oomph-lib does, of course, provide 1D meshes and Poisson elements but we deliberately ignore these because we wish to illustrate how to build such objects from scratch. The alternative example code one_d_poisson.cc, discussed in another example illustrates the implementation of the same problem using existing library objects.]
The main purpose of this document is to provide a "quick" introduction to oomph-lib's fundamental objects, so the example classes presented below are fully-functional but very basic. We provide exercises at the end of each section to allow the reader to explore various straightforward improvements, most of which are implemented in the corresponding objects in the library. Finally, the section Further comments provides a discussion of some general design principles that should be respected when creating new classes for use with the library.
Problems should be implemented as objects that inherit from the generic Problem class. These specific Problem objects will vary considerably depending on the exact details of the problem being solved.Only a few member functions must be implemented for each specific Problem class:
SomeSpecificProblem(...)
Problem::actions_before_newton_solve()
Problem::actions_after_newton_solve()
SomeSpecificProblem(...) usually contains the following steps: Problem::add_time_stepper_pt(new SomeTimeStepper());
Mesh (which may involve passing a particular element type as a template parameter), e.g. Problem::mesh_pt() = new SomeMesh<SomeElement>(...);
Problem::mesh_pt()->node_pt(0)->pin(0);
Problem::mesh_pt()->node_pt(0)->set_value(0,1.0);
Problem::assign_eqn_numbers();
oomph-lib treats all problems as non-linear problems and employs Newton's method to solve the system of nonlinear algebraic equations that results from its spatial (and, in time-dependent problems, temporal) discretisation. Within this framework, linear problems are special cases for which Newton's method converges in one step.
The (pure virtual) member function Problem::actions_before_newton_solve() should contain everything that must be done before a nonlinear solve to complete the specification of the problem. For example, the function may contain a call to update the boundary conditions.
void actions_before_newton_solve() {update_boundary_conditions();}
If desired, finer granularity may be obtained by overloading the empty virtual functions Problem::actions_before_newton_convergence_check(), Problem::actions_before_newton_step() and/or Problem::actions_before_implicit_timestep(), which are executed before each calculation of the maximum value of the residuals, each Newton step or each timestep, respectively. We refer to a separate document for a more detailed discussion of the various "action functions" executed by oomph-lib's Newton solver.
Problem::actions_after_newton_solve() should contain everything that is to take place after each nonlinear solve. For example, the function might contain commands to update the values of any "slave" variables, or post-processing commands. void actions_after_newton_solve() { //Update value of the slave variable in namespace GlobalVariables GlobalVariables::slave = node_pt(0)->value(0) + node_pt(0)->value(1); //Call the output function ofstream output_file("result.dat"); output(output_file); }
Again, the finer-grained member functions Problem::actions_after_newton_step() and Problem::actions_after_implicit_timestep() are provided.
DemoPoissonProblem class (taken from one_d_poisson_generic_only.cc) which implements the discretisation of the 1D Poisson problem described above, using a Mesh of type OneDimMesh, with elements of type TwoNodePoissonElements.
class DemoPoissonProblem : public Problem { public: /// Problem constructor: Pass the sign of the source function (default /// is +1) DemoPoissonProblem(const int& sign=1) : Sign(sign) { //Create a OneDimMesh Mesh object and set it to be the problem's mesh. //The element type, TwoNodePoissonElement, is passed as a template //parameter to the mesh. The argument to the constructor indicates //the number of elements in the mesh. Problem::mesh_pt() = new OneDimMesh<TwoNodePoissonElement>(10); //Pin the unknowns at the ends of the 1D domain: //The 1D mesh has 2 boundaries, each of which contains a single node; //the nodes on the boundary are available from Mesh::boundary_node_pt(...) //Pin the single nodal value at the single node on mesh //boundary 0 (= the left domain boundary at x=0) mesh_pt()->boundary_node_pt(0,0)->pin(0); //Pin the single nodal value at the single node on mesh //boundary 1 (= the right domain boundary at x=1) mesh_pt()->boundary_node_pt(1,0)->pin(0); // All values are initialised to zero. This is consistent with the // boundary condition at x=0 and no further action is required // at that node. // Apply the boundary condition at x=1: u(x=1)=-/+1 mesh_pt()->boundary_node_pt(1,0)->set_value(0,-double(Sign)); // Finish problem setup: Set the sign for the source function // in all elements //Find number of elements in mesh unsigned n_element = mesh_pt()->nelement(); // Loop over the elements for(unsigned i=0;i<n_element;i++) { // The sign() member function is defined in the // TwoNodePoissonElement class not the base GeneralisedElement class. // In order to use it, we must // upcast from GeneralisedElement to the specific element type, // which is achieved by a C++ dynamic_cast. TwoNodePoissonElement *specific_element_pt = dynamic_cast<TwoNodePoissonElement*>(mesh_pt()->element_pt(i)); // Set the sign of the source function specific_element_pt->sign() = Sign; } //Assign the global and local equations numbers for the problem cout << "Number of equations is " << assign_eqn_numbers() << std::endl; } /// Check that everything has been set up properly void actions_before_newton_solve() { if (0==self_test()) { cout << "Problem has been set up correctly and can be solved." << std::endl; } else { throw OomphLibError("Trouble! Check error messages and fix the problems.\n", "DemoPoissonProblem::actions_before_newton_solve()", OOMPH_EXCEPTION_LOCATION); } } /// Print out the result after the solve void actions_after_newton_solve() { ofstream file ("result.dat"); mesh_pt()->output(file); } private: /// The sign of the source function int Sign; }; //End of problem definition
(The sign of the source function is an optional argument to the problem constructor. If no argument is specified, the + sign is used.) Modify the actions_after_newton_solve() function so that it writes the exact solution into another file, "exact_solution.dat", say.
.
? (Hint: What are the natural boundary conditions for the Poisson equation?)Problem::self_test().oomph-lib provides a large number of fully-functional Mesh objects. Many of these meshes can easily be adapted to discretise other domains, provided the domain has the same topology as the original Mesh. We shall illustrate this in section How to adapt an existing Mesh to a different domain shape below.Mesh object, SpecificMesh, say, must be created. The specific mesh should be created as an object that inherits from the generic Mesh class. To maximise the potential for code-reuse, we recommended making the element type a template parameter of the SpecificMesh class.
template<class ELEMENT> class SpecificMesh : public Mesh
This allows the SpecificMesh to be used with any FiniteElement that is based on the same geometric element type (see the section How to build a new geometric element for a more detailed discussion of geometric elements).
The minimum requirements for a specific mesh object are that it must:
Nodes,Mesh::Element_pt vector,Nodes in the Mesh::Node_pt vector,Nodes Nodes.Mesh constructors also set up an auxiliary lookup scheme that stores information about the mesh boundaries. While this is not strictly required, it greatly facilitates the application of boundary conditions in complex domains. Firstly, the function Mesh::set_nboundary(...) must be called to specify the number of mesh boundaries. In addition, the generic Mesh class provides a function Mesh::add_boundary_node(i,node_pt), which adds the Node pointed to by node_pt to the Mesh's i -th boundary and constructs the required look-up schemes, including a reverse lookup scheme that informs all Nodes which mesh boundaries (if any) they are located on. (In our simple 1D example there are only two boundaries, each consisting of a single Node.) Although this step is optional, it is required during mesh adaptation -- if the reverse lookup scheme has not been set up, a warning is issued and the code execution stops when any mesh adaptation is attempted. Implementation detail: Nodes that are located on mesh boundaries must be defined as BoundaryNodes.
The majority of the work required to build a specific Mesh takes place in its constructor. Typically, the constructor creates the elements (of the type specified by the template parameter ELEMENT) and uses the elements' member function FiniteElement::construct_node(...) to create the new Nodes. The equivalent member function FiniteElement::construct_boundary_node(...) is used to create new BoundaryNodes.
Here is the complete class definition for a simple, one-dimensional (line) mesh which discretises the 1D domain
using a specified number of equally-spaced elements.
template<class ELEMENT> class OneDimMesh : public Mesh { public: /// Mesh Constructor. The argument is the desired number of elements OneDimMesh(const unsigned &n_element) { //Resize the vector of pointers to elements: there are n_element elements Element_pt.resize(n_element); //Construct the first element (Note the use of the template parameter) Element_pt[0] = new ELEMENT; //Construct the first node and add it to the Mesh::Node_pt vector //Note: The FiniteElement::construct_boundary_node(j) function //builds the element's j-th local node, and provides the functionality //that allows it to be located on a Mesh boundary, essentially this //involves allocating additional storage to the Node. //The function obtains the Node's //characteristics (e.g. its spatial dimension, the number of //values to be stored, etc) from various virtual FiniteElement //member functions, such as FiniteElement::required_nvalue(). //FiniteElement::construct_boundary_node(...) also //stores a pointer to the newly created Node in the element's own //Node_pt vector. //Finally, the function returns the pointer to the //the newly created Node, so that it can be stored in the Mesh's Node_pt //vector, as done here: Node_pt.push_back(finite_element_pt(0)->construct_boundary_node(0)); //Find the number of nodes per element (N.B. all elements are identical //so we can determine this value once and for all). unsigned n_node = finite_element_pt(0)->nnode(); //Loop over the remaning nodes of the first element for(unsigned n=1;n<n_node;n++) { //Construct the next node and add it to the Mesh::Node_pt vector //Note that these interior nodes need not (and should not) //be boundary nodes, so they are created using the construct_node //function, which has the same interface as //construct_boundary_node() Node_pt.push_back(finite_element_pt(0)->construct_node(n)); } //Loop over the remaining elements apart from the last for(unsigned e=1;e<(n_element-1);e++) { //Construct the e-th element Element_pt[e] = new ELEMENT; //The first local node of the e-th element is the last local node //of the the (e-1)-th element. We MUST NOT construct the node twice. //Instead, we set the pointer in the e-th element to point to the //previously created node in the (e-1)-th element. finite_element_pt(e)->node_pt(0) = finite_element_pt(e-1)->node_pt(n_node-1); //Loop over the remaining nodes of the e-th element for(unsigned n=1;n<n_node;n++) { //Construct the next node and add it to the Mesh::Node_pt vector //Note that these interior nodes need not (and should not) //be boundary nodes, so they are created using the construct_node //function, which has the same interface as //construct_boundary_node() Node_pt.push_back(finite_element_pt(e)->construct_node(n)); } } //End of loop over elements //Construct the final element Element_pt[n_element-1] = new ELEMENT; //The first local node of the final element is the last local node //of the the penultimate element. We MUST NOT construct the node twice. //Instead, we set the pointer in the final element to point to the //previously created node in the penultimate element. finite_element_pt(n_element-1)->node_pt(0) = finite_element_pt(n_element-2)->node_pt(n_node-1); //Loop over the remaining central nodes of the final element for(unsigned n=1;n<(n_node-1);n++) { //Construct the next node and add it to the Mesh::Node_pt vector //Note that these interior nodes need not (and should not) //be boundary nodes, so they are created using the construct_node //function() Node_pt.push_back(finite_element_pt(n_element-1)->construct_node(n)); } //Construct the final node and add it to the Mesh::Node_pt vector. //This node will be located on a boundary, and hence we use //the construct_boundary_node function. Node_pt.push_back(finite_element_pt(n_element-1) ->construct_boundary_node(n_node-1)); //We've now created all the nodes -- let's set their positions: //Find the total number of nodes unsigned n_global_node = nnode(); //Loop over all nodes for(unsigned n=0;n<n_global_node;n++) { //Set the position of the node (equally spaced through the unit interval) Node_pt[n]->x(0) = double(n)/double(n_global_node-1); } //Set the boundary data: //There are two boundaries in this mesh set_nboundary(2); //Boundary 0 contains the first node in the mesh: add_boundary_node(0,Node_pt[0]); //Boundary 1 contains the final node in the mesh: add_boundary_node(1,Node_pt[n_global_node-1]); } // End of constructor }; // End of OneDimMesh class.
To build a OneDimMesh with ten elements of type SomeElement, say, the Problem constructor would contain the following
Problem::mesh_pt() = new OneDimMesh<SomeElement>(10);
Mesh into a shape that matches the required domain. Provided that the new domain has the same topology as the domain represented by the original Mesh, this can always be done by re-positioning the Nodes. Such "deformed" Meshes should be implemented via inheritance, by deriving the new Mesh from an exisiting one.
Here is an example that illustrates the procedure. Assume we wish to solve an equation in the 2D annular domain bounded (in polar coordinates) by
and
. Inspection of the list of available meshes shows that oomph-lib does not provide a mesh for this geometry. However, there is a Mesh object, SimpleRectangularMesh, which provides a uniform discretisation of a 2D rectangular domain
and
with
quadrilateral elements. Since, the topology of the two domains is identical, the annular mesh can be implemented in a few lines of code:
//==================================================================== /// AnnularQuadMesh, derived from SimpleRectangularQuadMesh. //==================================================================== template<class ELEMENT> class AnnularQuadMesh : public SimpleRectangularQuadMesh<ELEMENT> { public: /// \short Constructor for angular mesh with n_r x n_phi /// 2D quad elements. Calls constructor for the underlying /// SimpleRectangularQuadMesh; then deforms the mesh so that it fits /// into the annular region bounded by the radii r_min and r_max /// and angles (in degree) of phi_min and phi_max. AnnularQuadMesh(const unsigned& n_r, const unsigned& n_phi, const double& r_min, const double& r_max, const double& phi_min, const double& phi_max) : SimpleRectangularQuadMesh<ELEMENT>(n_r,n_phi,1.0,1.0) { // The constructor for the SimpleRectangularQuadMesh has // built the mesh with n_x x n_y = n_r x n_phi elements in the unit // square. Let's reposition the nodal points so that the mesh // gets mapped into the required annular region: // Find out how many nodes there are unsigned n_node=nnode(); // Calculate the value of pi const double pi = 4.0*atan(1.0); // Loop over all nodes for (unsigned n=0;n<n_node;n++) { // Pointer to node: Node* nod_pt=node_pt(n); // Get the x/y coordinates double x_old=nod_pt->x(0); double y_old=nod_pt->x(1); // Map from the old x/y to the new r/phi: double r=r_min+(r_max-r_min)*x_old; double phi=(phi_min+(phi_max-phi_min)*y_old)*pi/180.0; // Set new nodal coordinates nod_pt->x(0)=r*cos(phi); nod_pt->x(1)=r*sin(phi); } } };
OneDimMesh object so that it provides a piecewise uniform discretisation of the domain
:
equally spaced elements are to be placed in the the region
, while
elements are to be placed in the the region
. Pass the parameters
and
to the mesh constructor. Construct the modified mesh via inheritance from the basic OneDimMesh and include an error check to confirm that
.
oomph-lib provides fully-functional FiniteElements for the discretisation of a wide range of PDEs. Most of the existing elements are constructed in a three-level hierarchy, the base being the generic FiniteElement class. The next level in the hierarchy contains geometric elements (e.g. 1D line elements, 2D quad or triangular elements, 3D brick elements, etc.). These geometric classes form the bases for elements that implement the discretisation of particular PDEs. This hierarchy maximises the potential for code-reuse, because it allows many different specific elements to be derived from the same geometric element.We shall discuss the implementation of new element types in two sections:
SpecificElement, say, that implements the discretisation of a system of PDEs on an existing geometric element. The SpecificElement class needs to implement the following functions:FiniteElement::required_nvalue(n)
FiniteElement::get_residuals(residuals)
FiniteElement::get_jacobian(residuals,jacobian)
FiniteElement::output(ostream)
Most specific finite element classes will contain further member functions and member data to provide additional, problem-specific functionality. As a concrete example, we consider the TwoNodePoissonElement, a specific FiniteElement that provides an isoparametric discretisation of the 1D Poisson equation (1), based on the geometric element TwoNodeGeometricElement, to be discussed below:
class TwoNodePoissonElement : public TwoNodeGeometricElement
In addition to the FiniteElement member functions discussed above, this element provides a function that defines the source function
,
double f(const double &x) const { return double(Sign)*30.0*sin(sqrt(30.0)*x); }
where the sign of the source function is stored as private member data
int Sign;
and can be set by the access function
int& sign() {return Sign;}
We provide a member function that returns the (single) nodal value stored at a specified local node in the element,
double u(const unsigned &n) {return node_pt(n)->value(0);}
Finally, it is good practice to implement a self-test function that provides a sanity check of all data before the problem is solved. oomph-lib already provides self-test functions for all fundamental objects. Additional tests can be added by overloading these. For instance, in our Poisson element, the Sign variable should only take the values
. This can be tested with the following function, which also executes the self_test() function of the underlying FiniteElement:
/// \short Self test function: The sign in the source function should /// only have the values +/- 1. Following the general oomph-lib convention, /// the self_test() returns 0 for success, and 1 for failure: unsigned self_test() { // Initialise success flag unsigned success=0; // Run the generic FiniteElement self test success=FiniteElement::self_test(); // Do additional test for this function if ((Sign!=1)&&(Sign!=-1)) { cout << "Sign of source function should be +/- 1," << std::endl; cout << "but it is:" << Sign << std::endl; success=1; } // Return success flag return success; } // End of self test
We will now discuss the implementation of the generic FiniteElement member functions for the specific TwoNodePoissonElement:
n-th local node. In our scalar Poisson problem, each node stores one value:
/// For the Poisson equation, only one value is stored at each node unsigned required_nvalue(const unsigned &n) const {return 1;}
The function is used by FiniteElement::construct_node(...) to determine the amount of storage to be allocated at each of the element's Nodes.
void get_residuals(Vector<double> &residuals) { //Find the number of degrees of freedom (unpinned values) in the element unsigned n_dof = ndof(); //Initialise all the residuals to zero for(unsigned i=0;i<n_dof;i++) {residuals[i] = 0.0;} //Find the number of nodes in the element unsigned n_node = nnode(); //Allocate memory for shape functions and their derivatives: // There's one shape function for each node: Shape psi(n_node); // Each of the n_node shape functions has one derivative with // respect to the single local coordinate: DShape dpsidx(n_node,1); //Storage for the single local coordinate Vector<double> s(1); //Find the number of integration points in the underlying //geometric element's integration scheme unsigned n_intpt = integral_pt()->nweight(); //Loop over the integration points for(unsigned ipt=0;ipt<n_intpt;ipt++) { //Set the value of the local coordinate to be the integration //scheme's knot point s[0] = integral_pt()->knot(ipt,0); //Find the weight of the integration scheme at this knot point double w = integral_pt()->weight(ipt); //Find the shape functions and their derivatives at the knot point. //This function is implemented in FiniteElement. //It also returns the Jacobian of the mapping from local to //global coordinates. double J = dshape_eulerian(s,psi,dpsidx); //Premultiply the weight and the Jacobian double W = w*J; //Allocate storage for the value of the field variable u, //its derivative and the global position at the knot point. //Initialise them all to zero. double interpolated_x=0.0, interpolated_u=0.0, interpolated_dudx=0.0; //Calculate the interpolated values by looping over the shape //functions and summing the appropriate contributions for(unsigned n=0;n<n_node;n++) { interpolated_x += nodal_position(n,0)*psi[n]; interpolated_u += u(n)*psi[n]; interpolated_dudx += u(n)*dpsidx(n,0); } // Evaluate the source function double source=f(interpolated_x); //ASSEMBLE THE RESIDUALS //Loop over the test functions (same as the shape functions //since we're implementing an isoparametric element) for(unsigned l=0;l<n_node;l++) { //Get the local equation number //The variable is the first (only) value stored at the nodes int local_eqn_number = nodal_local_eqn(l,0); //If the equation is not a Dirichlet boundary condition if(local_eqn_number >= 0) { //Add body force/source term here residuals[local_eqn_number] += source*psi[l]*W; //Add the Poisson bit itself residuals[local_eqn_number] += interpolated_dudx*dpsidx(l,0)*W; } } } //End of loop over the integration points } //End of function
void get_jacobian(Vector<double> &residuals, DenseMatrix<double> &jacobian) { //First, calculate the residuals get_residuals(residuals); //Find the number of degrees of freedom (unpinned values) in the element unsigned n_dof = ndof(); //Initialise all entries of the Jacobian matrix to zero for(unsigned i=0;i<n_dof;i++) { for(unsigned j=0;j<n_dof;j++) {jacobian(i,j) = 0.0;} } //Find the number of nodes in the element unsigned n_node = nnode(); //Allocate memory for shape functions and their derivatives Shape psi(n_node); DShape dpsidx(n_node,1); //Storage for the local coordinate Vector<double> s(1); //Find the number of integration points in the underlying //geometric element's integration scheme unsigned n_intpt = integral_pt()->nweight(); //Loop over the integration points for(unsigned ipt=0;ipt<n_intpt;ipt++) { //Set the value of the local coordinate to be the integration //scheme's knot point s[0] = integral_pt()->knot(ipt,0); //Find the weight of the integration scheme at this knot point double w = integral_pt()->weight(ipt); //Find the shape functions and their derivatives at the knot point. //This function is implemented in FiniteElement. //It also returns the Jacobian of the mapping from local to //global coordinates. double J = dshape_eulerian(s,psi,dpsidx); //Premultiply the weight and the Jacobian double W = w*J; //ASSEMBLE THE JACOBIAN TERMS //Loop over the test (shape) functions for(unsigned l=0;l<n_node;l++) { //Get the local equation number //The variable is the first (only) value stored at the nodes int local_eqn_number = nodal_local_eqn(l,0); //If the equation is not a Dirichlet boundary condition if(local_eqn_number >= 0) { //Loop over the degrees of freedom for(unsigned l2=0;l2<n_node;l2++) { //Get the local degree of freedom number //The variable is the first (only) value stored at the nodes int local_dof_number = nodal_local_eqn(l2,0); //If the degree of freedom is not pinned if(local_dof_number >= 0) { //Add the contribution to the Jacobian jacobian(local_eqn_number,local_dof_number) += dpsidx(l2,0)*dpsidx(l,0)*W; } } } } } //End of loop over the integration points } //End of function
Note: There is a large amount of code duplication between the get_residuals() and get_jacobian() functions. To avoid this, we usually implement the computation of the residual vector and the Jacobian matrix in a single function containing the loop over the integration points. We then use a boolean flag as an additional argument to determine whether the Jacobian matrix should be assembled, e.g.
void get_generic_residual_contribution(Vector<double>& residuals, DenseMatrix<double>& jacobian, bool flag)
void output(ostream &output) { //Read out the number of nodes in the element unsigned n_node = nnode(); //Loop over the nodes and print out the global coordinate //and value of the field variable, u, at each node for(unsigned n=0;n<n_node;n++) { output << nodal_position(n,0) << " " << u(n) << std::endl; } } //End of function
get_generic_residual_contribution(...) as a private member function of the TwoNodePoissonElement. Use the function to avoid the large amount of code duplication between get_residuals(...) and get_jacobian(...) by re-writing these functions as follows: /// \short Calculate the elemental contributions to the residuals for /// the weak form of the Poisson equation void get_residuals(Vector<double> &residuals) { // Set flag for not computing the Jacobian bool flag=false; // Dummy Jacobian DenseMatrix<double> jacobian; // Compute the residuals only: get_generic_residual_contribution(residuals,jacobian,flag); }
/// \short Calculate the elemental contribution to the Jacobian /// matrix dR_{i}/du_{j} used in the Newton method void get_jacobian(Vector<double> &residuals, DenseMatrix<double> &jacobian) { // Set flag for computing the Jacobian matrix bool flag=true; // Compute the residuals and the Jacobian matrix: get_generic_residual_contribution(residuals,jacobian,flag); }
in TwoNodePoissonElement::f(...) makes it impossible to use the TwoNodePoissonElement to solve the Poisson equation with any other source functions, even though the discretisation of the ODE would otherwise be completely identical. A better implementation would allow the "user" to specify a different source function without having to change the implementation of the element class itself. oomph-lib:/// \short Function pointer to source function: /// The source function returns the value of the /// source function at the global coordinate x. typedef double (*PoissonSourceFctPt)(const double& x);
TwoNodePoissonElement class and initalise it to NULL in the constructor: /// \short Function pointer to source function (initialised to /// NULL in the constructor) PoissonSourceFctPt Source_fct_pt;
/// \short Access function to pointer to source function PoissonSourceFctPt& source_fct_pt() {return Source_fct_pt;}
TwoNodePoissonElement::f(...) function, so that it evaluates the (global) function pointed to by the source function pointer. If possible, provide a default value for the case when the function pointer has not been set: /// Evaluate source function at Eulerian position x double f(const double& x) const { //If no source function has been set, return zero //so that the Poisson equation defaults to a Laplace equation. double source=0.0; if(Source_fct_pt!=0) { // Evaluate source function source = (*Source_fct_pt)(x); } return source; }
TwoNodePoissonElement to a TwoNodeSelfAdjointElement that implements the isoparametric discretisation of the self-adjoint ODE
Use function pointers to allow the "user" to specify the coefficient functions
and
.
FiniteElement. They are usually implemented as distinct classes that can then be used to create a number of FiniteElements each discretising a specific PDE, but with the same underlying geometrical representation. For this purpose, each geometric FiniteElement must implement the following functions:SomeGeometricFiniteElement::SomeGeometricFiniteElement()
FiniteElement::set_n_node(n_node).
FiniteElement::set_dimension(dim)
FiniteElements in 3D space, say.)FiniteElement::set_integration_scheme(&integration_scheme)
FiniteElement::set_nodal_dimension(dim)
FiniteElement::set_n_nodal_position_type(n_position_type)
FiniteElements to interpolate the unknown function(s) between the nodal values. FiniteElement::shape(s,psi)
It is usually necessary to implement the following additional functions:
FiniteElement::dshape_local(s,psi,dpsids)
FiniteElement::nnode_1d()
FiniteElements must store a pointer to an (instantiated) spatial integration scheme. It is good practice to provide a default integration scheme for each geometric element and to ensure its instantiation by making it a static data member of the geometric element class. This allows the constructor of the geometric element to set the pointer to the default integration scheme. If the default is not appropriate for a specific derived FiniteElement, the default assignment can be over-written in the constructor of the derived class.
As a concrete example, we consider the implementation of the one-dimensional, two-node geometric element, TwoNodeGeometricElement, that uses linear shape functions to define the mapping between the element's local and global coordinates. The element is derived from the FiniteElement base class,
class TwoNodeGeometricElement : public FiniteElement
and it uses a one-dimensional, two-point Gauss rule as the default spatial integration scheme
/// \short Integration scheme that will be used to integrate over the element. /// Simple Gaussian quadrature in one dimension, with two Gauss points. static Gauss<1,2> Default_spatial_integration_scheme;
Here is the implementation of the generic FiniteElement member functions for our specific geometric FiniteElement:
TwoNodeGeometricElement()
{
//Linear interpolation requires two Nodes per element.
//In fact, calling this function merely provides storage for
//the pointers to the Nodes and initialises the pointers to NULL.
//The Nodes themselves are created during the mesh generation
//process by the functions FiniteElement::construct_node(...)
//which stores the pointers to the newly created Nodes
//in the element's own internal storage.
this->set_n_node(2);
//The element is one-dimensional
this->set_dimension(1);
//Set the pointer to the spatial integration scheme
set_integration_scheme(&Default_spatial_integration_scheme);
}
void shape(const Vector<double> &s, Shape &psi) const { //There are two shape functions (one per node) //In terms of the local coordinate, s[0]: //Node 0 is at s[0] = -1.0 //Node 1 is at s[0] = 1.0 //psi[0] takes the value one at node 0 and zero at node 1 psi[0] = 0.5*(1.0 - s[0]); //psi[1] takes the value one at node 1 and zero at node 0 psi[1] = 0.5*(1.0 + s[0]); }
void dshape_local(const Vector<double> &s, Shape &psi, DShape &dpsids) const { //Call the shape functions shape(s,psi); //The derivative of psi[0] wrt s[0] is -0.5 dpsids(0,0) = -0.5; //The derivative of psi[0] wrt s[0] is 0.5 dpsids(1,0) = 0.5; }
unsigned nnode_1d() const {return 2;}
TwoNodeGeometricElement to a ThreeNodeGeometricElement in which quadratic interpolation is used to interpolate the Eulerian coordinates between the nodal points. Use this element as a basis for a ThreeNodePoissonElement and convince yourself that changing the TwoNodePoissonElement to a ThreeNodePoissonElement only requires the change of a single line of code!template <unsigned NNODE> class GeometricLineElement<NNODE> : public FiniteElement
NNODE nodes. Consider carefully which member functions you can implement in generality and which member functions require specialised implementations. Provide the specialised member functions for elements with two, three and four nodes.GeometricLineElements to implement an equivalent generalisation of the TwoNodePoissonElement and ThreeNodePoissonElement classes to the general class template <unsigned NNODE> class PoissonLineElement<NNODE> : public GeometricLineElement<NNODE>
is discretised with
equally spaced
-node line elements, we have
where
.
oomph-lib's fundamental objects. The exercises have already highlighted several undesirable features of the simple example classes which could easily be improved to facilitate the (re-)use of the classes in different problems. Here we shall briefly discuss some further modifications that we regard as good practice, and which tend to be implemented in the existing classes in oomph-lib:
FiniteElements in arbitrary spatial dimensions. For instance, oomph-lib's QElement class represents the family of (line/quadrilateral/brick-shaped) geometric elements with an arbitrary number of nodes along the elements' 1D edges. Thus, a QElement<1,3> is a three-node 1D line element, a QElement<3,2> is an eight-node 3D brick element, etc.
These geometric elements naturally form the basis for corresponding specific elements, such as the QPoissonElement family which provides an isoparametric discretisation of the Poisson equation (in an arbitrary spatial dimension), based on the QElements with the same template parameters.
, and their derivatives with respect to the global coordinate,
, at the element's integration points. In our simple example class, TwoNodePoissonElement, we (re-)compute these functions "on the fly", using the function FiniteElement::dshape_eulerian(s,psi,dpsidx)
s.The re-computation is wasteful because:
Sign variable. Their re-computation during the second solve could therefore be avoided if we stored their values in a suitable container.oomph-lib provides alternative interfaces for various functions that compute shape functions and their derivatives:FiniteElement::shape(s,psi)
s, can be replaced by FiniteElement::shape_at_knot(int_point,psi)
unsigned argument int_point identifies the integration point (as specified by the element's spatial integration scheme). FiniteElements the function FiniteElement::shape_at_knot(...) simply determines the position of the integration point and calls FiniteElement::shape(...) and so the shape functions are still computed "on the fly". StorableShapeElement<ELEMENT>, however, may be used to upgrade any class derived from FiniteElement into a class that can store the values of the shape functions and their derivatives at the integration points. The function shape_at_knot(...) is overloaded so that when called for the first time, it computes the values of the shape function at all integration points and stores them for future reference. In subsequent calls, the function returns the stored values, rather than re-computing them. FiniteElement::dshape(s,psi,dpsids)
FiniteElement::dshape_at_knot(int_point,psi,dpsids)
FiniteElement::d2shape(s,psi,dpsids,d2psids)
FiniteElement::d2shape_at_knot(int_point,psi,dpsids,d2psids)
StorableShapeElement<ELEMENT> class and by default the overloaded functions store the pre-computed values of the shape functions and their derivatives locally within each element. This implementation ensures data locality and should increase the speed of access to the stored values. However, it can also create significant storage overheads. oomph-lib therefore provides the function StorableShapeElement<ELEMENT>::set_shape_local_stored_from_element(...)
Derivatives of the element's shape functions with respect to the global Eulerian coordinates are generally computed by
FiniteElement::dshape_eulerian(s,psi,dpsidx)
FiniteElement::d2shape_eulerian(s,psi,dpsidx,d2psidx)
FiniteElement::J_eulerian(s)
StorableShapeElement<ELEMENT>::dshape_eulerian_at_knot(int_point,psi,dpsidx)
StorableShapeElement<ELEMENT>::d2shape_eulerian_at_knot(int_point,psi,dpsidx,d2psidx)
StorableShapeElement<ELEMENT>::J_eulerian_at_knot(int_point)
StorableShapeElement<ELEMENT>::pre_compute_dshape_eulerian_at_knots()
StorableShapeElement<ELEMENT>::pre_compute_d2shape_eulerian_at_knots()
StorableShapeElement<ELEMENT>::pre_compute_J_eulerian_at_knots()
*_eulerian_at_knot(...) functions return the stored values. To revert to the case in which the derivatives are re-computed "on the fly", the storage for the derivatives must be deleted by calling StorableShapeElement<ELEMENT>::delete_dshape_eulerian_stored()
Notes:
StorableShapeSolidElement<ELEMENT> provides equivalent overloaded functions for derivatives with respect to the element's Lagrangian coordinates that are used in solid mechanics problems.
FiniteElement -- Geometric Element -- Specific FiniteElement. Most finite elements in oomph-lib incorporate an additional intermediate "equation class" which implements the computation of the element residual vector and the element Jacobian matrix in terms of abstract shape and test functions, defined as pure virtual functions in the "equation class". This makes it easy to change the specific element formulation, without having to re-implement the weak form of the governing equation.
Note that different element types may store the same physical variable at different locations. For example, the pressure in the Navier--Stokes equations may be stored as internal Data (discontinuous) or nodal Data (continuous). Particular equation classes may require internal numbering schemes that store the appropriate local equation numbers for each physical variable. These schemes must be assembled for each specific element in the function GeneralisedElement::assign_additional_local_eqn_numbers(), which is called from within Problem::assign_eqn_numbers().
As an example, consider the weak form of the 2D advection diffusion equation
where the Peclet number,
, and the "wind"
are given. We expand the unknown function
in terms of the (global) basis functions
,
where
is the total number of nodes in the mesh. The mapping between the element's local and global coordinates is represented in terms of the local shape functions
as
where
is the number of nodes in the element.
The following sketch illustrates how this discretisation is implemented in oomph-lib's QAdvectionDiffusionElement -- an isoparametric, quadrilateral element, based on the Galerkin discretisation of the weak form with 
Typical inheritance diagram for oomph-lib elements
At large Peclet number, the Galerkin discretisation of the advection diffusion equation is well-known to produce spurious "wiggles" in the solution. These can be suppressed by SUPG stabilisation which employs test functions,
, that differ from the basis function,
,
where
is a stabilisation parameter. This can be implemented with a trivial change to the QAdvectionDiffusionElement class -- the QSUPGAdvectionDiffusionElement simply provides a different implementation of the test functions.
oomph-lib's generic self_test() functions. The top-level Problem::self_test() function performs a systematic test of all fundamental objects involved in a specific problem and can be used to diagnose any problems. It is good practise to implement further self_test() functions in any newly developed classes. The generic self_test() functions are defined to be virtual functions in oomph-lib's fundamental objects and can be overloaded. Obviously, the self_test() function in a specific derived object should still call the self_test() function of the underlying fundamental object. The TwoNodePoissonElement::self_test() function, listed in in the section How to implement a new system of equations as a specific FiniteElement illustrates the procedure.
While frequent sanity checks are helpful during code-development, they can introduce significant overheads into the code execution. oomph-lib therefore provides a compiler flag PARANOID, which allows the execution of sanity checks to be switched on or off. When developing new classes, sanity checks should be implemented to catch any potential problems, but the relevant code should be surrounded by ifdef/endif statements to allow the tests to be disabled. Here is an example:
#include <typeinfo> [...] // Recast to a different pointer type SomeOtherObject* other_pt=dynamic_cast<SomeOtherObject*>(some_pt); #ifdef PARANOID if (other_pt==0) { std::ostringstream error_stream; error_stream << "Failed to cast some_pt to SomeOtherObject* " << std::endl; error_stream << "The pointer some_pt points to an object of type: " << << typeid(some_pt).name() << std::endl; throw OomphLibError(error_stream.str(), "main()", OOMPH_EXCEPTION_LOCATION); } #endif [...]
OomphLibError object after catching the error -- this allows the provision of more explicit (and hopefully more meaningful) error messages.
Many access functions that provide indexed access to a private container, do, in fact, access a private STL vector. Explicit range checking for these (frequent!) cases can be avoided by changing the container to oomph-lib's Vector class. The Vector class performs automatic range checking, if oomph-lib's generic library is compiled with the RANGE_CHECKING flag set (i.e. if -DRANGE_CHECKING is specified as a compilation flag for the C++ compiler). For access functions that do not use the Vector class you should implement your own range checks using the RANGE_CHECKING compiler flag.
oomph-lib, but the direct of use -1 as a (bad!) "magic number" is generally avoided. Instead we refer to the static data member of the Data class static long Data::Is_pinned
static int Data::Is_unclassified
Problem::self_test() to check if any values have not been classified as pinned or free.
oomph-lib's existing "single-physics" elements are (and any newly designed ones should be) designed so that they can easily be used in multi-physics problems. We anticipate two types of multi-physics interactions:get_residuals(...) function of the two constituent elements and concatenates their residual vectors. When computating the Jacobian matrix, the "single physics" elements provide the diagonal blocks for the Jacobian matrix of the multi-physics element, while the off-diagonal interaction blocks must be computed separately. The details of the implementation vary from problem to problem. However, any single-physics element must satisfy the following requirements if it is to be used as a base class for derived multi-physics elements:
To illustrate this point, consider what would happen if we used the TwoNodePoissonElement::get_jacobian(...) function, discussed in the section The function FiniteElement::get_jacobian(residuals,jacobian), in a derived multi-physics element, which combines the TwoNodePoissonElement with another element, TwoNodeSomeOtherEquationElement, say. Assume that we implement the function get_jacobian(...) of the combined element so that it first calls the function TwoNodeSomeOtherEquationElement::get_jacobian(...) to determine the first diagonal block in the combined Jacobian matrix. When we call TwoNodePoissonElement::get_jacobian(...) to compute the entries in the second diagonal block, the initialisation loop
//Find the number of degrees of freedom (unpinned values) in the element unsigned n_dof = ndof(); //Initialise all entries of the Jacobian matrix to zero for(unsigned i=0;i<n_dof;i++) { for(unsigned j=0;j<n_dof;j++) {jacobian(i,j) = 0.0;} }
would initialise the entire Jacobian matrix, thus wiping out the entries that were already computed by TwoNodeSomeOtherEquationElement::get_jacobian(...).
The strategy used in oomph-lib to permit the easy combination of elements is to use the two protected member functions of the GeneralisedElement class:
virtual void GeneralisedElement::fill_in_contribution_to_residuals(Vector<double>&residuals);
and
virtual void GeneralisedElement::fill_in_contribution_to_jacobian(Vector<double> &residuals, DenseMatrix<double> &jacobian);
These functions DO NOT initialise the entries of the residuals vector or the Jacobian matrix. Instead, the functions merely add the appropriate contributions to the vector and the matrix entries. The default version of the get_residuals() and get_jacobian() functions, defined in GeneralisedElement, are simple wrappers that initialise the residuals and Jacobian to zero and then call the appropriate fill_in_contribution... function.
virtual void GeneralisedElement::get_residuals(Vector<double> &residuals) { //Zero the residuals vector residuals.initialise(0.0); //Add the elemental contribution to the residuals vector fill_in_contribution_to_residuals(residuals); } virtual void GeneralisedElement::get_jacobian(Vector<double> &residuals, DenseMatrix<double> &jacobian) { //Zero the residuals vector residuals.initialise(0.0); //Zero the Jacobian matrix jacobian.initialise(0.0); //Add the elemental contribution to the residuals vector and Jacobian fill_in_contribution_to_jacobian(residuals,jacobian); }
The get_residuals function, for example, can thus be overloaded in a multi-physics element, as follows:
/// Multi-physics element, created by multiple inheritance class SomeMultiPhysicsElement : public virtual TwoNodePoissonElement, public virtual TwoNodeSomeOtherEquationElement { [...] /// Residual vector of the combined element is made from the entries /// of the constituent single-physics elements virtual void get_residuals(Vector<double> &residuals) { //Zero the residuals vector residuals.initialise(0.0); //Add the first elemental contribution to the residuals vector TwoNodePoissonElement::fill_in_contribution_to_residuals(residuals); //Add the second elemental contribution to the residuals vector TwoNodeSomeOtherEquationElement::fill_in_contribution_to_residuals(residuals); } [...] };
It is, therefore, recommended that authors of "single-physics" elements, overload fill_in_contribution_to_residuals(...) and fill_in_contribution_to_jacobian(...), rather than get_residuals() and get_jacobian(...), respectively. A further advantage of the implementation is that the author need not worry about initialisation of the residuals vector or the Jacobian matrix when using the "fill_in_" rather than the "get_" functions.
Furthermore, to avoid clashes of function names that may occur when two single-physics elements are combined, member functions that can be expected to have counterparts in the context of other equations should be given suitable modifiers. For instance,
AdvectionDiffusionEquation::source_fct_adv_diff(...)
AdvectionDiffusionEquation::source_fct(...)
oomph-lib have been given suitable modifiers to avoid clashes of this type. Therefore, you should be able to combine any element with any other element in the library. If you find a counter-example let us know, and we will rectify this in the next release. In the meantime exploit the fact the oomph-lib is an open source library: You can change anything you want!TwoNodePoissonElement, discussed above, we provided an access function u(n) that returns the (single) nodal value, stored at the element's n -th Node. The function was implemented as a simple wrapper that followed the pointer to the element's n -th Node and returned the zero-th nodal value stored at that Node: double TwoNodePoissonElement::u(const unsigned &n) { return node_pt(n)->value(0); }
In a single-physics context, this implementation is perfectly acceptable since we know a priori that in a scalar problem each Node only stores a single value.
The same logic suggests that a TwoNodeAdvectionDiffusionElement would have a member function
double TwoNodeAdvectionDiffusionElement::u(const unsigned &n) { return node_pt(n)->value(0); }
However, merging these two elements via multiple inheritance creates two problems. First we have clash of names which could have been avoided by following rule 2 above and calling the two functions TwoNodePoissonElement::u_poisson(...) and TwoNodeAdvectionDiffusionElement::u_adv_diff(...), say. More serious is that both elements regard the zero-th nodal value as "their own". This can (and should!) be avoided by the following re-implementation:
double TwoNodePoissonElement::u(const unsigned &n) { return node_pt(n)->value(u_index_poisson()); }
where the virtual function u_index_poisson() provides the default index at which the Poisson nodal values are stored in a single-physics context:
virtual unsigned TwoNodePoissonElement::u_index_poisson() { // By default (i.e. in a single-physics context) the Poisson // value is stored at the zero-th nodal value: return 0; }
The advection-diffusion element can be modified in the same way:
double TwoNodeAdvectionDiffusionElement::u(const unsigned &n) { return node_pt(n)->value(u_index_adv_diff()); }
with
virtual unsigned TwoNodeAdvectionDiffusionElement::u_index_adv_diff() { // By default (i.e. in a single-physics context) the advection-diffusion // value is stored at the zero-th nodal value: return 0; }
In a combined multi-physics problem, we can now merge the two elements by multiple inheritance,
class TwoNodeAdvDiffAndPoissonElement : public virtual TwoNodePoissonElement, public virtual TwoNodeAdvectionDiffusionElement
Name clashes are already avoided, so we only have to overwrite the two index functions to indicate which (scalar) value is stored as which nodal value:
unsigned TwoNodeAdvDiffAndPoissonElement::u_index_adv_diff() { // In the combined multi-physics element we continue to store the // advection-diffusion value at the zero-th nodal value // [as a result it is not actually necessary to re-implement this // function!] return 0; }
and
unsigned TwoNodeAdvDiffAndPoissonElement::u_index_poisson() { // In the combined multi-physics element we store the Poisson // value at the first nodal value: return 1; }
Specific examples that illustrate the creation of (non-trivial) multi-physics elements by multiple inheritance are provided in the list of example codes.
TimeStepper) is not the same as the Eulerian time-derivative
that "usually" occurs in the governing equation. The former represents the rate of change when following the moving node; the latter is the rate of change evaluated at a fixed Eulerian position. The two time-derivatives are related to each other via the ALE relation
where
is the spatial dimension of the problem and
is the velocity of the node. Any Eulerian time-derivatives in the governing equation should be implemented in the above form to ensure that the element remains functional in moving mesh problems. See the example describing the solution of the unsteady heat equation in a moving domain for further details.
1.4.7