Grids and derivatives#

In the first two chapters we saw how to deal with vectors and how to integrate our first ordinary differential equation. But how about partial differential equations? In order to define a partial differential equation we need an actual physical space in two dimensions say and define derivatives on it. However, there is such a multitude of possible ways to discretize the physical space and the derivatives on it that we cannot hope to cover them all in one library. We have chosen so-called discontinuous Galerkin methods. You never heard of those before? In simple words these methods use a polynomial of arbitrary order in each grid cell instead of just one single point as do finite difference methods. The order of the polynomial defines the order of the method. To help you get started we have written an introduction to dG methods.

Function evaluation and integration#

Let us look at a first example of what we can do with these methods. Let’s integrate a function:

#include <iostream>
//include the dg-library
#include "dg/algorithm.h"
//define a function to integrate
double function(double x, double y){
  return exp(x) * exp(y);
}

//create a discretization of [0,2]x[0,2] with
//3 polynomial coefficients and 20 cells in x and y
dg::CartesianGrid2d g2d( 0, 2, 0, 2, 3, 20, 20);
//discretize a function on this grid
const dg::DVec f = dg::evaluate( function, g2d);
//create the volume element
const dg::DVec vol2d = dg::create::volume( g2d);
//compute the integral on the device
double integral = dg::blas1::dot( vol2d, f);
// output approximates (exp(2)-exp(0))^2
std::cout << integral <<std::endl;
// outputs 40.82

In this program we encounter a new class, the dg::CartesianGrid2d and two new functions, dg::evaluate and dg::create::volume. The grid class represents the topology (what point is neighbor to what other point) and the geometry (the metric) that we use. In this case it is a two-dimensional Cartesian geometry, that is, the metric tensor is the unit tensor. Furthermore, it is a structured product space grid, which means that the topology is given implicitly by the grid coordinates. In the above example there are 60x60=3600 grid points in total. This number comes from the 20 cells that we use in both x and y direction and the 3 polynomial coefficients (that we use for both directions).

Choose polynomials

Per default the number of polynomials is the same in both x and y direction. If you want the freedom to choose each individually you could use

dg::CartesianGrid2d g2d( {0, 2, 3, 20}, {0, 2, 5, 18});

Now we have a grid with 5 coefficients and 18 cells in the y direction. This highlights that the two-dimensional grid is made as a product of 2 one-dimensional grids.

The dg::evaluate function is a template function that takes a binary function or Functor as a first parameter. In our example we aptly named the function function and it depends on the x and y coordinate and returns the result. The dg::evaluate function now takes our Cartesian grid, constructs the grid coordinates from it and inserts the coordinate into the function, one by one. The result is a vector containing the values of function for all the grid coordinates. We effectively discretized our function on the grid.

Evaluation direction, do you really need it?

If you think of the computational space as a box the first point in the result vector corresponds to the lower left corner. The next one is the point to the right, that is the x-direction is contiguous in memory. However, if you find that you need this information, stop! You should not need it. Use dg::interpolate or dg::create::interpolation if you want to access individual points and the dg::blas functions to operate on them.

The last function is the dg::create::volume function. This function takes our Cartesian grid and computes the volume form from the metric and also takes the Gaussian weights (from our discontinuous Galerkin methods) into account. The volume form is the dxdy in an integration of Int f(x,y) dxdy.

Finally, we use the familiar dg::blas1::dot function to compute the scalar product between the function and the volume form and actually get a very good approximation of the analytical result.

If we want to use MPI we have to change our code a little bit. When we use MPI we have to distribute the vector f among all processes in the communicator. We do this in the most direct way, namely simply allocating an equally sized portion of the grid to every process in the communicator. Then, each process can evaluate function only on the portion of the grid that it was assigned to. The result looks like this:

#include <iostream>
//activate MPI in FELTOR
#include "mpi.h"
#include "dg/algorithm.h"

double function(double x, double y){
    return exp(x)* exp(y);
}
int main(int argc, char* argv[])
{
    //init MPI and create a 2d Cartesian Communicator assuming 4 MPI threads
    MPI_Init( &argc, &argv);
    int periods[2] = {true, true}, np[2] = {2,2};
    MPI_Comm comm;
    MPI_Cart_create( MPI_COMM_WORLD, 2, np, periods, true, &comm);
    //create a 2d discretization of [0,2]x[0,2] with
    // 3 polynomial coefficients and 20 cells in x and y.
    // Each process gets 10 by 10 cells
    dg::CartesianMPIGrid2d g2d( 0, 2, 0, 2, 3, 20, 20, comm);
    //discretize a function on this grid
    const dg::MDVec f = dg::evaluate( function, g2d);
    //create the volume element
    const dg::MDVec vol2d = dg::create::volume( g2d);
    //compute the square L2 norm
    double norm = dg::blas1::dot( vol2d, f);
    //on every thread norm is now: (exp(2)-exp(0))^2
    //be a good MPI citizen and clean up
    MPI_Finalize();
    return 0;
}

