Linear Elasticity

by Aashutosh Kulakarni Prachet and Jeetika Patangia, Brown University

45 minutes basic


  Lesson Objectives

  Understand the finite element discretization of a simple elastic deformation problem in MFEM.
  Learn how to handle vectorial solution variables in MFEM.
  Learn how to use Attributes to implement discontinuous material coefficients and multiple boundary conditions.

  Note

Complete the Example 1 tutorial before starting this lesson.

Linear elasticity is a vectorial partial differential equation (PDE) that models how elastic bodies deform and develop stress under applied forces. It is widely used to analyze the stress-strain behavior of structural and mechanical systems. Beginning from the Conservation of Linear Momentum, we have

$$ \nabla \cdot {\sigma({u})} + f = \rho {\ddot{u}}, $$

where ${u} \in \mathbb{R}^n$ ($n = 2$ or $3$) is the vector-valued displacement field, ${\sigma}$ is the stress tensor, $f$ is the body force per unit volume and $\rho$ is the material density. Next, employing the Hooke's Law for an isotropic material, the stress tensor ${\sigma}$ corresponding to the displacement field ${u}$ is given as

$$ {\sigma({u})} = \lambda (\nabla \cdot {u}) {I} + \mu (\nabla {u} + \nabla {u}^T), $$

where $\lambda$ and $\mu$ are Lamé's constants which depend on material properties.

To solve this system of equations numerically, we convert it into a discrete problem with a finite number of unknowns. The Finite Element Method (FEM) proceeds by constructing a linear combination of basis functions to represent the solution $u$ over the domain $\Omega\subset\mathbb{R}^n$.

In particular, instead of calculating the exact analytic solution, we approximate each component $u_i$ as

$$ u_i \approx u_{i,h} := \sum_{j=1}^{m} c_{ij} \varphi_{j}, $$

where $u_{i,h}$ is the $i$-th component of the vector-valued finite element approximation, $u_h$, with unknown coefficients $c_{ij}$, and $\varphi_{j}$ are scalar-valued basis functions. The FEM basis functions, $\varphi_{j}$, which do not depend on $i$, are typically piecewise-polynomial functions that are only non-zero on small portions of the domain.

For this example problem, we compute a stationary, homogeneous solution to our PDE by setting the inertial term $\rho {\ddot{u}} = 0$ and neglecting the body force, $f = 0$.

To arrive an equation for the unknown coefficients $c_{ij}$, we consider the weak (or variational) form of the PDE. This is obtained by substituting (3) into (1), followed multiplying with a test function ${v}_h$ and integrating over the domain $\Omega$,

$$ \int_{\Omega} {v}_h \cdot \left( \nabla \cdot {\sigma}({u}_h) \right) \mathrm{d}{x} = 0. $$

After integrating by parts using the divergence theorem, we find

$$ \int_{\Omega} \nabla v_h : {\sigma}(u_h)\, \mathrm{d}{x} = \int_{\partial \Omega} v_h \cdot ({\sigma}(u_h) \cdot {n})\, \mathrm{d}S. $$

The boundary of the domain, $\partial\Omega$, is divided into two parts, the displacement (Dirichlet) boundary $\Gamma_D$ and the traction (Neumann) boundary $\Gamma_T$. In this example problem, the left-hand side of the boundary has a homogeneous ($u = 0$) displacement boundary condition, whereas the rest of the boundary has a traction boundary condition, ${\sigma} \cdot {n} = t$. In this problem, the traction force $t$ is non-zero only on the right-hand side of the boundary. Now using our constitutive law (2), which gives the relation between ${\sigma}$ and the displacement field ${u}$, we obtain the weak formulation

$$ \int_{\Omega} \lambda (\nabla\cdot u_h)(\nabla\cdot v_h) + \mu (\nabla u_h + \nabla {u}^T_h) : \nabla v_h \, \mathrm{d}{x} = \int_{\Gamma_T} v_h \cdot t\, \mathrm{d}S . $$

Letting $\{e_k\}_{k=1}^n \subset \mathbb{R}^n$ denote the standard Cartesian unit vectors and setting $v_h = \varphi_j e_k$, for each index $j$ and $k$, we can leverage the basis expansion formula (4) to rewrite (6) as

$$ {A x} = {b}, $$

with the vector $x\in\mathbb{R}^{nm}$ holding the unknown coefficients $c_{ij}$. This is a $mn \times mn$ linear system that can be solved directly or iteratively. Note that we are free to choose the computational mesh and the basis functions $\varphi_i$ as we see fit.

  Note

The above is a basic introduction to finite elements in one of the simplest possible vectorial settings. To learn more, you can visit MFEM's Finite Element Method page.

  Annotated Example 2

The source file examples/ex2.cpp implements the above FEM for a beam-shaped body consisting of two adjacent material subdomains. In particular, it sets $t=-0.01$ on the right-hand side of the boundary and enforces the Dirichlet and Neumann boundary conditions over the other corresponding subsets of the boundary.

