The convergence order of H(div) conforming DG method for the NS equation

Hi everyone,

I’m trying to work on a dolfinx tutorial demo, which involves solving the Navier–Stokes equations using H(div) conforming DG finite elements.
I found the code in a PR and was able to run it successfully.
However, when I tried to compute the convergence order by refining the mesh, I noticed that the results do not seem to reach the expected order.
In my test, I used RT2-DGP1, n=4, 8,16 and dt = h^2.

e_u = 0.056454387321852796
e_div_u = 1.4440052725736236e-14
e_p = 0.05203947494663783

e_u = 0.015972656651637232
e_div_u = 2.475153702581696e-14
e_p = 0.021270857359452767

e_u = 0.0037373158636995734
e_div_u = 1.736254105137663e-13
e_p = 0.009487828176352775

I would like to know whether this is due to the upwinding operator, or if I may have made a mistake in my code.

# # Divergence conforming discontinuous Galerkin method for the Navier--Stokes equations
#
# ## Implementation
#
# We begin by importing the required modules and functions

# +
import numpy as np
from mpi4py import MPI
from petsc4py import PETSc
from ufl import (CellDiameter, FacetNormal, TestFunction, TrialFunction, avg,
                 conditional, div, dot, dS, ds, dx, grad, gt, inner, outer)
from dolfinx.fem.petsc import assemble_matrix_block, assemble_vector_block
from dolfinx import fem, io, mesh

if np.issubdtype(PETSc.ScalarType, np.complexfloating):
    print("Demo should only be executed with DOLFINx real mode")
    exit(0)
# -


# We also define some helper functions that will be used later


# +
def norm_L2(comm, v):
    """Compute the L2(Ω)-norm of v"""
    return np.sqrt(comm.allreduce(
        fem.assemble_scalar(fem.form(inner(v, v) * dx)), op=MPI.SUM))


def domain_average(msh, v):
    """Compute the average of a function over the domain"""
    vol = msh.comm.allreduce(
        fem.assemble_scalar(fem.form(fem.Constant(msh, 1.0) * dx)), op=MPI.SUM)
    return (1 / vol) * msh.comm.allreduce(fem.assemble_scalar(fem.form(v * dx)), op=MPI.SUM)


def u_e_expr(x):
    """Expression for the exact velocity solution to Kovasznay flow"""
    return np.vstack((1 - np.exp(
        (Re / 2 - np.sqrt(Re**2 / 4 + 4 * np.pi**2)) * x[0]) * np.cos(2 * np.pi * x[1]),
        (Re / 2 - np.sqrt(Re**2 / 4 + 4 * np.pi**2)) / (2 * np.pi) * np.exp(
            (Re / 2 - np.sqrt(Re**2 / 4 + 4 * np.pi**2)) * x[0])
        * np.sin(2 * np.pi * x[1])))


def p_e_expr(x):
    """Expression for the exact pressure solution to Kovasznay flow"""
    return (1 / 2) * (1 - np.exp(2 * (Re / 2 - np.sqrt(Re**2 / 4 + 4 * np.pi**2)) * x[0]))


def f_expr(x):
    """Expression for the applied force"""
    return np.vstack((np.zeros_like(x[0]),
                      np.zeros_like(x[0])))


def boundary_marker(x):
    return np.isclose(x[0], 0.0) | np.isclose(x[0], 1.0) | np.isclose(x[1], 0.0) | np.isclose(x[1], 1.0)
# -

# We define some simulation parameters


n = 16
num_time_steps = 25
t_end = 10
Re = 25  # Reynolds Number
k = 1  # Polynomial degree

# Next, we create a mesh and the required functions spaces over it.
# Since the velocity uses an $H(\textnormal{div})$-conforming function
# space, we also create a vector valued discontinuous Lagrange space to
# interpolate into for artifact free visualisation.

# +
msh = mesh.create_unit_square(MPI.COMM_WORLD, n, n)

# Function spaces for the velocity and for the pressure
V = fem.functionspace(msh, ("Raviart-Thomas", k + 1))
Q = fem.functionspace(msh, ("Discontinuous Lagrange", k))

# Funcion space for visualising the velocity field
gdim = msh.geometry.dim
W = fem.functionspace(msh, ("Discontinuous Lagrange", k + 1, (gdim,)))

