deal.II version GIT relicensing-2287-g6548a49e0a 2024-12-20 18:30:00+00:00
\(\newcommand{\dealvcentcolon}{\mathrel{\mathop{:}}}\) \(\newcommand{\dealcoloneq}{\dealvcentcolon\mathrel{\mkern-1.2mu}=}\) \(\newcommand{\jump}[1]{\left[\!\left[ #1 \right]\!\right]}\) \(\newcommand{\average}[1]{\left\{\!\left\{ #1 \right\}\!\right\}}\)
Loading...
Searching...
No Matches
The step-7 tutorial program

This tutorial depends on step-6.

Table of contents
  1. Introduction
  2. The commented program
  1. Results
  2. The plain program

Introduction

In this program, we will mainly consider two aspects:

  1. Verification of correctness of the program and generation of convergence tables;
  2. Non-homogeneous Neumann boundary conditions for the Helmholtz equation.

Besides these topics, again a variety of improvements and tricks will be shown.

Verification of correctness

There has probably never been a non-trivial finite element program that worked right from the start. It is therefore necessary to find ways to verify whether a computed solution is correct or not. Usually, this is done by choosing the set-up of a simulation in such a way that we know the exact continuous solution and evaluate the difference between continuous and computed discrete solution. If this difference converges to zero with the right order of convergence, this is already a good indication of correctness, although there may be other sources of error persisting which have only a small contribution to the total error or are of higher order. In the context of finite element simulations, this technique of picking the solution by choosing appropriate right hand sides and boundary conditions is often called the Method of Manufactured Solution. (We will come back to how exactly we construct the solution in this method below, after discussing the equation we want to solve.)

In this example, we will not go into the theories of systematic software verification which is a complicated problem in general. Rather we will demonstrate the tools which deal.II can offer in this respect. This is basically centered around the functionality of a single function, VectorTools::integrate_difference(). This function computes the difference between a given continuous function and a finite element field in various norms on each cell. Of course, like with any other integral, we can only evaluate these norms using quadrature formulas; the choice of the right quadrature formula is therefore crucial to the accurate evaluation of the error. This holds in particular for the \(L_\infty\) norm, where we evaluate the maximal deviation of numerical and exact solution only at the quadrature points; one should then not try to use a quadrature rule whose evaluation occurs only at points where super-convergence might occur, such as the Gauss points of the lowest-order Gauss quadrature formula for which the integrals in the assembly of the matrix is correct (e.g., for linear elements, do not use the QGauss(2) quadrature formula). In fact, this is generally good advice also for the other norms: if your quadrature points are fortuitously chosen at locations where the error happens to be particularly small due to superconvergence, the computed error will look like it is much smaller than it really is and may even suggest a higher convergence order. Consequently, we will choose a different quadrature formula for the integration of these error norms than for the assembly of the linear system.

The function VectorTools::integrate_difference() evaluates the desired norm on each cell \(K\) of the triangulation and returns a vector which holds these values for each cell. From the local values, we can then obtain the global error. For example, if the vector \(\mathbf e\) with element \(e_K\) for all cells \(K\) contains the local \(L_2\) norms \(\|u-u_h\|_K\), then

\[ E = \| {\mathbf e} \| = \left( \sum_K e_K^2 \right)^{1/2} \]

is the global \(L_2\) error \(E=\|u-u_h\|_\Omega\).

In the program, we will show how to evaluate and use these quantities, and we will monitor their values under mesh refinement. Of course, we have to choose the problem at hand such that we can explicitly state the solution and its derivatives, but since we want to evaluate the correctness of the program, this is only reasonable. If we know that the program produces the correct solution for one (or, if one wants to be really sure: many) specifically chosen right hand sides, we can be rather confident that it will also compute the correct solution for problems where we don't know the exact values.

In addition to simply computing these quantities, we will show how to generate nicely formatted tables from the data generated by this program that automatically computes convergence rates etc. In addition, we will compare different strategies for mesh refinement.

Non-homogeneous Neumann boundary conditions

The second, totally unrelated, subject of this example program is the use of non-homogeneous boundary conditions. These are included into the variational form using boundary integrals which we have to evaluate numerically when assembling the right hand side vector.

Before we go into programming, let's have a brief look at the mathematical formulation. The equation that we want to solve here is the Helmholtz equation "with the nice sign":

\[ -\Delta u + \alpha u = f, \]

on the square \([-1,1]^2\) with \(\alpha=1\), augmented by Dirichlet boundary conditions

\[ u = g_1 \]

on some part \(\Gamma_1\) of the boundary \(\Gamma\), and Neumann conditions

\[ {\mathbf n}\cdot \nabla u = g_2 \]

on the rest \(\Gamma_2 = \Gamma \backslash \Gamma_1\). In our particular testcase, we will use \(\Gamma_1=\Gamma \cap\{\{x=1\} \cup \{y=1\}\}\). (We say that this equation has the "nice sign" because the operator \(-\Delta + \alpha I\) with the identity \(I\) and \(\alpha>0\) is a positive definite operator; the equation with the "bad sign" is \(-\Delta u - \alpha u\) and results from modeling time-harmonic processes. For the equation with the "bad sign", the operator \(-\Delta-\alpha I\) is not positive definite if \(\alpha>0\) is large, and this leads to all sorts of issues we need not discuss here. The operator may also not be invertible – i.e., the equation does not have a unique solution – if \(\alpha\) happens to be one of the eigenvalues of \(-\Delta\).)

Using the above definitions, we can state the weak formulation of the equation, which reads: find \(u\in H^1_g=\{v\in H^1: v|_{\Gamma_1}=g_1\}\) such that

\[ {(\nabla v, \nabla u)}_\Omega + {(v,u)}_\Omega = {(v,f)}_\Omega + {(v,g_2)}_{\Gamma_2} \]

for all test functions \(v\in H^1_0=\{v\in H^1: v|_{\Gamma_1}=0\}\). The boundary term \({(v,g_2)}_{\Gamma_2}\) has appeared by integration by parts and using \(\partial_n u=g_2\) on \(\Gamma_2\) and \(v=0\) on \(\Gamma_1\). The cell matrices and vectors which we use to build the global matrices and right hand side vectors in the discrete formulation therefore look like this:

\begin{eqnarray*} A_{ij}^K &=& \left(\nabla \varphi_i, \nabla \varphi_j\right)_K +\left(\varphi_i, \varphi_j\right)_K, \\ F_i^K &=& \left(\varphi_i, f\right)_K +\left(\varphi_i, g_2\right)_{\partial K\cap \Gamma_2}. \end{eqnarray*}

Since the generation of the domain integrals has been shown in previous examples several times, only the generation of the contour integral is of interest here. It basically works along the following lines: for domain integrals we have the FEValues class that provides values and gradients of the shape values, as well as Jacobian determinants and other information and specified quadrature points in the cell; likewise, there is a class FEFaceValues that performs these tasks for integrations on faces of cells. One provides it with a quadrature formula for a manifold with dimension one less than the dimension of the domain is, and the cell and the number of its face on which we want to perform the integration. The class will then compute the values, gradients, normal vectors, weights, etc. at the quadrature points on this face, which we can then use in the same way as for the domain integrals. The details of how this is done are shown in the following program.

The method of manufactured solutions

Because we want to verify the convergence of our numerical solution \(u_h\), we want a setup so that we know the exact solution \(u\). This is where the Method of Manufactured Solutions comes in: Let us choose a function

\[ \bar u(\mathbf x) = \sum_{i=1}^3 \exp\left(-\frac{|\mathbf x-\mathbf x_i|^2}{\sigma^2}\right) \]

where the centers \(x_i\) of the exponentials are \(\mathbf x_1=(-\frac 12,\frac 12)\), \(\mathbf x_2=(-\frac 12,-\frac 12)\), and \(\mathbf x_3=(\frac 12,-\frac 12)\), and the half width is set to \(\sigma=\frac {1}{8}\). The method of manufactured solution then says: choose

\begin{align*} f &= -\Delta \bar u + \bar u, \\ g_1 &= \bar u|_{\Gamma_1}, \\ g_2 &= {\mathbf n}\cdot \nabla\bar u|_{\Gamma_2}. \end{align*}

With this particular choice for \(f,g_1,g_2\), the solution of the original problem must necessarily be \(u=\bar u\). In other words, by choosing the right hand sides of the equation and the boundary conditions in a particular way, we have manufactured ourselves a problem to which we know the solution – a very useful case given that in all but the very simplest cases, PDEs do not have solutions we can just write down. This then allows us to compute the error of our numerical solution. In the code below, we represent \(\bar u\) by the Solution class, and other classes will be used to denote \(\bar u|_{\Gamma_1}=g_1\) and \({\mathbf n}\cdot \nabla\bar u|_{\Gamma_2}=g_2\).

Note
In principle, you can choose whatever you want for the function \(\bar u\) above – here we have simply chosen a sum of three exponentials. In practice, there are two considerations you want to take into account: (i) The function must be simple enough so that you can compute derivatives of the function with not too much effort, for example in order to determine what \(f = -\Delta \bar u + \bar u\) is. Since the derivative of an exponential is relatively straightforward to compute, the choice above satisfies this requirement, whereas a function of the kind \(\bar u(\mathbf x) = \text{atan}\left(\|\mathbf x\|^{\|\mathbf x\|}\right)\) would have presented greater difficulties. (ii) You don't want \(\bar u\) be a polynomial of low degree. That is because if you choose the polynomial degree of your finite element sufficiently high, you can exactly represent this \(\bar u\) with the numerical solution \(u_h\), making the error zero regardless of how coarse or fine the mesh is. Verifying that this is so is a useful step, but it will not allow you to verify the correct order of convergence of \(\|u-u_h\|\) as a function of the mesh size \(h\) in the general case of arbitrary \(f\). (iii) The typical finite element error estimates assume sufficiently smooth solutions, i.e., sufficiently smooth domains, right-hand sides \(f\) and boundary conditions. As a consequence, you should choose a smooth solution \(\bar u\) – for example, it shouldn't have kinks. (iv) You want a solution whose variations can be resolved on the meshes you consider to test convergence. For example, if you were to choose \(\bar u(\mathbf x)=\sin(1000 x_1)\sin(1000 x_2)\), you shouldn't be surprised if you don't observe that the error decreases at the expected rate until your mesh is fine enough to actually resolve the high-frequency oscillations with substantially more than 1,000 mesh cells in each coordinate direction.

The solution \(\bar u\) we choose here satisfies all of these requirements: (i) It is relatively straightforward to differentiate; (ii) it is not a polynomial; (iii) it is smooth; and (iv) it has a length scale of \(\sigma=\frac {1}{8}\) which, on the domain \([-1,1]^d\) is relatively straightforward to resolve with 16 or more cells in each coordinate direction.

A note on good programming practice

Besides the mathematical topics outlined above, we also want to use this program to illustrate one aspect of good programming practice, namely the use of namespaces. In programming the deal.II library, we have take great care not to use names for classes and global functions that are overly generic, say f(), sz(), rhs() etc. Furthermore, we have put everything into namespace dealii. But when one writes application programs that aren't meant for others to use, one doesn't always pay this much attention. If you follow the programming style of step-1 through step-6, these functions then end up in the global namespace where, unfortunately, a lot of other stuff also lives (basically everything the C language provides, along with everything you get from the operating system through header files). To make things a bit worse, the designers of the C language were also not always careful in avoiding generic names; for example, the symbols j1, jn are defined in C header files (they denote Bessel functions).

To avoid the problems that result if names of different functions or variables collide (often with confusing error messages), it is good practice to put everything you do into a namespace. Following this style, we will open a namespace Step7 at the top of the program, import the deal.II namespace into it, put everything that's specific to this program (with the exception of main(), which must be in the global namespace) into it, and only close it at the bottom of the file. In other words, the structure of the program is of the kind

#includes ...
namespace Step7
{
using namespace dealii;
...everything to do with the program...
}
int main ()
{
...do whatever main() does...
}

We will follow this scheme throughout the remainder of the deal.II tutorial.

The commented program

Include files

These first include files have all been treated in previous examples, so we won't explain what is in them again.

  #include <deal.II/base/quadrature_lib.h>
  #include <deal.II/base/function.h>
  #include <deal.II/lac/vector.h>
  #include <deal.II/lac/full_matrix.h>
  #include <deal.II/lac/sparse_matrix.h>
  #include <deal.II/lac/dynamic_sparsity_pattern.h>
  #include <deal.II/lac/solver_cg.h>
  #include <deal.II/lac/precondition.h>
  #include <deal.II/lac/affine_constraints.h>
  #include <deal.II/grid/tria.h>
  #include <deal.II/grid/grid_generator.h>
  #include <deal.II/grid/grid_refinement.h>
  #include <deal.II/dofs/dof_handler.h>
  #include <deal.II/dofs/dof_tools.h>
  #include <deal.II/fe/fe_q.h>
  #include <deal.II/numerics/matrix_tools.h>
  #include <deal.II/numerics/error_estimator.h>
  #include <deal.II/numerics/data_out.h>
 

In this example, we will not use the numeration scheme which is used per default by the DoFHandler class, but will renumber them using the Cuthill-McKee algorithm. As has already been explained in step-2, the necessary functions are declared in the following file :

  #include <deal.II/dofs/dof_renumbering.h>

Then we will show a little trick how we can make sure that objects are not deleted while they are still in use. For this purpose, deal.II has the ObserverPointer helper class, which is declared in this file :

  #include <deal.II/base/observer_pointer.h>

Next, we will want to use the function VectorTools::integrate_difference() mentioned in the introduction, and we are going to use a ConvergenceTable that collects all important data during a run and prints it at the end as a table. These comes from the following two files:

  #include <deal.II/numerics/vector_tools.h>
  #include <deal.II/base/convergence_table.h>

And finally, we need to use the FEFaceValues class, which is declared in the same file as the FEValues class:

  #include <deal.II/fe/fe_values.h>
 
  #include <array>
  #include <fstream>
  #include <iostream>
 

The last step before we go on with the actual implementation is to open a namespace Step7 into which we will put everything, as discussed at the end of the introduction, and to import the members of namespace dealii into it:

  namespace Step7
  {
  using namespace dealii;
 

Equation data

Before implementing the classes that actually solve something, we first declare and define some function classes that represent right hand side and solution classes. Since we want to compare the numerically obtained solution to the exact continuous one, we need a function object that represents the continuous solution. On the other hand, we need the right hand side function, and that one of course shares some characteristics with the solution. In order to reduce dependencies which arise if we have to change something in both classes at the same time, we move the common characteristics of both functions into a base class.

The common characteristics for solution (as explained in the introduction, we choose a sum of three exponentials) and right hand side, are these: the number of exponentials, their centers, and their half width. We declare them in the following class. Since the number of exponentials is a compile-time constant we use a fixed-length std::array to store the center points:

  template <int dim>
  class SolutionBase
  {
  protected:
  static const std::array<Point<dim>, 3> source_centers;
  static const double width;
  };
 
 

The variables which denote the centers and the width of the exponentials have just been declared, now we still need to assign values to them. Here, we can show another small piece of template sorcery, namely how we can assign different values to these variables depending on the dimension. We will only use the 2d case in the program, but we show the 1d case for exposition of a useful technique.

First we assign values to the centers for the 1d case, where we place the centers equidistantly at -1/3, 0, and 1/3. The template <> header for this definition indicates an explicit specialization. This means, that the variable belongs to a template, but that instead of providing the compiler with a template from which it can specialize a concrete variable by substituting dim with some concrete value, we provide a specialization ourselves, in this case for dim=1. If the compiler then sees a reference to this variable in a place where the template argument equals one, it knows that it doesn't have to generate the variable from a template by substituting dim, but can immediately use the following definition:

  template <>
  const std::array<Point<1>, 3> SolutionBase<1>::source_centers = {
  {Point<1>(-1.0 / 3.0), Point<1>(0.0), Point<1>(+1.0 / 3.0)}};
 
Definition point.h:111

Likewise, we can provide an explicit specialization for dim=2. We place the centers for the 2d case as follows:

  template <>
  const std::array<Point<2>, 3> SolutionBase<2>::source_centers = {
  {Point<2>(-0.5, +0.5), Point<2>(-0.5, -0.5), Point<2>(+0.5, -0.5)}};
 

There remains to assign a value to the half-width of the exponentials. We would like to use the same value for all dimensions. In this case, we simply provide the compiler with a template from which it can generate a concrete instantiation by substituting dim with a concrete value:

  template <int dim>
  const double SolutionBase<dim>::width = 1. / 8.;
 
 
 

After declaring and defining the characteristics of solution and right hand side, we can declare the classes representing these two. They both represent continuous functions, so they are derived from the Function<dim> base class, and they also inherit the characteristics defined in the SolutionBase class.

The actual classes are declared in the following. Note that in order to compute the error of the numerical solution against the continuous one in the L2 and H1 (semi-)norms, we have to provide value and gradient of the exact solution. This is more than we have done in previous examples, where all we provided was the value at one or a list of points. Fortunately, the Function class also has virtual functions for the gradient, so we can simply overload the respective virtual member functions in the Function base class. Note that the gradient of a function in dim space dimensions is a vector of size dim, i.e. a tensor of rank 1 and dimension dim. As for so many other things, the library provides a suitable class for this. One new thing about this class is that it explicitly uses the Tensor objects, which previously appeared as intermediate terms in step-3 and step-4. A tensor is a generalization of scalars (rank zero tensors), vectors (rank one tensors), and matrices (rank two tensors), as well as higher dimensional objects. The Tensor class requires two template arguments: the tensor rank and tensor dimension. For example, here we use tensors of rank one (vectors) with dimension dim (so they have dim entries.) While this is a bit less flexible than using Vector, the compiler can generate faster code when the length of the vector is known at compile time. Additionally, specifying a Tensor of rank one and dimension dim guarantees that the tensor will have the right shape (since it is built into the type of the object itself), so the compiler can catch most size-related mistakes for us.

  template <int dim>
  class Solution : public Function<dim>, protected SolutionBase<dim>
  {
  public:
  virtual double value(const Point<dim> &p,
  const unsigned int component = 0) const override;
 
  virtual Tensor<1, dim>
  gradient(const Point<dim> &p,
  const unsigned int component = 0) const override;
  };
 
 
virtual Tensor< 1, dim, RangeNumberType > gradient(const Point< dim > &p, const unsigned int component=0) const
virtual RangeNumberType value(const Point< dim > &p, const unsigned int component=0) const

The actual definition of the values and gradients of the exact solution class is according to their mathematical definition and does not need much explanation.

The only thing that is worth mentioning is that if we access elements of a base class that is template dependent (in this case the elements of SolutionBase<dim>), then the C++ language forces us to write this->source_centers, and similarly for other members of the base class. C++ does not require the this-> qualification if the base class is not template dependent. The reason why this is necessary is complicated; C++ books will explain under the phrase two-stage (name) lookup, and there is also a lengthy description in the deal.II FAQs.

  template <int dim>
  double Solution<dim>::value(const Point<dim> &p, const unsigned int) const
  {
  double return_value = 0;
  for (const auto &center : this->source_centers)
  {
  const Tensor<1, dim> x_minus_xi = p - center;
  return_value +=
  std::exp(-x_minus_xi.norm_square() / (this->width * this->width));
  }
 
  return return_value;
  }
 
 
::VectorizedArray< Number, width > exp(const ::VectorizedArray< Number, width > &)

Likewise, this is the computation of the gradient of the solution. In order to accumulate the gradient from the contributions of the exponentials, we allocate an object return_value that denotes the mathematical quantity of a tensor of rank 1 and dimension dim. Its default constructor sets it to the vector containing only zeroes, so we need not explicitly care for its initialization.

Note that we could as well have taken the type of the object to be Point<dim> instead of Tensor<1,dim>. Tensors of rank 1 and points are almost exchangeable, and have only very slightly different mathematical meanings. In fact, the Point<dim> class is derived from the Tensor<1,dim> class, which makes up for their mutual exchange ability. Their main difference is in what they logically mean: points are points in space, such as the location at which we want to evaluate a function (see the type of the first argument of this function for example). On the other hand, tensors of rank 1 share the same transformation properties, for example that they need to be rotated in a certain way when we change the coordinate system; however, they do not share the same connotation that points have and are only objects in a more abstract space than the one spanned by the coordinate directions. (In fact, gradients live in ‘reciprocal’ space, since the dimension of their components is not that of a length, but of one over length).

  template <int dim>
  Tensor<1, dim> Solution<dim>::gradient(const Point<dim> &p,
  const unsigned int) const
  {
  Tensor<1, dim> return_value;
 
  for (const auto &center : this->source_centers)
  {
  const Tensor<1, dim> x_minus_xi = p - center;
 

For the gradient, note that its direction is along (x-x_i), so we add up multiples of this distance vector, where the factor is given by the exponentials.

  return_value +=
  (-2. / (this->width * this->width) *
  std::exp(-x_minus_xi.norm_square() / (this->width * this->width)) *
  x_minus_xi);
  }
 
  return return_value;
  }
 
 
 

Besides the function that represents the exact solution, we also need a function which we can use as right hand side when assembling the linear system of discretized equations. This is accomplished using the following class and the following definition of its function. Note that here we only need the value of the function, not its gradients or higher derivatives.

  template <int dim>
  class RightHandSide : public Function<dim>, protected SolutionBase<dim>
  {
  public:
  virtual double value(const Point<dim> &p,
  const unsigned int component = 0) const override;
  };
 
 

The value of the right hand side is given by the negative Laplacian of the solution plus the solution itself, since we wanted to solve Helmholtz's equation:

  template <int dim>
  double RightHandSide<dim>::value(const Point<dim> &p,
  const unsigned int) const
  {
  double return_value = 0;
  for (const auto &center : this->source_centers)
  {
  const Tensor<1, dim> x_minus_xi = p - center;
 

The first contribution is the Laplacian:

  return_value +=
  ((2. * dim -
  4. * x_minus_xi.norm_square() / (this->width * this->width)) /
  (this->width * this->width) *
  std::exp(-x_minus_xi.norm_square() / (this->width * this->width)));

And the second is the solution itself:

  return_value +=
  std::exp(-x_minus_xi.norm_square() / (this->width * this->width));
  }
 
  return return_value;
  }
 
 

The Helmholtz solver class

Then we need the class that does all the work. Except for its name, its interface is mostly the same as in previous examples.

One of the differences is that we will use this class in several modes: for different finite elements, as well as for adaptive and global refinement. The decision whether global or adaptive refinement shall be used is communicated to the constructor of this class through an enumeration type declared at the top of the class. The constructor then takes a finite element object and the refinement mode as arguments.

The rest of the member functions are as before except for the process_solution function: After the solution has been computed, we perform some analysis on it, such as computing the error in various norms. To enable some output, it requires the number of the refinement cycle, and consequently gets it as an argument.

  template <int dim>
  class HelmholtzProblem
  {
  public:
  enum RefinementMode
  {
  global_refinement,
  adaptive_refinement
  };
 
  HelmholtzProblem(const FiniteElement<dim> &fe,
  const RefinementMode refinement_mode);
 
  void run();
 
  private:
  void setup_system();
  void assemble_system();
  void solve();
  void refine_grid();
  void process_solution(const unsigned int cycle);
 

Now for the data elements of this class. Among the variables that we have already used in previous examples, only the finite element object differs: The finite elements which the objects of this class operate on are passed to the constructor of this class. It has to store a pointer to the finite element for the member functions to use. Now, for the present class there is no big deal in that, but since we want to show techniques rather than solutions in these programs, we will here point out a problem that often occurs – and of course the right solution as well.

Consider the following situation that occurs in all the example programs: we have a triangulation object, and we have a finite element object, and we also have an object of type DoFHandler that uses both of the first two. These three objects all have a lifetime that is rather long compared to most other objects: they are basically set at the beginning of the program or an outer loop, and they are destroyed at the very end. The question is: can we guarantee that the two objects which the DoFHandler uses, live at least as long as they are in use? This means that the DoFHandler must have some kind of knowledge on the destruction of the other objects.

We will show here how the library managed to find out that there are still active references to an object and the object is still alive from the point of view of a using object. Basically, the method is along the following line: all objects that are subject to such potentially dangerous pointers are derived from a class called EnableObserverPointer. For example, the Triangulation, DoFHandler, and a base class of the FiniteElement class are derived from EnableObserverPointer. This latter class does not offer much functionality, but it has a built-in counter which we can subscribe to, thus the name of the class. Whenever we initialize a pointer to that object, we can increase its use counter, and when we move away our pointer or do not need it any more, we decrease the counter again. This way, we can always check how many objects still use that object. Additionally, the class requires to know about a pointer that it can use to tell the subscribing object about its invalidation.

If an object of a class that is derived from the EnableObserverPointer class is destroyed, it also has to call the destructor of the EnableObserverPointer class. In this destructor, we tell all the subscribing objects about the invalidation of the object using the stored pointers. The same happens when the object appears on the right hand side of a move expression, i.e., it will no longer contain valid content after the operation. The subscribing class is expected to check the value stored in its corresponding pointer before trying to access the object subscribed to.

This is exactly what the ObserverPointer class is doing. It basically acts just like a pointer, i.e. it can be dereferenced, can be assigned to and from other pointers, and so on. On top of that it uses the mechanism described above to find out if the pointer this class is representing is dangling when we try to dereference it. In that case an exception is thrown.

In the present example program, we want to protect the finite element object from the situation that for some reason the finite element pointed to is destroyed while still in use. We therefore use a ObserverPointer to the finite element object; since the finite element object is actually never changed in our computations, we pass a const FiniteElement<dim> as template argument to the ObserverPointer class. Note that the pointer so declared is assigned at construction time of the solve object, and destroyed upon destruction, so the lock on the destruction of the finite element object extends throughout the lifetime of this HelmholtzProblem object.

  DoFHandler<dim> dof_handler;
 
 
  AffineConstraints<double> hanging_node_constraints;
 
  SparsityPattern sparsity_pattern;
  SparseMatrix<double> system_matrix;
 
  Vector<double> solution;
  Vector<double> system_rhs;
 
const ::parallel::distributed::Triangulation< dim, spacedim > * triangulation

The second to last variable stores the refinement mode passed to the constructor. Since it is only set in the constructor, we can declare this variable constant, to avoid that someone sets it involuntarily (e.g. in an ‘if’-statement where == was written as = by chance).

  const RefinementMode refinement_mode;
 

For each refinement level some data (like the number of cells, or the L2 error of the numerical solution) will be generated and later printed. The TableHandler can be used to collect all this data and to output it at the end of the run as a table in a simple text or in LaTeX format. Here we don't only use the TableHandler but we use the derived class ConvergenceTable that additionally evaluates rates of convergence:

  ConvergenceTable convergence_table;
  };
 
 

The HelmholtzProblem class implementation

HelmholtzProblem::HelmholtzProblem constructor

In the constructor of this class, we only set the variables passed as arguments, and associate the DoF handler object with the triangulation (which is empty at present, however).

  template <int dim>
  HelmholtzProblem<dim>::HelmholtzProblem(const FiniteElement<dim> &fe,
  const RefinementMode refinement_mode)
  : dof_handler(triangulation)
  , fe(&fe)
  , refinement_mode(refinement_mode)
  {}
 
 

HelmholtzProblem::setup_system

The following function sets up the degrees of freedom, sizes of matrices and vectors, etc. Most of its functionality has been showed in previous examples, the only difference being the renumbering step immediately after first distributing degrees of freedom.

Renumbering the degrees of freedom is not overly difficult, as long as you use one of the algorithms included in the library. It requires only a single line of code. Some more information on this can be found in step-2.

Note, however, that when you renumber the degrees of freedom, you must do so immediately after distributing them, since such things as hanging nodes, the sparsity pattern etc. depend on the absolute numbers which are altered by renumbering.

The reason why we introduce renumbering here is that it is a relatively cheap operation but often has a beneficial effect: While the CG iteration itself is independent of the actual ordering of degrees of freedom, we will use SSOR as a preconditioner. SSOR goes through all degrees of freedom and does some operations that depend on what happened before; the SSOR operation is therefore not independent of the numbering of degrees of freedom, and it is known that its performance improves by using renumbering techniques. A little experiment shows that indeed, for example, the number of CG iterations for the fifth refinement cycle of adaptive refinement with the Q1 program used here is 40 without, but 36 with renumbering. Similar savings can generally be observed for all the computations in this program.

  template <int dim>
  void HelmholtzProblem<dim>::setup_system()
  {
  dof_handler.distribute_dofs(*fe);
 
  hanging_node_constraints.clear();
  hanging_node_constraints);
  hanging_node_constraints.close();
 
  DynamicSparsityPattern dsp(dof_handler.n_dofs(), dof_handler.n_dofs());
  DoFTools::make_sparsity_pattern(dof_handler, dsp);
  hanging_node_constraints.condense(dsp);
  sparsity_pattern.copy_from(dsp);
 
  system_matrix.reinit(sparsity_pattern);
 
  solution.reinit(dof_handler.n_dofs());
  system_rhs.reinit(dof_handler.n_dofs());
  }
 
 
void make_hanging_node_constraints(const DoFHandler< dim, spacedim > &dof_handler, AffineConstraints< number > &constraints)
void make_sparsity_pattern(const DoFHandler< dim, spacedim > &dof_handler, SparsityPatternBase &sparsity_pattern, const AffineConstraints< number > &constraints={}, const bool keep_constrained_dofs=true, const types::subdomain_id subdomain_id=numbers::invalid_subdomain_id)
void Cuthill_McKee(DoFHandler< dim, spacedim > &dof_handler, const bool reversed_numbering=false, const bool use_constraints=false, const std::vector< types::global_dof_index > &starting_indices=std::vector< types::global_dof_index >())

HelmholtzProblem::assemble_system

Assembling the system of equations for the problem at hand is mostly as for the example programs before. However, some things have changed anyway, so we comment on this function fairly extensively.

At the top of the function you will find the usual assortment of variable declarations. Compared to previous programs, of importance is only that we expect to solve problems also with bi-quadratic elements and therefore have to use sufficiently accurate quadrature formula. In addition, we need to compute integrals over faces, i.e. dim-1 dimensional objects. The declaration of a face quadrature formula is then straightforward:

  template <int dim>
  void HelmholtzProblem<dim>::assemble_system()
  {
  const QGauss<dim> quadrature_formula(fe->degree + 1);
  const QGauss<dim - 1> face_quadrature_formula(fe->degree + 1);
 
  const unsigned int n_q_points = quadrature_formula.size();
  const unsigned int n_face_q_points = face_quadrature_formula.size();
 
  const unsigned int dofs_per_cell = fe->n_dofs_per_cell();
 
  FullMatrix<double> cell_matrix(dofs_per_cell, dofs_per_cell);
  Vector<double> cell_rhs(dofs_per_cell);
 
  std::vector<types::global_dof_index> local_dof_indices(dofs_per_cell);
 
const unsigned int degree
Definition fe_data.h:452
unsigned int n_dofs_per_cell() const

Then we need objects which can evaluate the values, gradients, etc of the shape functions at the quadrature points. While it seems that it should be feasible to do it with one object for both domain and face integrals, there is a subtle difference since the weights in the domain integrals include the measure of the cell in the domain, while the face integral quadrature requires the measure of the face in a lower-dimensional manifold. Internally these two classes are rooted in a common base class which does most of the work and offers the same interface to both domain and interface integrals.

For the domain integrals in the bilinear form for Helmholtz's equation, we need to compute the values and gradients, as well as the weights at the quadrature points. Furthermore, we need the quadrature points on the real cell (rather than on the unit cell) to evaluate the right hand side function. The object we use to get at this information is the FEValues class discussed previously.

For the face integrals, we only need the values of the shape functions, as well as the weights. We also need the normal vectors and quadrature points on the real cell since we want to determine the Neumann values from the exact solution object (see below). The class that gives us this information is called FEFaceValues:

  FEValues<dim> fe_values(*fe,
  quadrature_formula,
 
  FEFaceValues<dim> fe_face_values(*fe,
  face_quadrature_formula,
 
@ update_values
Shape function values.
@ update_normal_vectors
Normal vectors.
@ update_JxW_values
Transformed quadrature weights.
@ update_gradients
Shape function gradients.
@ update_quadrature_points
Transformed quadrature points.

Then we need some objects already known from previous examples: An object denoting the right hand side function, its values at the quadrature points on a cell, the cell matrix and right hand side, and the indices of the degrees of freedom on a cell.

Note that the operations we will do with the right hand side object are only querying data, never changing the object. We can therefore declare it const:

  RightHandSide<dim> right_hand_side;
  std::vector<double> rhs_values(n_q_points);
 

Finally we define an object denoting the exact solution function. We will use it to compute the Neumann values at the boundary from it. Usually, one would of course do so using a separate object, in particular since the exact solution is generally unknown while the Neumann values are prescribed. We will, however, be a little bit lazy and use what we already have in information. Real-life programs would to go other ways here, of course.

  Solution<dim> exact_solution;
 

Now for the main loop over all cells. This is mostly unchanged from previous examples, so we only comment on the things that have changed.

  for (const auto &cell : dof_handler.active_cell_iterators())
  {
  fe_values.reinit(cell);
 
  cell_matrix = 0.;
  cell_rhs = 0.;
 
  right_hand_side.value_list(fe_values.get_quadrature_points(),
  rhs_values);
 
  for (unsigned int q_point = 0; q_point < n_q_points; ++q_point)
  for (unsigned int i = 0; i < dofs_per_cell; ++i)
  {
  for (unsigned int j = 0; j < dofs_per_cell; ++j)

The first thing that has changed is the bilinear form. It now contains the additional term from the Helmholtz equation:

  cell_matrix(i, j) +=
  ((fe_values.shape_grad(i, q_point) * // grad phi_i(x_q)
  fe_values.shape_grad(j, q_point) // grad phi_j(x_q)
  +
  fe_values.shape_value(i, q_point) * // phi_i(x_q)
  fe_values.shape_value(j, q_point)) * // phi_j(x_q)
  fe_values.JxW(q_point)); // dx
 
 
  cell_rhs(i) += (fe_values.shape_value(i, q_point) * // phi_i(x_q)
  rhs_values[q_point] * // f(x_q)
  fe_values.JxW(q_point)); // dx
  }
 

Then there is that second term on the right hand side, the contour integral. First we have to find out whether the intersection of the faces of this cell with the boundary part Gamma2 is nonzero. To this end, we loop over all faces and check whether its boundary indicator equals 1, which is the value that we have assigned to that portions of the boundary composing Gamma2 in the run() function further below. (The default value of boundary indicators is 0, so faces can only have an indicator equal to 1 if we have explicitly set it.)

  for (const auto &face : cell->face_iterators())
  if (face->at_boundary() && (face->boundary_id() == 1))
  {

If we came into here, then we have found an external face belonging to Gamma2. Next, we have to compute the values of the shape functions and the other quantities which we will need for the computation of the contour integral. This is done using the reinit function which we already know from the FEValue class:

  fe_face_values.reinit(cell, face);
 

And we can then perform the integration by using a loop over all quadrature points.

On each quadrature point, we first compute the value of the normal derivative. We do so using the gradient of the exact solution and the normal vector to the face at the present quadrature point obtained from the fe_face_values object. This is then used to compute the additional contribution of this face to the right hand side:

  for (unsigned int q_point = 0; q_point < n_face_q_points;
  ++q_point)
  {
  const double neumann_value =
  (exact_solution.gradient(
  fe_face_values.quadrature_point(q_point)) *
  fe_face_values.normal_vector(q_point));
 
  for (unsigned int i = 0; i < dofs_per_cell; ++i)
  cell_rhs(i) +=
  (fe_face_values.shape_value(i, q_point) * // phi_i(x_q)
  neumann_value * // g(x_q)
  fe_face_values.JxW(q_point)); // dx
  }
  }
 

Now that we have the contributions of the present cell, we can transfer it to the global matrix and right hand side vector, as in the examples before:

  cell->get_dof_indices(local_dof_indices);
  for (unsigned int i = 0; i < dofs_per_cell; ++i)
  {
  for (unsigned int j = 0; j < dofs_per_cell; ++j)
  system_matrix.add(local_dof_indices[i],
  local_dof_indices[j],
  cell_matrix(i, j));
 
  system_rhs(local_dof_indices[i]) += cell_rhs(i);
  }
  }
 

Likewise, elimination and treatment of boundary values has been shown previously.

We note, however that now the boundary indicator for which we interpolate boundary values (denoted by the second parameter to interpolate_boundary_values) does not represent the whole boundary any more. Rather, it is that portion of the boundary which we have not assigned another indicator (see below). The degrees of freedom at the boundary that do not belong to Gamma1 are therefore excluded from the interpolation of boundary values, just as we want.

  hanging_node_constraints.condense(system_matrix);
  hanging_node_constraints.condense(system_rhs);
 
  std::map<types::global_dof_index, double> boundary_values;
  Solution<dim>(),
  boundary_values);
  system_matrix,
  solution,
  system_rhs);
  }
 
 
void apply_boundary_values(const std::map< types::global_dof_index, number > &boundary_values, SparseMatrix< number > &matrix, Vector< number > &solution, Vector< number > &right_hand_side, const bool eliminate_columns=true)
void interpolate_boundary_values(const Mapping< dim, spacedim > &mapping, const DoFHandler< dim, spacedim > &dof, const std::map< types::boundary_id, const Function< spacedim, number > * > &function_map, std::map< types::global_dof_index, number > &boundary_values, const ComponentMask &component_mask={})

HelmholtzProblem::solve

Solving the system of equations is done in the same way as before:

  template <int dim>
  void HelmholtzProblem<dim>::solve()
  {
  SolverControl solver_control(1000, 1e-6 * system_rhs.l2_norm());
  SolverCG<Vector<double>> cg(solver_control);
 
  preconditioner.initialize(system_matrix, 1.2);
 
  cg.solve(system_matrix, solution, system_rhs, preconditioner);
 
  hanging_node_constraints.distribute(solution);
  }
 
 
void initialize(const MatrixType &A, const AdditionalData &parameters=AdditionalData())

HelmholtzProblem::refine_grid

Now for the function doing grid refinement. Depending on the refinement mode passed to the constructor, we do global or adaptive refinement.

Global refinement is simple, so there is not much to comment on. In case of adaptive refinement, we use the same functions and classes as in the previous example program. Note that one could treat Neumann boundaries differently than Dirichlet boundaries, and one should in fact do so here since we have Neumann boundary conditions on part of the boundaries, but since we don't have a function here that describes the Neumann values (we only construct these values from the exact solution when assembling the matrix), we omit this detail even though doing this in a strictly correct way would not be hard to add.

At the end of the switch, we have a default case that simply says DEAL_II_ASSERT_UNREACHABLE(). This macro raises an error whenever the program reaches this point; the program is then aborted. This is intentional: Right now we have only implemented two refinement strategies (global and adaptive), but someone might want to add a third strategy (for example adaptivity with a different refinement criterion) and add a third member to the enumeration that determines the refinement mode. If it weren't for the default case of the switch statement, this function would simply run to its end without doing anything. This is most likely not what was intended. One of the defensive programming techniques that you will find all over the deal.II library is therefore to always have default cases that abort, to make sure that values not considered when listing the cases in the switch statement are eventually caught, and forcing programmers to add code to handle them. The documentation of DEAL_II_ASSERT_UNREACHABLE() shows other examples of how this macro can be used.

We will use this same technique in other places further down as well.

  template <int dim>
  void HelmholtzProblem<dim>::refine_grid()
  {
  switch (refinement_mode)
  {
  case global_refinement:
  {
  triangulation.refine_global(1);
  break;
  }
 
  case adaptive_refinement:
  {
  Vector<float> estimated_error_per_cell(
  triangulation.n_active_cells());
 
  dof_handler,
  QGauss<dim - 1>(fe->degree + 1),
  std::map<types::boundary_id, const Function<dim> *>(),
  solution,
  estimated_error_per_cell);
 
  triangulation, estimated_error_per_cell, 0.3, 0.03);
 
  triangulation.execute_coarsening_and_refinement();
 
  break;
  }
 
  default:
  {
  }
  }
  }
 
 
static void estimate(const Mapping< dim, spacedim > &mapping, const DoFHandler< dim, spacedim > &dof, const Quadrature< dim - 1 > &quadrature, const std::map< types::boundary_id, const Function< spacedim, Number > * > &neumann_bc, const ReadVector< Number > &solution, Vector< float > &error, const ComponentMask &component_mask={}, const Function< spacedim > *coefficients=nullptr, const unsigned int n_threads=numbers::invalid_unsigned_int, const types::subdomain_id subdomain_id=numbers::invalid_subdomain_id, const types::material_id material_id=numbers::invalid_material_id, const Strategy strategy=cell_diameter_over_24)
#define DEAL_II_ASSERT_UNREACHABLE()
void refine_and_coarsen_fixed_number(Triangulation< dim, spacedim > &triangulation, const Vector< Number > &criteria, const double top_fraction_of_cells, const double bottom_fraction_of_cells, const unsigned int max_n_cells=std::numeric_limits< unsigned int >::max())

HelmholtzProblem::process_solution

Finally we want to process the solution after it has been computed. For this, we integrate the error in various (semi-)norms, and we generate tables that will later be used to display the convergence against the continuous solution in a nice format.

  template <int dim>
  void HelmholtzProblem<dim>::process_solution(const unsigned int cycle)
  {

Our first task is to compute error norms. In order to integrate the difference between computed numerical solution and the continuous solution (described by the Solution class defined at the top of this file), we first need a vector that will hold the norm of the error on each cell. Since accuracy with 16 digits is not so important for these quantities, we save some memory by using float instead of double values.

The next step is to use a function from the library which computes the error in the L2 norm on each cell. We have to pass it the DoF handler object, the vector holding the nodal values of the numerical solution, the continuous solution as a function object, the vector into which it shall place the norm of the error on each cell, a quadrature rule by which this norm shall be computed, and the type of norm to be used. Here, we use a Gauss formula with three points in each space direction, and compute the L2 norm.

Finally, we want to get the global L2 norm. This can of course be obtained by summing the squares of the norms on each cell, and taking the square root of that value. This is equivalent to taking the l2 (lower case l) norm of the vector of norms on each cell:

  Vector<float> difference_per_cell(triangulation.n_active_cells());
  solution,
  Solution<dim>(),
  difference_per_cell,
  QGauss<dim>(fe->degree + 1),
  const double L2_error =
  difference_per_cell,
 
double compute_global_error(const Triangulation< dim, spacedim > &tria, const InVector &cellwise_error, const NormType &norm, const double exponent=2.)
void integrate_difference(const Mapping< dim, spacedim > &mapping, const DoFHandler< dim, spacedim > &dof, const ReadVector< Number > &fe_function, const Function< spacedim, Number > &exact_solution, OutVector &difference, const Quadrature< dim > &q, const NormType &norm, const Function< spacedim, double > *weight=nullptr, const double exponent=2.)

By same procedure we get the H1 semi-norm. We re-use the difference_per_cell vector since it is no longer used after computing the L2_error variable above. The global \(H^1\) semi-norm error is then computed by taking the sum of squares of the errors on each individual cell, and then the square root of it – an operation that is conveniently performed by VectorTools::compute_global_error.

  solution,
  Solution<dim>(),
  difference_per_cell,
  QGauss<dim>(fe->degree + 1),
  const double H1_error =
  difference_per_cell,
 

Finally, we compute the maximum norm. Of course, we can't actually compute the true maximum of the error over all points in the domain, but only the maximum over a finite set of evaluation points that, for convenience, we will still call "quadrature points" and represent by an object of type Quadrature even though we do not actually perform any integration.

There is then the question of what points precisely we want to evaluate at. It turns out that the result we get depends quite sensitively on the "quadrature" points being used. There is also the issue of superconvergence: Finite element solutions are, on some meshes and for polynomial degrees \(k\ge 2\), particularly accurate at the node points as well as at Gauss-Lobatto points, much more accurate than at randomly chosen points. (See [145] and the discussion and references in Section 1.2 for more information on this.) In other words, if we are interested in finding the largest difference \(u(\mathbf x)-u_h(\mathbf x)\), then we ought to look at points \(\mathbf x\) that are specifically not of this "special" kind of points and we should specifically not use QGauss(fe->degree+1) to define where we evaluate. Rather, we use a special quadrature rule that is obtained by iterating the trapezoidal rule by the degree of the finite element times two plus one in each space direction. Note that the constructor of the QIterated class takes a one-dimensional quadrature rule and a number that tells it how often it shall repeat this rule in each space direction.

Using this special quadrature rule, we can then try to find the maximal error on each cell. Finally, we compute the global L infinity error from the L infinity errors on each cell with a call to VectorTools::compute_global_error.

  const QTrapezoid<1> q_trapez;
  const QIterated<dim> q_iterated(q_trapez, fe->degree * 2 + 1);
  solution,
  Solution<dim>(),
  difference_per_cell,
  q_iterated,
  const double Linfty_error =
  difference_per_cell,
 

After all these errors have been computed, we finally write some output. In addition, we add the important data to the TableHandler by specifying the key of the column and the value. Note that it is not necessary to define column keys beforehand – it is sufficient to just add values, and columns will be introduced into the table in the order values are added the first time.

  const unsigned int n_active_cells = triangulation.n_active_cells();
  const unsigned int n_dofs = dof_handler.n_dofs();
 
  std::cout << "Cycle " << cycle << ':' << std::endl
  << " Number of active cells: " << n_active_cells
  << std::endl
  << " Number of degrees of freedom: " << n_dofs << std::endl;
 
  convergence_table.add_value("cycle", cycle);
  convergence_table.add_value("cells", n_active_cells);
  convergence_table.add_value("dofs", n_dofs);
  convergence_table.add_value("L2", L2_error);
  convergence_table.add_value("H1", H1_error);
  convergence_table.add_value("Linfty", Linfty_error);
  }
 
 

HelmholtzProblem::run

As in previous example programs, the run function controls the flow of execution. The basic layout is as in previous examples: an outer loop over successively refined grids, and in this loop first problem setup, assembling the linear system, solution, and post-processing.

The first task in the main loop is creation and refinement of grids. This is as in previous examples, with the only difference that we want to have part of the boundary marked as Neumann type, rather than Dirichlet.

For this, we will use the following convention: Faces belonging to Gamma1 will have the boundary indicator 0 (which is the default, so we don't have to set it explicitly), and faces belonging to Gamma2 will use 1 as boundary indicator. To set these values, we loop over all cells, then over all faces of a given cell, check whether it is part of the boundary that we want to denote by Gamma2, and if so set its boundary indicator to 1. For the present program, we consider the left and bottom boundaries as Gamma2. We determine whether a face is part of that boundary by asking whether the x or y coordinates (i.e. vector components 0 and 1) of the midpoint of a face equals -1, up to some small wiggle room that we have to give since it is instable to compare floating point numbers that are subject to round off in intermediate computations.

It is worth noting that we have to loop over all cells here, not only the active ones. The reason is that upon refinement, newly created faces inherit the boundary indicator of their parent face. If we now only set the boundary indicator for active faces, coarsen some cells and refine them later on, they will again have the boundary indicator of the parent cell which we have not modified, instead of the one we intended. Consequently, we have to change the boundary indicators of faces of all cells on Gamma2, whether they are active or not. Alternatively, we could of course have done this job on the coarsest mesh (i.e. before the first refinement step) and refined the mesh only after that.

  template <int dim>
  void HelmholtzProblem<dim>::run()
  {
  const unsigned int n_cycles =
  (refinement_mode == global_refinement) ? 5 : 9;
  for (unsigned int cycle = 0; cycle < n_cycles; ++cycle)
  {
  if (cycle == 0)
  {
  triangulation.refine_global(3);
 
  for (const auto &cell : triangulation.cell_iterators())
  for (const auto &face : cell->face_iterators())
  {
  const auto center = face->center();
  if ((std::fabs(center[0] - (-1.0)) < 1e-12) ||
  (std::fabs(center[1] - (-1.0)) < 1e-12))
  face->set_boundary_id(1);
  }
  }
  else
  refine_grid();
 
 
void hyper_cube(Triangulation< dim, spacedim > &tria, const double left=0., const double right=1., const bool colorize=false)

The next steps are already known from previous examples. This is mostly the basic set-up of every finite element program:

  setup_system();
 
  assemble_system();
  solve();
 

The last step in this chain of function calls is usually the evaluation of the computed solution for the quantities one is interested in. This is done in the following function. Since the function generates output that indicates the number of the present refinement step, we pass this number as an argument.

  process_solution(cycle);
  }
 

Output of graphical data

After the last iteration we output the solution on the finest grid. This is done using the following sequence of statements which we have already discussed in previous examples. The first step is to generate a suitable filename (called vtk_filename here, since we want to output data in VTK format; we add the prefix to distinguish the filename from that used for other output files further down below). Here, we augment the name by the mesh refinement algorithm, and as above we make sure that we abort the program if another refinement method is added and not handled by the following switch statement:

  std::string vtk_filename;
  switch (refinement_mode)
  {
  case global_refinement:
  vtk_filename = "solution-global";
  break;
  case adaptive_refinement:
  vtk_filename = "solution-adaptive";
  break;
  default:
  }
 

We augment the filename by a postfix denoting the finite element which we have used in the computation. To this end, the finite element base class stores the maximal polynomial degree of shape functions in each coordinate variable as a variable degree, which we append as "-q1", "-q2", etc., to the filename.

  vtk_filename += "-q" + std::to_string(fe->degree);
 

Once we have the base name for the output file, we add an extension appropriate for VTK output, open a file, and add the solution vector to the object that will do the actual output:

  vtk_filename += ".vtk";
  std::ofstream output(vtk_filename);
 
  DataOut<dim> data_out;
  data_out.attach_dof_handler(dof_handler);
  data_out.add_data_vector(solution, "solution");
 
void attach_dof_handler(const DoFHandler< dim, spacedim > &)

Now building the intermediate format as before is the next step. We introduce one more feature of deal.II here. The background is the following: in some of the runs of this function, we have used biquadratic finite elements. However, since almost all output formats only support bilinear data, the data is written only bilinear, and information is consequently lost. Of course, we can't change the format in which graphic programs accept their inputs, but we can write the data differently such that we more closely resemble the information available in the quadratic approximation. We can, for example, write each cell as four sub-cells with bilinear data each, such that we have nine data points for each cell in the triangulation. The graphic programs will, of course, display this data still only bilinear, but at least we have given some more of the information we have.

In order to allow writing more than one sub-cell per actual cell, the build_patches function accepts a parameter (the default is 1, which is why you haven't seen this parameter in previous examples). This parameter denotes into how many sub-cells per space direction each cell shall be subdivided for output. For example, if you give 2, this leads to 4 cells in 2d and 8 cells in 3d. For quadratic elements, two sub-cells per space direction is obviously the right choice, so this is what we choose. In general, for elements of polynomial order q, we use q subdivisions, and the order of the elements is determined in the same way as above.

With the intermediate format so generated, we can then actually write the graphical output:

  data_out.build_patches(fe->degree);
  data_out.write_vtk(output);
 

Output of convergence tables

After graphical output, we would also like to generate tables from the error computations we have done in process_solution. There, we have filled a table object with the number of cells for each refinement step as well as the errors in different norms.

For a nicer textual output of this data, one may want to set the precision with which the values will be written upon output. We use 3 digits for this, which is usually sufficient for error norms. By default, data is written in fixed point notation. However, for columns one would like to see in scientific notation another function call sets the scientific_flag to true, leading to floating point representation of numbers.

  convergence_table.set_precision("L2", 3);
  convergence_table.set_precision("H1", 3);
  convergence_table.set_precision("Linfty", 3);
 
  convergence_table.set_scientific("L2", true);
  convergence_table.set_scientific("H1", true);
  convergence_table.set_scientific("Linfty", true);
 

For the output of a table into a LaTeX file, the default captions of the columns are the keys given as argument to the add_value functions. To have TeX captions that differ from the default ones you can specify them by the following function calls. Note, that ‘\’ is reduced to ‘&rsquo; by the compiler such that the real TeX caption is, e.g., ‘ \(L^\infty\)-error’.

  convergence_table.set_tex_caption("cells", "\\# cells");
  convergence_table.set_tex_caption("dofs", "\\# dofs");
  convergence_table.set_tex_caption("L2", "L^2-error");
  convergence_table.set_tex_caption("H1", "H^1-error");
  convergence_table.set_tex_caption("Linfty", "L^\\infty-error");
 

Finally, the default LaTeX format for each column of the table is ‘c’ (centered). To specify a different (e.g. ‘right’) one, the following function may be used:

  convergence_table.set_tex_format("cells", "r");
  convergence_table.set_tex_format("dofs", "r");
 

After this, we can finally write the table to the standard output stream std::cout (after one extra empty line, to make things look prettier). Note, that the output in text format is quite simple and that captions may not be printed directly above the specific columns.

  std::cout << std::endl;
  convergence_table.write_text(std::cout);
 

The table can also be written into a LaTeX file. The (nicely) formatted table can be viewed after calling latex filename.tex and whatever output viewer you prefer, where filename is the name of the file to which we will write output. We construct the file name in the same way as before, but with a different prefix "error":

  std::string error_filename = "error";
  switch (refinement_mode)
  {
  case global_refinement:
  error_filename += "-global";
  break;
  case adaptive_refinement:
  error_filename += "-adaptive";
  break;
  default:
  }
 
  error_filename += "-q" + std::to_string(fe->degree);
  error_filename += ".tex";
  std::ofstream error_table_file(error_filename);
 
  convergence_table.write_tex(error_table_file);
 
 

Further table manipulations

In case of global refinement, it might be of interest to also output the convergence rates. This may be done by the functionality the ConvergenceTable offers over the regular TableHandler. However, we do it only for global refinement, since for adaptive refinement the determination of something like an order of convergence is somewhat more involved. While we are at it, we also show a few other things that can be done with tables.

  if (refinement_mode == global_refinement)
  {

The first thing is that one can group individual columns together to form so-called super columns. Essentially, the columns remain the same, but the ones that were grouped together will get a caption running across all columns in a group. For example, let's merge the "cycle" and "cells" columns into a super column named "n cells":

  convergence_table.add_column_to_supercolumn("cycle", "n cells");
  convergence_table.add_column_to_supercolumn("cells", "n cells");
 

Next, it isn't necessary to always output all columns, or in the order in which they were originally added during the run. Selecting and re-ordering the columns works as follows (note that this includes super columns):

  std::vector<std::string> new_order;
  new_order.emplace_back("n cells");
  new_order.emplace_back("H1");
  new_order.emplace_back("L2");
  convergence_table.set_column_order(new_order);
 

For everything that happened to the ConvergenceTable until this point, it would have been sufficient to use a simple TableHandler. Indeed, the ConvergenceTable is derived from the TableHandler but it offers the additional functionality of automatically evaluating convergence rates. For example, here is how we can let the table compute reduction and convergence rates (convergence rates are the binary logarithm of the reduction rate):

  convergence_table.evaluate_convergence_rates(
  convergence_table.evaluate_convergence_rates(
  convergence_table.evaluate_convergence_rates(
  convergence_table.evaluate_convergence_rates(

Each of these function calls produces an additional column that is merged with the original column (in our example the ‘L2’ and the ‘H1’ column) to a supercolumn.

Finally, we want to write this convergence chart again, first to the screen and then, in LaTeX format, to disk. The filename is again constructed as above.

  std::cout << std::endl;
  convergence_table.write_text(std::cout);
 
  std::string conv_filename = "convergence";
  switch (refinement_mode)
  {
  case global_refinement:
  conv_filename += "-global";
  break;
  case adaptive_refinement:
  conv_filename += "-adaptive";
  break;
  default:
  }
  conv_filename += "-q" + std::to_string(fe->degree);
  conv_filename += ".tex";
 
  std::ofstream table_file(conv_filename);
  convergence_table.write_tex(table_file);
  }
  }
 

The final step before going to main() is then to close the namespace Step7 into which we have put everything we needed for this program:

  } // namespace Step7
 

Main function

The main function is mostly as before. The only difference is that we solve three times, once for Q1 and adaptive refinement, once for Q1 elements and global refinement, and once for Q2 elements and global refinement.

Since we instantiate several template classes below for two space dimensions, we make this more generic by declaring a constant at the beginning of the function denoting the number of space dimensions. If you want to run the program in 1d or 2d, you will then only have to change this one instance, rather than all uses below:

  int main()
  {
  const unsigned int dim = 2;
 
  try
  {
  using namespace dealii;
  using namespace Step7;
 

Now for the three calls to the main class. Each call is blocked into curly braces in order to destroy the respective objects (i.e. the finite element and the HelmholtzProblem object) at the end of the block and before we go to the next run. This avoids conflicts with variable names, and also makes sure that memory is released immediately after one of the three runs has finished, and not only at the end of the try block.

  {
  std::cout << "Solving with Q1 elements, adaptive refinement"
  << std::endl
  << "============================================="
  << std::endl
  << std::endl;
 
  const FE_Q<dim> fe(1);
  HelmholtzProblem<dim> helmholtz_problem_2d(
  fe, HelmholtzProblem<dim>::adaptive_refinement);
 
  helmholtz_problem_2d.run();
 
  std::cout << std::endl;
  }
 
  {
  std::cout << "Solving with Q1 elements, global refinement" << std::endl
  << "===========================================" << std::endl
  << std::endl;
 
  const FE_Q<dim> fe(1);
  HelmholtzProblem<dim> helmholtz_problem_2d(
  fe, HelmholtzProblem<dim>::global_refinement);
 
  helmholtz_problem_2d.run();
 
  std::cout << std::endl;
  }
 
  {
  std::cout << "Solving with Q2 elements, global refinement" << std::endl
  << "===========================================" << std::endl
  << std::endl;
 
  const FE_Q<dim> fe(2);
  HelmholtzProblem<dim> helmholtz_problem_2d(
  fe, HelmholtzProblem<dim>::global_refinement);
 
  helmholtz_problem_2d.run();
 
  std::cout << std::endl;
  }
  {
  std::cout << "Solving with Q2 elements, adaptive refinement"
  << std::endl
  << "===========================================" << std::endl
  << std::endl;
 
  const FE_Q<dim> fe(2);
  HelmholtzProblem<dim> helmholtz_problem_2d(
  fe, HelmholtzProblem<dim>::adaptive_refinement);
 
  helmholtz_problem_2d.run();
 
  std::cout << std::endl;
  }
  }
  catch (std::exception &exc)
  {
  std::cerr << std::endl
  << std::endl
  << "----------------------------------------------------"
  << std::endl;
  std::cerr << "Exception on processing: " << std::endl
  << exc.what() << std::endl
  << "Aborting!" << std::endl
  << "----------------------------------------------------"
  << std::endl;
  return 1;
  }
  catch (...)
  {
  std::cerr << std::endl
  << std::endl
  << "----------------------------------------------------"
  << std::endl;
  std::cerr << "Unknown exception!" << std::endl
  << "Aborting!" << std::endl
  << "----------------------------------------------------"
  << std::endl;
  return 1;
  }
 
  return 0;
  }
Definition fe_q.h:554

Results

The program generates two kinds of output. The first are the output files solution-adaptive-q1.vtk, solution-global-q1.vtk, and solution-global-q2.vtk. We show the latter in a 3d view here:

Secondly, the program writes tables not only to disk, but also to the screen while running. The output looks like the following (recall that columns labeled as "H1" actually show the \(H^1\) semi-norm of the error, not the full \(H^1\) norm):

examples/step-7> make run
Solving with Q1 elements, adaptive refinement
=============================================
Cycle 0:
Number of active cells: 64
Number of degrees of freedom: 81
Cycle 1:
Number of active cells: 121
Number of degrees of freedom: 154
Cycle 2:
Number of active cells: 280
Number of degrees of freedom: 341
Cycle 3:
Number of active cells: 565
Number of degrees of freedom: 678
Cycle 4:
Number of active cells: 1075
Number of degrees of freedom: 1240
Cycle 5:
Number of active cells: 2041
Number of degrees of freedom: 2306
Cycle 6:
Number of active cells: 3913
Number of degrees of freedom: 4216
Cycle 7:
Number of active cells: 7432
Number of degrees of freedom: 7909
Cycle 8:
Number of active cells: 14203
Number of degrees of freedom: 14870
cycle cells dofs L2 H1 Linfty
0 64 81 1.840e+00 2.858e+00 1.835e+00
1 121 154 5.336e-02 1.200e+00 1.354e-01
2 280 341 1.439e-02 7.892e-01 7.554e-02
3 565 678 8.696e-03 5.086e-01 2.843e-02
4 1075 1240 3.245e-03 3.059e-01 1.072e-02
5 2041 2306 2.407e-03 2.147e-01 5.156e-03
6 3913 4216 8.501e-04 1.503e-01 2.033e-03
7 7432 7909 7.113e-04 1.086e-01 1.808e-03
8 14203 14870 3.140e-04 7.671e-02 7.181e-04
Solving with Q1 elements, global refinement
===========================================
Cycle 0:
Number of active cells: 64
Number of degrees of freedom: 81
Cycle 1:
Number of active cells: 256
Number of degrees of freedom: 289
Cycle 2:
Number of active cells: 1024
Number of degrees of freedom: 1089
Cycle 3:
Number of active cells: 4096
Number of degrees of freedom: 4225
Cycle 4:
Number of active cells: 16384
Number of degrees of freedom: 16641
cycle cells dofs L2 H1 Linfty
0 64 81 1.840e+00 2.858e+00 1.835e+00
1 256 289 3.570e-02 1.199e+00 1.307e-01
2 1024 1089 1.192e-02 7.565e-01 7.168e-02
3 4096 4225 3.047e-03 3.823e-01 2.128e-02
4 16384 16641 7.660e-04 1.917e-01 5.554e-03
n cells H1 L2
0 64 2.858e+00 - - 1.840e+00 - -
1 256 1.199e+00 2.38 1.25 3.570e-02 51.54 5.69
2 1024 7.565e-01 1.58 0.66 1.192e-02 2.99 1.58
3 4096 3.823e-01 1.98 0.98 3.047e-03 3.91 1.97
4 16384 1.917e-01 1.99 1.00 7.660e-04 3.98 1.99
Solving with Q2 elements, global refinement
===========================================
Cycle 0:
Number of active cells: 64
Number of degrees of freedom: 289
Cycle 1:
Number of active cells: 256
Number of degrees of freedom: 1089
Cycle 2:
Number of active cells: 1024
Number of degrees of freedom: 4225
Cycle 3:
Number of active cells: 4096
Number of degrees of freedom: 16641
Cycle 4:
Number of active cells: 16384
Number of degrees of freedom: 66049
cycle cells dofs L2 H1 Linfty
0 64 289 1.606e-01 1.278e+00 3.029e-01
1 256 1089 7.638e-03 5.248e-01 4.816e-02
2 1024 4225 8.601e-04 1.086e-01 4.827e-03
3 4096 16641 1.107e-04 2.756e-02 7.804e-04
4 16384 66049 1.394e-05 6.915e-03 9.991e-05
n cells H1 L2
0 64 1.278e+00 - - 1.606e-01 - -
1 256 5.248e-01 2.43 1.28 7.638e-03 21.03 4.39
2 1024 1.086e-01 4.83 2.27 8.601e-04 8.88 3.15
3 4096 2.756e-02 3.94 1.98 1.107e-04 7.77 2.96
4 16384 6.915e-03 3.99 1.99 1.394e-05 7.94 2.99
Solving with Q2 elements, adaptive refinement
===========================================
Cycle 0:
Number of active cells: 64
Number of degrees of freedom: 289
Cycle 1:
Number of active cells: 121
Number of degrees of freedom: 569
Cycle 2:
Number of active cells: 280
Number of degrees of freedom: 1317
Cycle 3:
Number of active cells: 529
Number of degrees of freedom: 2459
Cycle 4:
Number of active cells: 1015
Number of degrees of freedom: 4719
Cycle 5:
Number of active cells: 1963
Number of degrees of freedom: 9039
Cycle 6:
Number of active cells: 3727
Number of degrees of freedom: 17143
Cycle 7:
Number of active cells: 7081
Number of degrees of freedom: 32343
Cycle 8:
Number of active cells: 13525
Number of degrees of freedom: 60895
cycle cells dofs L2 H1 Linfty
0 64 289 1.606e-01 1.278e+00 3.029e-01
1 121 569 7.916e-03 5.257e-01 4.857e-02
2 280 1317 1.092e-03 1.165e-01 4.832e-03
3 529 2459 5.999e-04 5.177e-02 1.873e-03
4 1015 4719 2.100e-04 3.245e-02 7.938e-04
5 1963 9039 7.821e-05 1.990e-02 7.261e-04
6 3727 17143 2.868e-05 8.498e-03 1.462e-04
7 7081 32343 1.146e-05 4.360e-03 8.576e-05
8 13525 60895 3.747e-06 2.123e-03 2.174e-05

One can see the error reduction upon grid refinement, and for the cases where global refinement was performed, also the convergence rates can be seen. The linear and quadratic convergence rates of Q1 and Q2 elements in the \(H^1\) semi-norm can clearly be seen, as are the quadratic and cubic rates in the \(L_2\) norm.

Finally, the program also generated LaTeX versions of the tables (not shown here) that is written into a file in a way so that it could be copy-pasted into a LaTeX document.

When is the error "small"?

What we showed above is how to determine the size of the error \(\|u-u_h\|\) in a number of different norms. We did this primarily because we were interested in testing that our solutions converge. But from an engineering perspective, the question is often more practical: How fine do I have to make my mesh so that the error is "small enough"? In other words, if in the table above the \(H^1\) semi-norm has been reduced to 2.123e-03, is this good enough for me to sign the blueprint and declare that our numerical simulation showed that the bridge is strong enough?

In practice, we are rarely in this situation because I can not typically compare the numerical solution \(u_h\) against the exact solution \(u\) in situations that matter – if I knew \(u\), I would not have to compute \(u_h\). But even if I could, the question to ask in general is then: 2.123e-03 what? The solution will have physical units, say kg-times-meter-squared, and I'm integrating a function with units square of the above over the domain, and then take the square root. So if the domain is two-dimensional, the units of \(\|u-u_h\|_{L_2}\) are kg-times-meter-cubed. The question is then: Is \(2.123\times 10^{-3}\) kg-times-meter-cubed small? That depends on what you're trying to simulate: If you're an astronomer used to masses measured in solar masses and distances in light years, then yes, this is a fantastically small number. But if you're doing atomic physics, then no: That's not small, and your error is most certainly not sufficiently small; you need a finer mesh.

In other words, when we look at these sorts of numbers, we generally need to compare against a "scale". One way to do that is to not look at the absolute error \(\|u-u_h\|\) in whatever norm, but at the relative* error \(\|u-u_h\|/\|u\|\). If this ratio is \(10^{-5}\), then you know that on average, the difference between \(u\) and \(u_h\) is 0.001 per cent – probably small enough for engineering purposes.

How do we compute \(\|u\|\)? We just need to do an integration loop over all cells, quadrature points on these cells, and then sum things up and take the square root at the end. But there is a simpler way often used: You can call

Vector<double> zero_vector (dof_handler.n_dofs());
Vector<float> norm_per_cell(triangulation.n_active_cells());
zero_vector,
Solution<dim>(),
norm_per_cell,
QGauss<dim>(fe->degree + 1),

which computes \(\|u-0\|_{L_2}\). Alternatively, if you're particularly lazy and don't feel like creating the zero_vector, you could use that if the mesh is not too coarse, then \(\|u\| \approx \|u_h\|\), and we can compute \(\|u\| \approx \|u_h\|=\|0-u_h\|\) by calling

In both cases, one then only has to combine the vector of cellwise norms into one global norm as we already do in the program, by calling

const double L2_norm =
norm_per_cell,

Possibilities for extensions

Higher Order Elements

Go ahead and run the program with higher order elements ( \(Q_3\), \(Q_4\), ...). You will notice that assertions in several parts of the code will trigger (for example in the generation of the filename for the data output). You might have to address these, but it should not be very hard to get the program to work!

Convergence Comparison

Is \(Q_1\) or \(Q_2\) better? What about adaptive versus global refinement? A (somewhat unfair but typical) metric to compare them, is to look at the error as a function of the number of unknowns.

To see this, create a plot in log-log style with the number of unknowns on the \(x\) axis and the \(L_2\) error on the \(y\) axis. You can add reference lines for \(h^2=N^{-1}\) and \(h^3=N^{-3/2}\) and check that global and adaptive refinement follow those. If one makes the (not completely unreasonable) assumption that with a good linear solver, the computational effort is proportional to the number of unknowns \(N\), then it is clear that an error reduction of \({\cal O}(N^{-3/2})\) is substantially better than a reduction of the form \({\cal O}(N^{-1})\): That is, that adaptive refinement gives us the desired error level with less computational work than if we used global refinement. This is not a particularly surprising conclusion, but it's worth checking these sorts of assumptions in practice.

Of course, a fairer comparison would be to plot runtime (switch to release mode first!) instead of number of unknowns on the \(x\) axis. If you plotted run time (check out the Timer class!) against the number of unknowns by timing each refinement step, you will notice that the linear system solver we use in this program is not perfect – its run time grows faster than proportional to the linear system size – and picking a better linear solver might be appropriate for this kind of comparison.

To see how a comparison of this kind could work, take a look at [139] , and specifically Figure 5 that illustrates the error as a function of compute time for a number of polynomial degrees (as well as a number of different ways to discretize the equation used there).

The plain program

/* ------------------------------------------------------------------------
*
* SPDX-License-Identifier: LGPL-2.1-or-later
* Copyright (C) 2000 - 2024 by the deal.II authors
*
* This file is part of the deal.II library.
*
* Part of the source code is dual licensed under Apache-2.0 WITH
* LLVM-exception OR LGPL-2.1-or-later. Detailed license information
* governing the source code and code contributions can be found in
* LICENSE.md and CONTRIBUTING.md at the top level directory of deal.II.
*
* ------------------------------------------------------------------------
*/
#include <array>
#include <fstream>
#include <iostream>
namespace Step7
{
using namespace dealii;
template <int dim>
class SolutionBase
{
protected:
static const std::array<Point<dim>, 3> source_centers;
static const double width;
};
template <>
const std::array<Point<1>, 3> SolutionBase<1>::source_centers = {
{Point<1>(-1.0 / 3.0), Point<1>(0.0), Point<1>(+1.0 / 3.0)}};
template <>
const std::array<Point<2>, 3> SolutionBase<2>::source_centers = {
{Point<2>(-0.5, +0.5), Point<2>(-0.5, -0.5), Point<2>(+0.5, -0.5)}};
template <int dim>
const double SolutionBase<dim>::width = 1. / 8.;
template <int dim>
class Solution : public Function<dim>, protected SolutionBase<dim>
{
public:
virtual double value(const Point<dim> &p,
const unsigned int component = 0) const override;
gradient(const Point<dim> &p,
const unsigned int component = 0) const override;
};
template <int dim>
double Solution<dim>::value(const Point<dim> &p, const unsigned int) const
{
double return_value = 0;
for (const auto &center : this->source_centers)
{
const Tensor<1, dim> x_minus_xi = p - center;
return_value +=
std::exp(-x_minus_xi.norm_square() / (this->width * this->width));
}
return return_value;
}
template <int dim>
Tensor<1, dim> Solution<dim>::gradient(const Point<dim> &p,
const unsigned int) const
{
Tensor<1, dim> return_value;
for (const auto &center : this->source_centers)
{
const Tensor<1, dim> x_minus_xi = p - center;
return_value +=
(-2. / (this->width * this->width) *
std::exp(-x_minus_xi.norm_square() / (this->width * this->width)) *
x_minus_xi);
}
return return_value;
}
template <int dim>
class RightHandSide : public Function<dim>, protected SolutionBase<dim>
{
public:
virtual double value(const Point<dim> &p,
const unsigned int component = 0) const override;
};
template <int dim>
double RightHandSide<dim>::value(const Point<dim> &p,
const unsigned int) const
{
double return_value = 0;
for (const auto &center : this->source_centers)
{
const Tensor<1, dim> x_minus_xi = p - center;
return_value +=
((2. * dim -
4. * x_minus_xi.norm_square() / (this->width * this->width)) /
(this->width * this->width) *
std::exp(-x_minus_xi.norm_square() / (this->width * this->width)));
return_value +=
std::exp(-x_minus_xi.norm_square() / (this->width * this->width));
}
return return_value;
}
template <int dim>
class HelmholtzProblem
{
public:
enum RefinementMode
{
global_refinement,
adaptive_refinement
};
HelmholtzProblem(const FiniteElement<dim> &fe,
const RefinementMode refinement_mode);
void run();
private:
void setup_system();
void assemble_system();
void solve();
void refine_grid();
void process_solution(const unsigned int cycle);
DoFHandler<dim> dof_handler;
AffineConstraints<double> hanging_node_constraints;
SparsityPattern sparsity_pattern;
SparseMatrix<double> system_matrix;
Vector<double> solution;
Vector<double> system_rhs;
const RefinementMode refinement_mode;
ConvergenceTable convergence_table;
};
template <int dim>
HelmholtzProblem<dim>::HelmholtzProblem(const FiniteElement<dim> &fe,
const RefinementMode refinement_mode)
: dof_handler(triangulation)
, fe(&fe)
, refinement_mode(refinement_mode)
{}
template <int dim>
void HelmholtzProblem<dim>::setup_system()
{
dof_handler.distribute_dofs(*fe);
hanging_node_constraints.clear();
hanging_node_constraints);
hanging_node_constraints.close();
DynamicSparsityPattern dsp(dof_handler.n_dofs(), dof_handler.n_dofs());
hanging_node_constraints.condense(dsp);
sparsity_pattern.copy_from(dsp);
system_matrix.reinit(sparsity_pattern);
solution.reinit(dof_handler.n_dofs());
system_rhs.reinit(dof_handler.n_dofs());
}
template <int dim>
void HelmholtzProblem<dim>::assemble_system()
{
const QGauss<dim> quadrature_formula(fe->degree + 1);
const QGauss<dim - 1> face_quadrature_formula(fe->degree + 1);
const unsigned int n_q_points = quadrature_formula.size();
const unsigned int n_face_q_points = face_quadrature_formula.size();
const unsigned int dofs_per_cell = fe->n_dofs_per_cell();
FullMatrix<double> cell_matrix(dofs_per_cell, dofs_per_cell);
Vector<double> cell_rhs(dofs_per_cell);
std::vector<types::global_dof_index> local_dof_indices(dofs_per_cell);
FEValues<dim> fe_values(*fe,
quadrature_formula,
FEFaceValues<dim> fe_face_values(*fe,
face_quadrature_formula,
RightHandSide<dim> right_hand_side;
std::vector<double> rhs_values(n_q_points);
Solution<dim> exact_solution;
for (const auto &cell : dof_handler.active_cell_iterators())
{
fe_values.reinit(cell);
cell_rhs = 0.;
right_hand_side.value_list(fe_values.get_quadrature_points(),
rhs_values);
for (unsigned int q_point = 0; q_point < n_q_points; ++q_point)
for (unsigned int i = 0; i < dofs_per_cell; ++i)
{
for (unsigned int j = 0; j < dofs_per_cell; ++j)
cell_matrix(i, j) +=
((fe_values.shape_grad(i, q_point) * // grad phi_i(x_q)
fe_values.shape_grad(j, q_point) // grad phi_j(x_q)
+
fe_values.shape_value(i, q_point) * // phi_i(x_q)
fe_values.shape_value(j, q_point)) * // phi_j(x_q)
fe_values.JxW(q_point)); // dx
cell_rhs(i) += (fe_values.shape_value(i, q_point) * // phi_i(x_q)
rhs_values[q_point] * // f(x_q)
fe_values.JxW(q_point)); // dx
}
for (const auto &face : cell->face_iterators())
if (face->at_boundary() && (face->boundary_id() == 1))
{
fe_face_values.reinit(cell, face);
for (unsigned int q_point = 0; q_point < n_face_q_points;
++q_point)
{
const double neumann_value =
(exact_solution.gradient(
fe_face_values.quadrature_point(q_point)) *
fe_face_values.normal_vector(q_point));
for (unsigned int i = 0; i < dofs_per_cell; ++i)
cell_rhs(i) +=
(fe_face_values.shape_value(i, q_point) * // phi_i(x_q)
neumann_value * // g(x_q)
fe_face_values.JxW(q_point)); // dx
}
}
cell->get_dof_indices(local_dof_indices);
for (unsigned int i = 0; i < dofs_per_cell; ++i)
{
for (unsigned int j = 0; j < dofs_per_cell; ++j)
system_matrix.add(local_dof_indices[i],
local_dof_indices[j],
cell_matrix(i, j));
system_rhs(local_dof_indices[i]) += cell_rhs(i);
}
}
hanging_node_constraints.condense(system_matrix);
hanging_node_constraints.condense(system_rhs);
std::map<types::global_dof_index, double> boundary_values;
Solution<dim>(),
boundary_values);
system_matrix,
solution,
system_rhs);
}
template <int dim>
void HelmholtzProblem<dim>::solve()
{
SolverControl solver_control(1000, 1e-6 * system_rhs.l2_norm());
SolverCG<Vector<double>> cg(solver_control);
preconditioner.initialize(system_matrix, 1.2);
cg.solve(system_matrix, solution, system_rhs, preconditioner);
hanging_node_constraints.distribute(solution);
}
template <int dim>
void HelmholtzProblem<dim>::refine_grid()
{
switch (refinement_mode)
{
case global_refinement:
{
triangulation.refine_global(1);
break;
}
case adaptive_refinement:
{
Vector<float> estimated_error_per_cell(
triangulation.n_active_cells());
dof_handler,
QGauss<dim - 1>(fe->degree + 1),
std::map<types::boundary_id, const Function<dim> *>(),
solution,
estimated_error_per_cell);
triangulation, estimated_error_per_cell, 0.3, 0.03);
triangulation.execute_coarsening_and_refinement();
break;
}
default:
{
}
}
}
template <int dim>
void HelmholtzProblem<dim>::process_solution(const unsigned int cycle)
{
Vector<float> difference_per_cell(triangulation.n_active_cells());
solution,
Solution<dim>(),
difference_per_cell,
QGauss<dim>(fe->degree + 1),
const double L2_error =
difference_per_cell,
solution,
Solution<dim>(),
difference_per_cell,
QGauss<dim>(fe->degree + 1),
const double H1_error =
difference_per_cell,
const QTrapezoid<1> q_trapez;
const QIterated<dim> q_iterated(q_trapez, fe->degree * 2 + 1);
solution,
Solution<dim>(),
difference_per_cell,
q_iterated,
const double Linfty_error =
difference_per_cell,
const unsigned int n_active_cells = triangulation.n_active_cells();
const unsigned int n_dofs = dof_handler.n_dofs();
std::cout << "Cycle " << cycle << ':' << std::endl
<< " Number of active cells: " << n_active_cells
<< std::endl
<< " Number of degrees of freedom: " << n_dofs << std::endl;
convergence_table.add_value("cycle", cycle);
convergence_table.add_value("cells", n_active_cells);
convergence_table.add_value("dofs", n_dofs);
convergence_table.add_value("L2", L2_error);
convergence_table.add_value("H1", H1_error);
convergence_table.add_value("Linfty", Linfty_error);
}
template <int dim>
void HelmholtzProblem<dim>::run()
{
const unsigned int n_cycles =
(refinement_mode == global_refinement) ? 5 : 9;
for (unsigned int cycle = 0; cycle < n_cycles; ++cycle)
{
if (cycle == 0)
{
triangulation.refine_global(3);
for (const auto &cell : triangulation.cell_iterators())
for (const auto &face : cell->face_iterators())
{
const auto center = face->center();
if ((std::fabs(center[0] - (-1.0)) < 1e-12) ||
(std::fabs(center[1] - (-1.0)) < 1e-12))
face->set_boundary_id(1);
}
}
else
refine_grid();
setup_system();
assemble_system();
solve();
process_solution(cycle);
}
std::string vtk_filename;
switch (refinement_mode)
{
case global_refinement:
vtk_filename = "solution-global";
break;
case adaptive_refinement:
vtk_filename = "solution-adaptive";
break;
default:
}
vtk_filename += "-q" + std::to_string(fe->degree);
vtk_filename += ".vtk";
std::ofstream output(vtk_filename);
DataOut<dim> data_out;
data_out.attach_dof_handler(dof_handler);
data_out.add_data_vector(solution, "solution");
data_out.build_patches(fe->degree);
data_out.write_vtk(output);
convergence_table.set_precision("L2", 3);
convergence_table.set_precision("H1", 3);
convergence_table.set_precision("Linfty", 3);
convergence_table.set_scientific("L2", true);
convergence_table.set_scientific("H1", true);
convergence_table.set_scientific("Linfty", true);
convergence_table.set_tex_caption("cells", "\\# cells");
convergence_table.set_tex_caption("dofs", "\\# dofs");
convergence_table.set_tex_caption("L2", "@f$L^2@f$-error");
convergence_table.set_tex_caption("H1", "@f$H^1@f$-error");
convergence_table.set_tex_caption("Linfty", "@f$L^\\infty@f$-error");
convergence_table.set_tex_format("cells", "r");
convergence_table.set_tex_format("dofs", "r");
std::cout << std::endl;
convergence_table.write_text(std::cout);
std::string error_filename = "error";
switch (refinement_mode)
{
case global_refinement:
error_filename += "-global";
break;
case adaptive_refinement:
error_filename += "-adaptive";
break;
default:
}
error_filename += "-q" + std::to_string(fe->degree);
error_filename += ".tex";
std::ofstream error_table_file(error_filename);
convergence_table.write_tex(error_table_file);
if (refinement_mode == global_refinement)
{
convergence_table.add_column_to_supercolumn("cycle", "n cells");
convergence_table.add_column_to_supercolumn("cells", "n cells");
std::vector<std::string> new_order;
new_order.emplace_back("n cells");
new_order.emplace_back("H1");
new_order.emplace_back("L2");
convergence_table.set_column_order(new_order);
convergence_table.evaluate_convergence_rates(
convergence_table.evaluate_convergence_rates(
convergence_table.evaluate_convergence_rates(
convergence_table.evaluate_convergence_rates(
std::cout << std::endl;
convergence_table.write_text(std::cout);
std::string conv_filename = "convergence";
switch (refinement_mode)
{
case global_refinement:
conv_filename += "-global";
break;
case adaptive_refinement:
conv_filename += "-adaptive";
break;
default:
}
conv_filename += "-q" + std::to_string(fe->degree);
conv_filename += ".tex";
std::ofstream table_file(conv_filename);
convergence_table.write_tex(table_file);
}
}
} // namespace Step7
int main()
{
const unsigned int dim = 2;
try
{
using namespace dealii;
using namespace Step7;
{
std::cout << "Solving with Q1 elements, adaptive refinement"
<< std::endl
<< "============================================="
<< std::endl
<< std::endl;
const FE_Q<dim> fe(1);
HelmholtzProblem<dim> helmholtz_problem_2d(
fe, HelmholtzProblem<dim>::adaptive_refinement);
helmholtz_problem_2d.run();
std::cout << std::endl;
}
{
std::cout << "Solving with Q1 elements, global refinement" << std::endl
<< "===========================================" << std::endl
<< std::endl;
const FE_Q<dim> fe(1);
HelmholtzProblem<dim> helmholtz_problem_2d(
fe, HelmholtzProblem<dim>::global_refinement);
helmholtz_problem_2d.run();
std::cout << std::endl;
}
{
std::cout << "Solving with Q2 elements, global refinement" << std::endl
<< "===========================================" << std::endl
<< std::endl;
const FE_Q<dim> fe(2);
HelmholtzProblem<dim> helmholtz_problem_2d(
fe, HelmholtzProblem<dim>::global_refinement);
helmholtz_problem_2d.run();
std::cout << std::endl;
}
{
std::cout << "Solving with Q2 elements, adaptive refinement"
<< std::endl
<< "===========================================" << std::endl
<< std::endl;
const FE_Q<dim> fe(2);
HelmholtzProblem<dim> helmholtz_problem_2d(
fe, HelmholtzProblem<dim>::adaptive_refinement);
helmholtz_problem_2d.run();
std::cout << std::endl;
}
}
catch (std::exception &exc)
{
std::cerr << std::endl
<< std::endl
<< "----------------------------------------------------"
<< std::endl;
std::cerr << "Exception on processing: " << std::endl
<< exc.what() << std::endl
<< "Aborting!" << std::endl
<< "----------------------------------------------------"
<< std::endl;
return 1;
}
catch (...)
{
std::cerr << std::endl
<< std::endl
<< "----------------------------------------------------"
<< std::endl;
std::cerr << "Unknown exception!" << std::endl
<< "Aborting!" << std::endl
<< "----------------------------------------------------"
<< std::endl;
return 1;
}
return 0;
}
void write_vtk(std::ostream &out) const
void add_data_vector(const VectorType &data, const std::vector< std::string > &names, const DataVectorType type=type_automatic, const std::vector< DataComponentInterpretation::DataComponentInterpretation > &data_component_interpretation={})
virtual void build_patches(const unsigned int n_subdivisions=0)
Definition data_out.cc:1062
constexpr numbers::NumberTraits< Number >::real_type norm_square() const
void cell_matrix(FullMatrix< double > &M, const FEValuesBase< dim > &fe, const FEValuesBase< dim > &fetest, const ArrayView< const std::vector< double > > &velocity, const double factor=1.)
Definition advection.h:74
void run(const Iterator &begin, const std_cxx20::type_identity_t< Iterator > &end, Worker worker, Copier copier, const ScratchData &sample_scratch_data, const CopyData &sample_copy_data, const unsigned int queue_length, const unsigned int chunk_size)
unsigned int n_active_cells(const internal::TriangulationImplementation::NumberCache< 1 > &c)
Definition tria.cc:14881
unsigned int boundary_id
Definition types.h:144