Below we highlight selected portions of the example code and connect them with the description in the previous section. You can follow along by browsing ex2.cpp in your VS Code browser window. In the settings of this tutorial, the visualization will automatically update in the GLVis browser window.

MFEM uses attributes and bdr_attributes to distinguish between subdomains and subsets of the domain boundary, respectively. The computational mesh is provided as input (option -m) that could be a 2D or 3D domain made up of triangular/quadrilateral/tetrahedral/hexahedral elements, etc. (It defaults to beam-tri.mesh in line 50.) The code in lines 73-84 loads the mesh from the given file, mesh_file and creates the corresponding MFEM pointer *mesh of class Mesh. It then checks that the mesh file uploaded has at least two different materials and the correct number of boundary attributes.

Mesh *mesh = new Mesh(mesh_file, 1, 1);
int dim = mesh->Dimension();

if (mesh->attributes.Max() < 2 || mesh->bdr_attributes.Max() < 2)
{
    cerr << "\nInput mesh should have at least two materials and "
        << "two boundary attributes! (See schematic in ex2.cpp)\n"
        << endl;
    return 3;
}

The following code (lines 93-104) refines the mesh uniformly to about 5,000 elements. You can easily modify the refinement by changing the definition of ref_levels.

int ref_levels =
    (int)floor(log(5000./mesh->GetNE())/log(2.)/dim);
for (int l = 0; l < ref_levels; l++)
{
    mesh->UniformRefinement();
}

In the next block of code, lines 106-124, we create the finite element space, i.e., specify the finite element basis functions $\varphi_j$ on the mesh. This involves the MFEM classes FiniteElementCollection, which specifies the space (including its order, provided as input via -o), and FiniteElementSpace, which connects the space and the mesh. The dimension of the vector-valued finite elements are specified in the last argument of FiniteElementSpace. If we are using NURBS meshes, we use the NURBS space associated with our mesh.

if (mesh->NURBSext)
{
    FiniteElementCollection *fec = NULL;
    FiniteElementSpace *fespace = mesh->GetNodes()->FESpace();
}
else
{
    FiniteElementCollection *fec = new H1_FECollection(order, dim);
    FiniteElementSpace *fespace = new FiniteElementSpace(mesh, fec, dim);
}
cout << "Number of finite element unknowns: " << fespace->GetTrueVSize() << endl << "Assembling: " << flush;

The printed number of finite element unknowns corresponds to the size of the linear system $mn$ from the previous section.

The finite element degrees of freedom that are on the domain boundary are then extracted in lines 126-133. The Dirichlet boundary conditions are then imposed only on the boundary with the first boundary attribute, while the rest are given to Neumann boundary conditions.

Array<int> ess_tdof_list, ess_bdr(mesh->bdr_attributes.Max());
ess_bdr = 0;
ess_bdr[0] = 1;
fespace->GetEssentialTrueDofs(ess_bdr, ess_tdof_list);

The method GetEssentialTrueDofs takes a marker array of Mesh boundary attributes and returns the FiniteElementSpace degrees of freedom that belong to the marked attributes (the non-zero entries of ess_bdr).

The right-hand side $b$ is constructed in lines 135-158. In MFEM terminology, integrals of the form in the right hand side of (6) are implemented using the AddBoundaryIntegrator function in the class LinearForm. We now define the traction force over the entire boundary using a VectorArrayCoefficient object. To this end, a pull_force vector is created, which specifies the vertical component of the pointwise traction force for each boundary attribute. Note that pull_force is only non-zero for the final boundary attribute, which corresponds to the right-hand beam boundary. This vector is used to create a piecewise constant coefficient corresponding to the vertical component of the traction force density.

VectorArrayCoefficient f(dim);
for (int i = 0; i < dim-1; i++)
{
    f.Set(i, new ConstantCoefficient(0.0));
}
{
    Vector pull_force(mesh->bdr_attributes.Max());
    pull_force = 0.0;
    pull_force(1) = -1.0e-2;
    f.Set(dim-1, new PWConstCoefficient(pull_force));
}

LinearForm *b = new LinearForm(fespace);
b->AddBoundaryIntegrator(new VectorBoundaryLFIntegrator(f));
cout << "r.h.s. ... " << flush;
b->Assemble();

The finite element approximation $u_h$ resides in the MFEM GridFunction object x associated to the vector-valued FiniteElementSpace fespace. Note that a GridFunction object can be viewed both as the function $u_h$ in (2) as well as the vector of degrees of freedom $x$ in (8). See lines 160-164.

GridFunction x(fespace);
x = 0.0;

We need to initialize x with the boundary values we want to impose as Dirichlet boundary conditions. Since we wish to assign zero-displacement boundary conditions on the only Dirichlet part of the boundary, we simple set x=0.0 in the whole domain.

We now move on to assembling the bilinear form. We start by defining the Lamé parameters using the PWConstCoefficient class to specify different Lamé parameters in the two different segments of the beam. The left-hand side of (6) is represented as a BilinearForm object, with ElasticityIntegrator corresponding to the specific bilinear form. See lines 166-187.