# Define trial and test functions

u, v = TrialFunction(V), TestFunction(V)
p, q = TrialFunction(Q), TestFunction(Q)

delta_t = fem.Constant(msh, t_end / num_time_steps)
alpha = fem.Constant(msh, 6.0 * k**2)

h = CellDiameter(msh)
n = FacetNormal(msh)


def jump(phi, n):
    return outer(phi("+"), n("+")) + outer(phi("-"), n("-"))
# -

# We solve the Stokes problem for the initial condition, omitting the
# convective term:


# +
a_00 = (1 / Re) * (inner(grad(u), grad(v)) * dx
                   - inner(avg(grad(u)), jump(v, n)) * dS
                   - inner(jump(u, n), avg(grad(v))) * dS
                   + alpha / avg(h) * inner(jump(u, n), jump(v, n)) * dS
                   - inner(grad(u), outer(v, n)) * ds
                   - inner(outer(u, n), grad(v)) * ds
                   + alpha / h * inner(outer(u, n), outer(v, n)) * ds)
a_01 = - inner(p, div(v)) * dx
a_10 = - inner(div(u), q) * dx

a = fem.form([[a_00, a_01],
              [a_10, None]])

f = fem.Function(W)
u_D = fem.Function(V)
u_D.interpolate(u_e_expr)
L_0 = inner(f, v) * dx + (1 / Re) * (- inner(outer(u_D, n), grad(v)) * ds
                                     + (alpha / h) * inner(outer(u_D, n), outer(v, n)) * ds)
L_1 = inner(fem.Constant(msh, 0.0), q) * dx
L = fem.form([L_0, L_1])

# Boundary conditions
boundary_facets = mesh.locate_entities_boundary(msh, msh.topology.dim - 1, boundary_marker)
boundary_vel_dofs = fem.locate_dofs_topological(V, msh.topology.dim - 1, boundary_facets)
bc_u = fem.dirichletbc(u_D, boundary_vel_dofs)
bcs = [bc_u]

# Assemble Stokes problem
A = assemble_matrix_block(a, bcs=bcs)
A.assemble()
b = assemble_vector_block(L, a, bcs=bcs)

# Create and configure solver
ksp = PETSc.KSP().create(msh.comm)
ksp.setOperators(A)
ksp.setType("preonly")
ksp.getPC().setType("lu")
ksp.getPC().setFactorSolverType("mumps")
opts = PETSc.Options()
opts["mat_mumps_icntl_14"] = 80  # Increase MUMPS working memory
opts["mat_mumps_icntl_24"] = 1  # Option to support solving a singular matrix (pressure nullspace)
opts["mat_mumps_icntl_25"] = 0  # Option to support solving a singular matrix (pressure nullspace)
opts["ksp_error_if_not_converged"] = 1
ksp.setFromOptions()

# Solve Stokes for initial condition
x = A.createVecRight()
try:
    ksp.solve(b, x)
except PETSc.Error as e:
    if e.ierr == 92:
        print("The required PETSc solver/preconditioner is not available. Exiting.")
        print(e)
        exit(0)
    else:
        raise e


# Split the solution
u_h = fem.Function(V)
p_h = fem.Function(Q)
p_h.name = "p"
offset = V.dofmap.index_map.size_local * V.dofmap.index_map_bs
u_h.x.array[:offset] = x.array_r[:offset]
u_h.x.scatter_forward()
p_h.x.array[:(len(x.array_r) - offset)] = x.array_r[offset:]
p_h.x.scatter_forward()
# Subtract the average of the pressure since it is only determined
# up to a constant
p_h.x.array[:] -= domain_average(msh, p_h)

u_vis = fem.Function(W)
u_vis.name = "u"
u_vis.interpolate(u_h)

# Write initial condition to file
t = 0.0
try:
    u_file = io.VTXWriter(msh.comm, "u.bp", [u_vis._cpp_object])
    p_file = io.VTXWriter(msh.comm, "p.bp", [p_h._cpp_object])
    u_file.write(t)
    p_file.write(t)
except AttributeError:
    print("File output requires ADIOS2.")

# Create function to store solution and previous time step
u_n = fem.Function(V)
u_n.x.array[:] = u_h.x.array
# -

# Now we add the time stepping and convective terms

