Introduction

A Laplace problem

As an introduction we will solve \begin{equation} -\triangle u + u = f \end{equation} in \(\Omega=[0,1]^2\), where \(f=f(x)\) is a given forcing term. On the boundary we prescribe Neumann boundary \(\nabla u\cdot n = 0\).

We will solve this problem in variational form \(a(u,v) = l(v)\) with \begin{equation} a(u,v) := \int_\Omega \nabla u\cdot\nabla v + uv~,\qquad l(v) := \int_\Omega fv~. \end{equation} We choose \(f=(8\pi^2+1)\cos(2\pi x_1)\cos(2\pi x_2)\) so that the exact solution is \begin{align*} u(x) = \cos(2\pi x_1)\cos(2\pi x_2) \end{align*}

We first need to setup a tessellation of \(\Omega\). We use a Cartesian grid with a 20 cells in each coordinate direction

[1]:
import numpy as np

from dune.grid import structuredGrid
gridView = structuredGrid([0, 0], [1, 1], [20, 20])

Note

In the following we will often use the term gridView instead of grid. The details of what a gridView is together with some other central concepts is provided in the next section.

Tip

an overview of available approaches to construct a grid can be found here.

Next we define a linear Lagrange Finite-Element space over that grid and setup a discrete function which we will store the discrete solution to our PDE

[2]:
from dune.fem.space import lagrange
space = lagrange(gridView, order=1)
u_h   = space.interpolate(0, name='u_h')

We define the mathematical problem using ufl

[3]:
from ufl import (TestFunction, TrialFunction, SpatialCoordinate,
                 dx, grad, inner, dot, sin, cos, pi )
x = SpatialCoordinate(space)
u = TrialFunction(space)
v = TestFunction(space)

f = (8*pi**2+1) * cos(2*pi*x[0])*cos(2*pi*x[1])
a = ( inner(grad(u),grad(v)) + u*v ) * dx
l = f*v * dx

Now we can assemble the matrix and the right hand side

[4]:
from dune.fem import assemble
mat,rhs = assemble(a==l)

We solve the resulting linear system of equations \(Ay=b\) using scipy. To this end it is straightforward to expose the underlying data structures of mat,rhs and u_h using the as_numpy attribute.

[5]:
from scipy.sparse.linalg import spsolve as solver

A = mat.as_numpy
b = rhs.as_numpy
y = u_h.as_numpy
y[:] = solver(A,b)

Note the y[:] which guarantees that the result from the solver is stored in the same buffer used for the discrete function. Consequently, no copying is required.

Tip

More details on how to use Scipy will be given later in the tutorial. Other linear solver backends are also available, e.g., PETSc and petsc4py.

That’s it - to see the result we plot it using matplotlib

[6]:
u_h.plot()
_images/dune-fempy_nb_13_0.jpg

Since in this case the exact solution is known, we can also compute the \(L^2\) and \(H^1\) errors to see how good our approximation actually is

[7]:
from dune.fem import integrate
exact = cos(2*pi*x[0])*cos(2*pi*x[1])
e_h = u_h-exact
squaredErrors = integrate([e_h**2,inner(grad(e_h),grad(e_h))])
print("L^2 and H^1 errors:",[np.sqrt(e) for e in squaredErrors])
L^2 and H^1 errors: [0.004816749930263432, 0.40259995183954644]

Note

The assemble function can be used to assemble bilinear forms as shown above, linear forms (functionals) as shown further down, and to compute scalar integrals. So we could compute the \(L^2\) error using assemble(e_h**2*dx). We use the integrate function above since it allows to integrate vector valued expressions which is more efficient than calling assemble multiple times. Using the assemble function with ‘a==l’ and ‘a-l==0’ produces the same result.

Tip

Both assemble and integrate take extra arguments gridView and order. The latter allows to fix the order of the quadrature used to integrate a function - if it is not provided (None) a reasonable order is determined heuristically. The gridView argument is required in cases where the gridView can not be determined from the ufl expression.

In the above example gridView can be extracted from the discrete function u_h which is part of the expression for e_h. Consider instead the following example where the gridView has to be provided to the integrate method

[8]:
print("average:",integrate(exact,gridView=gridView) )
# since the integrand is scalar, the following is equivalent:
print("average:",assemble(exact*dx,gridView=gridView) )
average: 1.5720931501039814e-17
average: 1.5720931501039814e-17

Laplace equation with Dirichlet boundary conditions