PWConstCoefficient lambda_func(lambda);
PWConstCoefficient mu_func(mu);
BilinearForm *a = new BilinearForm(fespace);
a->AddDomainIntegrator(new ElasticityIntegrator(lambda_func,mu_func));
cout << "matrix ... " << flush;
if (static_cond) { a->EnableStaticCondensation(); }
a->Assemble();

MFEM supports different assembly levels for $A$ and many different integrators. Here we use the static condensation transformation to reduce the size of our original system to the remaining interfacial unknowns and assemble it.

The linear system (7) is formed and solved in lines 189-207:

SparseMatrix A;
Vector B, X;

a->FormLinearSystem(ess_tdof_list, x, *b, A, X, B);
cout << "Size of linear system: " << A.Height() << endl;

GSSmoother M(A);
PCG(A, M, B, X, 1, 500, 1e-8, 0.0);

The method FormLinearSystem takes the BilinearForm, LinearForm, GridFunction, and boundary conditions (i.e., a, b, x, and ess_tdof_list); applies any necessary transformations such as eliminating boundary conditions (specified by the boundary values of x, applying conforming constraints for non-conforming AMR, static condensation, etc.); and produces the corresponding matrix A, right-hand side vector B, and unknown vector X.

In the above example, we then solve A X = B with conjugate gradient iterations, using a simple Gauss-Seidel preconditioner. We set the maximum number of iterations to 500 and the relative error tolerance to 1e-8.

Solving the linear system is one of the main computational bottlenecks in the FEM. It can take many preconditioned conjugate gradient (PCG) iterations depending on the problem size, the difficulty of the problem, and the choice of the preconditioner.

Once the linear system is solved, we recover the solution as a finite element grid function, and then visualize and save the final results to disk (files displaced.mesh and sol.gf). See lines 210-248.

a->RecoverFEMSolution(X, *b, x);

ofstream mesh_ofs("displaced.mesh");
mesh->Print(mesh_ofs);
ofstream sol_ofs("sol.gf");
x.Save(sol_ofs);

socketstream sol_sock("localhost", 19916);
sol_sock << "solution\n" << mesh << x << flush;

  Parallel Example 2p

Like most MFEM examples, Example 2 has also a parallel version in the source file examples/ex2p.cpp, which illustrates the ease of transition between sequential and MPI-parallel code. The parallel version supports all options of the serial example, and can be executed on varying numbers of MPI ranks, e.g., with mpirun -np. Besides MPI, in parallel we also depend on METIS for mesh partitioning and hypre for solvers.

The differences between the two versions are small, and you can compare them for yourself by opening both files in your VS Code window.

The main additions in ex2p.cpp compared to ex2.cpp are:

Initializing MPI and hypre

Mpi::Init();
Hypre::Init();

Splitting the serial mesh in parallel with additional parallel refinement (Lines 141-149)

ParMesh *pmesh = new ParMesh(MPI_COMM_WORLD, *mesh);
int par_ref_levels = 2;
for (int l = 0; l < par_ref_levels; l++)
{
   pmesh.UniformRefinement();
}

Using the Par-prefixed versions of the classes

ParFiniteElementSpace *fespace;
ParLinearForm *b = new ParLinearForm(fespace)
ParGridFunction x(fespace);
ParBilinearForm *a = new ParBilinearForm(fespace);

Parallel PCG with hypre's algebraic multigrid BoomerAMG preconditioner

HypreBoomerAMG *amg = new HypreBoomerAMG(A);
HyprePCG *pcg = new HyprePCG(A);
pcg->SetTol(1e-8);
pcg->SetMaxIter(500);
pcg->SetPrintLevel(2);
pcg->SetPreconditioner(*amg);
pcg->Mult(B, X);

  Note

Unlike in the serial version, we expect the number of PCG iterations to be independent, or grow modestly, with respect to the mesh size, coefficient jumps, and number of MPI ranks when using the BoomerAMG preconditioner. Note, however, that algebraic multigrid has a non-trivial setup phase, which can be comparable in terms of time with the PCG solve phase. For more details, see the   Solvers and Scalability page.

  Serial and parallel runs

Both ex2 and ex2p come pre-built in the tutorial environment. You can see a number of sample runs at the beginning of their corresponding source files when you open them in VS Code. To get a feel for how these examples work, you can copy and paste some of these runs from the source to the terminal in VS Code.

  Try this!

Specify a couple different meshes with -m in the VS Code terminal to see how the image rendered by GLVis changes. Run

./ex2 -m ../data/beam-tet.mesh

_Beam Displacement_

  Warning

The current directory is not in the VS Code PATH so make sure to add ./ before the executable, e.g., ./ex2 -m ../data/beam-tet.mesh not ex2 -m ../data/beam-tet.mesh.

  Note

The GLVis visualization is local to your browser, so it may take a while to update after a sample run. Once the data arrives, interaction with the visualization window should be fast.

  Try this!

Now try out some sample parallel runs:

mpirun -np 16 ex2p
mpirun -np 16 ex2p -m ../data/beam-tet.mesh
mpirun -np 48 ex2p -m ../data/beam-tet.mesh

Back to the  Getting Started page.