# +
lmbda = conditional(gt(dot(u_n, n), 0), 1, 0)
u_uw = lmbda("+") * u("+") + lmbda("-") * u("-")
a_00 += inner(u / delta_t, v) * dx - \
    inner(u, div(outer(v, u_n))) * dx + \
    inner((dot(u_n, n))("+") * u_uw, v("+")) * dS + \
    inner((dot(u_n, n))("-") * u_uw, v("-")) * dS + \
    inner(dot(u_n, n) * lmbda * u, v) * ds
a = fem.form([[a_00, a_01],
              [a_10, None]])

L_0 += inner(u_n / delta_t, v) * dx - inner(dot(u_n, n) * (1 - lmbda) * u_D, v) * ds
L = fem.form([L_0, L_1])

# Time stepping loop
for n in range(num_time_steps):
    t += delta_t.value

    A.zeroEntries()
    fem.petsc.assemble_matrix_block(A, a, bcs=bcs)  # type: ignore
    A.assemble()

    with b.localForm() as b_loc:
        b_loc.set(0)
    fem.petsc.assemble_vector_block(b, L, a, bcs=bcs)  # type: ignore

    # Compute solution
    ksp.solve(b, x)

    u_h.x.array[:offset] = x.array_r[:offset]
    u_h.x.scatter_forward()
    p_h.x.array[:(len(x.array_r) - offset)] = x.array_r[offset:]
    p_h.x.scatter_forward()
    p_h.x.array[:] -= domain_average(msh, p_h)

    u_vis.interpolate(u_h)

    # Write to file
    try:
        u_file.write(t)
        p_file.write(t)
    except NameError:
        pass

    # Update u_n
    u_n.x.array[:] = u_h.x.array

try:
    u_file.close()
    p_file.close()
except NameError:
    pass
# -

# Now we compare the computed solution to the exact solution

# +
# Function spaces for exact velocity and pressure
V_e = fem.functionspace(msh, ("Lagrange", k + 3, (gdim,)))
Q_e = fem.functionspace(msh, ("Lagrange", k + 2))

u_e = fem.Function(V_e)
u_e.interpolate(u_e_expr)

p_e = fem.Function(Q_e)
p_e.interpolate(p_e_expr)

# Compute errors
e_u = norm_L2(msh.comm, u_h - u_e)
e_div_u = norm_L2(msh.comm, div(u_h))

# This scheme conserves mass exactly, so check this
assert np.isclose(e_div_u, 0.0)
p_e_avg = domain_average(msh, p_e)
e_p = norm_L2(msh.comm, p_h - (p_e - p_e_avg))

if msh.comm.rank == 0:
    print(f"e_u = {e_u}")
    print(f"e_div_u = {e_div_u}")
    print(f"e_p = {e_p}")
# -

Taking a very brief look, it seems the code is using implicit Euler time-stepping. That is first order accurate. If you indeed use dt ~ h^2, then that would produce something second order accurate, which would explain your results. Could that be it?

First thing I’d do to improve this is to use Crank-Nicolson instead of implicit euler.

Thanks for your reply!

I also tried to use dt~h^3, but the result is same as dt~h^2.

e_u = 0.05646673312895648
e_div_u = 1.5480656489339727e-14
e_p = 0.05206181675499448

e_u = 0.015967937670534657
e_div_u = 2.700077358015221e-14
e_p = 0.021268861411334494

e_u = 0.003736460730359719
e_div_u = 9.161230970814136e-14
e_p = 0.009487903063637084

I will try to improve this code to a C-N scheme, but I’m worried that the upwind scheme will have an impact on the convergence order of the spatial error.

Hmm ok. Looking a bit more carefully, I’m now realizing you’re aiming to solve for a steady-state solution (Kovasznay flow). Then indeed the order of accuracy of the time-stepper will not impact the convergence order of the solution. In fact, the overly dissipative behavior of implicit Euler might help converge to steady state sooner (and certainly Crank-Nicolson will make things worse here, as it is more energy conservative).

Changing your T_end to 100, keeping your num_time_steps fixed to 50, putting your script in a function, and computing convergence rates gives the suboptimal 2nd order convergence (for n in range [8,16,32,64]):

e_u = 0.01596876968346004
e_u = 0.003738358404472318
Convergence rate: 2.094776369241913
e_u = 0.0008396195738329102
Convergence rate: 2.1545971846854535
e_u = 0.0001922147047322497
Convergence rate: 2.127017091385694