We consider the scalar boundary value problem \begin{align*} -\triangle u &= f & \text{in}\;\Omega:=(0,1)^2 \\ \nabla u\cdot n &= g_N & \text{on}\;\Gamma_N \\ u &= g_D & \text{on}\;\Gamma_D \end{align*} and \(f=f(x)\) is some forcing term. For the boundary conditions we set \(\Gamma_D=\{0\}\times[0,1]\) and take \(\Gamma_N\) to be the remaining boundary of \(\Omega\).

We will solve this problem in variational form \begin{align*} \int \nabla u \cdot \nabla \varphi \ - \int_{\Omega} f(x) \varphi\ dx - \int_{\Gamma_N} g_N(x) v\ ds = 0. \end{align*}

We choose \(f,g_N,g_D\) so that the exact solution is \begin{align*} u(x) = \left(\frac{1}{2}(x_1^2 + x_2^2) - \frac{1}{3}(x_1^3 - x_2^3)\right) + 1~. \end{align*}

Tip

More general boundary conditions are discussed in a later section.

The setup of the model using ufl is very similar to the previous example but we need to include the non trivial Neumann boundary conditions:

[9]:
from ufl import conditional, FacetNormal, ds, div

exact = 1/2*(x[0]**2+x[1]**2) - 1/3*(x[0]**3 - x[1]**3) + 1
a   = dot(grad(u), grad(v)) * dx
f   = -div( grad(exact) )
g_N = grad(exact)
n   = FacetNormal(space)
l   = f*v*dx + dot(g_N,n)*conditional(x[0]>=1e-8,1,0)*v*ds

With the model described as a ufl form, we can again assemble the system matrix and right hand side using dune.fem.assemble. To take the Dirichlet boundary conditions into account we construct an instance of dune.ufl.DirichletBC that described the values to use and the part of the boundary to apply them to. This is then passed into the assemble function:

[10]:
from dune.ufl import DirichletBC
dbc = DirichletBC(space,exact,x[0]<=1e-8)
mat,rhs = assemble([a==l,dbc])

Solving the linear system of equations, plotting the solution, and computing the error is now identical to the previous example:

[11]:
u_h.as_numpy[:] = solver(mat.as_numpy, rhs.as_numpy)
u_h.plot()
e_h = u_h-exact
squaredErrors = integrate([e_h**2,inner(grad(e_h),grad(e_h))])
print("L^2 and H^1 errors:",[np.sqrt(e) for e in squaredErrors])
_images/dune-fempy_nb_24_0.jpg
L^2 and H^1 errors: [0.000492922796816225, 0.031176023550870714]

It is straightforward to solve a problem with a different right hand side and different boundary values. Assuming the type of the boundary conditions remains the same, the system matrix does not change we only need to reassemble the right hand side:

[12]:
l   = conditional(dot(x,x)<0.1,1,0)*v*dx
dbc = DirichletBC(space,x[1]*(1-x[1]),x[0]<=1e-8)
rhs = assemble([l,dbc])
u_h.as_numpy[:] = solver(mat.as_numpy, rhs.as_numpy)
u_h.plot()
_images/dune-fempy_nb_26_0.jpg

A non-linear elliptic problem

It is very easy to solve a non-linear elliptic problem with very few changes to the above code. We will demonstrate this using the PDE \begin{equation} -\triangle u + m(u) = f \end{equation} in \(\Omega=[0,1]^2\), where again \(f=f(x)\) is a given forcing term and \(m=m(u)\) is some non-linearity. On the boundary we still prescribe Neumann boundary \(\nabla u\cdot n = 0\).

We will solve this problem in variational form \begin{equation} \int_\Omega \nabla u\cdot\nabla v + m(u)v = \int_\Omega fv~. \end{equation} We use \(f(x)=|x|^2\) as forcing and choose \(m(u) = (1+u)^2u\). Most of the code is identical to the linear case, we can use the same grid, discrete lagrange space, and the discrete function u_h. The model description using ufl is also very similar

[13]:
a = ( inner(grad(u),grad(v)) + (1+u)**2*u*v ) * dx
l = dot(x,x)*v * dx

To solve the non-linear problem we need to use something like a Newton solver. We could use the implementation available in Scipy but dune-fem provides so called schemes that have a solve method which can handle both linear and non-linear models. The default solver is based on a Newton-Krylov solver using a gmres method to solve the intermediate linear problems. These schemes are quite central to solving PDE is different ways, including writing your own linear or non-linear solvers, accessing degrees of freedom on the boundary etc. A detailed description on the API is given here and how to use the schemes in different context to solve the problems on the Python side is available here.