Derivatives#

The next step after evaluating functions is to compute derivatives of course. Consider this example code, which computes the Arakawa bracket \(\{ f , g\} = \partial_x f \partial_y g - \partial_y f \partial_x g\)

 //define some test functions
double left( double x, double y) {
  return sin(x) * cos(y);
}
double right( double x, double y) {
  return sin(y) * cos(x);
}
// The analytical solution
double jacobian( double x, double y) {
  return cos(x) * cos(y) * cos(x) * cos(y) - sin(x) * sin(y) * sin(x) * sin(y);
}
double lx = 2.*M_PI, ly = lx;
unsigned n = 3, Nx = 40, Ny = 40;
//boundary conditions
dg::bc bcx = dg::PER, bcy = dg::PER;

//create a grid with Cartesian geometry
const dg::CartesianGrid2d grid( 0, lx, 0, ly, n, Nx, Ny, bcx, bcy);
//evaluate functions on the grid coordinates
const dg::DVec lhs = dg::evaluate( left, grid);
const dg::DVec rhs = dg::evaluate( right, grid);
const dg::DVec sol = dg::evaluate( jacobian, grid);


//allocate workspace
dg::DVec dxlhs(lhs), dxrhs(lhs), dylhs(lhs), dyrhs(lhs), jac(lhs);
//create the derivatives
dg::DMatrix dx = dg::create::dx( grid), dy = dg::create::dy(grid);
//apply the derivative to the functions
dg::blas2::symv( dx, lhs, dxlhs);
dg::blas2::symv( dy, lhs, dylhs);
dg::blas2::symv( dx, rhs, dxrhs);
dg::blas2::symv( dy, rhs, dyrhs);
//combine the results
dg::blas1::pointwiseDot( 1./3., dxlhs, dyrhs, -1./3., dylhs, dxrhs, 0., jac);
dg::blas1::pointwiseDot( 1./3.,   lhs, dyrhs, -1./3., dylhs,   rhs, 0., dylhs);
dg::blas1::pointwiseDot( 1./3., dxlhs,   rhs, -1./3.,   lhs, dxrhs, 0., dxrhs);
//add the remaining derivatives
dg::blas2::symv( 1., dx, dylhs, 1., jac);
dg::blas2::symv( 1., dy, dxrhs, 1., jac);

//create the volume form
dg::DVec vol = dg::create::volume(grid);
//test conservative property
double integral = dg::blas1::dot( vol, jac);
std::cout << "Integrated Jacobian is "<<integral<<"\n";
// should output 0 or something close to 1e-16

//now compute the distance to solution in L2 norm
dg::blas1::axpby( 1., sol, -1., jac);
double error = sqrt(dg::blas2::dot( jac, vol, jac));
std::cout << "Distance to solution "<<error<<"\n";
// Distance to solution 0.000754106

Here, we encounter the dg::create::dx and dg::create::dy functions that, as the names suggest, create derivatives in x and y direction respectively. The only parameter is the grid. Per default the boundary condition for the derivative is periodic and we use a centered discretization. The type of the matrix is a dg::DMatrix which is a typedef for an obscure dg-specific data type that holds a block-strcutured sparse matrix. The important thing is that a dg::DMatrix can be used together with a dg::DVec in the dg::blas2::symv functions. These are level 1 functions that do nothing but applying a given matrix to a vector and storing the result in another vector. Just as the dg::blas1 functions the dg::blas2 functions are templates that work for a variety of data types. In an MPI implementation we would use a dg::MDMatrix.

A note on boundary conditions#

The matrices in the ‘dg’ library only know homogeneous boundary conditions. For example when choosing dg::DIR as the boundary condition in dg::create::dx the assumed boundary value is zero and when choosing dg::NEU the assumed derivative on the boundary is also zero. This has the advantage that our matrices are always linear. However, how do I implement other boundary conditions then, you ask. For example when the boundary value of my function is 1 and not 0?

The answer is that you’ll have to manually subtract the value from the function before you apply the derivative. For example if we assume that ‘lhs’ in the previous example has non-homogeneous boundary conditions we would do something like

double function_with_dir_boundary( double x, double y){
    return 1.+sin(x)*sin(y);
}
dg::DVec fun = dg::evaluate( function_with_dir_boundary, grid), der(fun);
dg::blas1::plus( fun, -1.); // subtract boundary value before deriving
dg::blas2::symv( dx, fun, der);

This procedure can be done similarly for Neumann boundary conditions, where you’ll have to construct a function with your boundary conditions and subtract it before applying the derivative.