While, if I replace

V = fem.functionspace(msh, ("Raviart-Thomas", k + 1))
Q = fem.functionspace(msh, ("Discontinuous Lagrange", k))

by conventional Taylor-Hood elements

V = fem.functionspace(msh, ("CG", k + 1, (2,)))
Q = fem.functionspace(msh, ("CG", k))

the output shows very clean convergence:

e_u = 0.001211177136042391
e_u = 0.0001511092340371029
Convergence rate: 3.002746147230327
e_u = 1.8886004236410066e-05
Convergence rate: 3.0002024197339963
e_u = 2.3607067149579664e-06
Convergence rate: 3.000026776096103

So it has nothing to do with the timestepping, nor approaching steady state, but indeed something with the RT/DG pair or formulation.

To the best of my knowledge, upwind schemes in finite elements are not low-order. This is a misconception from finite difference and finite volume where they are low-order approximations to gradients. Indeed, in this paper, table II, order k+1 convergence in L2 is retrieved: A Note on Discontinuous Galerkin Divergence-free Solutions of the Navier–Stokes Equations | Journal of Scientific Computing

Oh wait, I believe you are observing the expected rate of convergence.

According to that paper, pressure L^2 converges order k and velocity L^2 order k+1. In their notation, the element pair is RT_k/DG_k. In FEniCS, that same pair is written RT_{k+1}/DG_k. So that means we expect order k+1 convergence in velocity, which the code is producing.

I agree it is a little disappointing… Getting 3 order of magnitude error drop by changing to Taylor-Hood elements.

But luckily, all is not lost. Change from V = fem.functionspace(msh, ("Raviart-Thomas", k + 1)) to V = fem.functionspace(msh, ("Brezzi-Douglas-Marini", k + 1)) and you’ll retrieve k+1 convergence in pressure and p+2 convergence in velocity. :partying_face:

5 Likes

First of all, thank you so much for your reply! I really appreciate you taking the time to think about my question.
You have completely addressed my question, it does seem that the BDM element is more suitable for this problem than the RT element.
Thanks again for your helpful answer!

1 Like

Hello!

Just thought I’d chime in here because I wrote that demo! Yes, as @Stein points out, the code is behaving as expected - the seemingly strange behaviour is partly because RT elements “sit between” complete polynomial spaces and partly because FEniCS uses an unconventional numbering of RT spaces.

On triangles, the lowest-order RT space is spanned by (see e.g. DefElement)

\begin{pmatrix} 1 \\ 0 \end{pmatrix}, \begin{pmatrix} 0 \\ 1 \end{pmatrix}, \text{ and} \begin{pmatrix} x \\ y \end{pmatrix}.

In the literature, this space is usually indexed 0, since it doesn’t contain all linear functions but does contain all constants. In FEniCS, this space is indexed 1, since the smallest complete polynomial space that contains it has degree 1.

The lowest-order BDM element is spanned by

\begin{pmatrix} 1 \\ 0 \end{pmatrix}, \begin{pmatrix} 0 \\ 1 \end{pmatrix}, \begin{pmatrix} x \\ 0 \end{pmatrix}, \begin{pmatrix} 0 \\ x \end{pmatrix}, \begin{pmatrix} y \\ 0 \end{pmatrix}, \text{ and} \begin{pmatrix} 0 \\ y \end{pmatrix}.

This is given the index 1 by both FEniCS and the literature, since it exactly contains all polynomials of degree 1.

Using the usual convention from the finite element literature, the k th RT space is richer than the k th BDM space. Whilst this seems like a bit of a waste, on general quadrilateral cells, these extra basis functions actually prevent RT elements from losing accuracy in L_2 (see this paper for more info).

Thanks!

3 Likes

Dear Joe, thanks a lot for your detailed explanation and for providing this demo!
Your explanation really helped my understanding.

Also, sorry for not adding a proper link to your code — I’m still new to the community and not very familiar with how to reference things correctly.

Thanks again!

1 Like

No probs! It’s just one of the standard demos in the main repository, so I wasn’t expecting a reference anyway - I just wanted to help confirm that that behaviour is indeed expected :slight_smile:

Cheers,

Joe

1 Like