Since the problem here is symmetric we can use a cg method. A full list of available solvers, preconditioners, and how to customize them is available in the Alternative Linear Solvers section.

[14]:
from dune.fem.scheme import galerkin as solutionScheme
scheme = solutionScheme(a == l, solver='cg')
u_h.clear() # set u_h to zero as initial guess for the Newton solver
info = scheme.solve(target=u_h)

That’s it - we can plot the solution again - we don’t know the exact solution so we can’t compute any errors in this case. In addition the info structure returned by the solve method gives some information on the solver like number of iterations required

[15]:
print(info)
u_h.plot()
{'converged': True, 'iterations': 5, 'linear_iterations': 250, 'timing': [0.007532947, 0.0038442399999999996, 0.0036887070000000003]}
_images/dune-fempy_nb_32_1.jpg

Using Constant parameters and grid functions in PDEs

Every time we call assemble or construct a new scheme as show above, some code must be compiled which leads to some extra cost. In time critical points of the simulation, e.g., in loops, this extra cost is not acceptable. To avoid recompilation general grid functions and placeholders for scalar or vector valued constants can be used within the ufl forms. In the next section we will give a full example for this in the context of a time dependent problems.

As a simple introduction we consider a linear version of the elliptic problem considered previously

\[-\varepsilon \triangle u(x) + m(x) u(x) = f(x)\]

with Neuman boundary conditions. We also added a real valued constant \(\varepsilon\) and we want to able to change \(m\) easily. Assuming \(\bar{u}\) is the exact solution of the non-linear elliptic problem from above then \(\bar{u}\) will also solve the above equation if \(\varepsilon=1\) and we chose \(m(x) = (1+\bar{u})^2\). We will use the discrete solution \(u_h\) from above, which is an approximation to \(\bar{u}\) and chose \(m(x) = (1+u_h)^2\). Later we want to solve the same linear problem but with \(\varepsilon=0\) and \(m(x)=1\) so that we are simply looking at the \(L^2\) projection of \(f\):

\[\int_\Omega uv = \int_\Omega fv~.\]

Although the problem is linear and we could just use dune.fem.assemble and Scipy to solve it, we will again use a scheme instead. Details on schemes and operators will be provided in the following section. For this example the important characteristic of a scheme is that we can still access some information contained in the underlying UFL form, e.g., the Constants used. This allows us to change these efficiently during the simulation as shown below.

[16]:
from dune.ufl import Constant

epsilon = Constant(1,name="eps")   # start with epsilon=1
x,u,v   = SpatialCoordinate(space), TrialFunction(space), TestFunction(space)
f       = dot(x,x)
a       = epsilon*dot(grad(u), grad(v)) * dx + (1+u_h)**2*u*v * dx
b       = f*v*dx

scheme = solutionScheme(a == b, solver='cg')
w_h    = space.interpolate(0,name="w_h")
scheme.solve(target = w_h)
w_h.plot()

from dune.fem.operator import galerkin
op = galerkin(a == b)
_images/dune-fempy_nb_34_0.jpg

Note that since \(\varepsilon=1\) and we use the previously computed approximation \(u_h\) the new discrete function \(w_h\) is close to \(u_h\).

We can print the value of a Constant with name foo either via scheme.model.foo or using the value on the Constant itself. We can change the value using the same attribute. See the discussion at the end of section on operators and schemes in the concepts chapter for more detail on these two approaches.

[17]:
print(scheme.model.eps, epsilon.value)
epsilon.value = 0                          # change to the problem which matches our exact solution
print(scheme.model.eps, epsilon.value)
1.0 1.0
0.0 0.0

To switch to a standard \(L^2\) projection we will also change \(m=(1+u_h)^2\) to \(m\equiv 1\). This can be easily done by changing \(u_h\) to be zero everywhere. This can be either done using u_h.interpolate(0) or by using u_h.clear().

[18]:
u_h.interpolate(0)
scheme.solve(target = w_h)
w_h.plot()
_images/dune-fempy_nb_38_0.jpg

Tip

A wide range of problems a covered in the further examples section. In the next section we explain the main concepts we use to solve PDE using finite-element approximations which we end with a solution to a non-linear time-dependent problem using the Crank-Nicolson method in time.

This page was generated from the notebook dune-fempy_nb.ipynb and is part of the tutorial for the dune-fem python bindings DOI