Divergence conforming discontinuous Galerkin method for the Navier–Stokes equations
This demo (demo_navier-stokes.py
) illustrates how to
implement a divergence conforming discontinuous Galerkin method for
the Navier-Stokes equations in FEniCSx. The method conserves mass
exactly and uses upwinding. The formulation is based on a combination
of “A fully divergence-free finite element method for
magnetohydrodynamic equations” by Hiptmair et al., “A Note on
Discontinuous Galerkin Divergence-free Solutions of the Navier-Stokes
Equations” by Cockburn et al, and “On the Divergence Constraint in
Mixed Finite Element Methods for Incompressible Flows” by John et al.
Governing equations
We consider the incompressible Navier-Stokes equations in a domain \(\Omega \subset \mathbb{R}^d\), \(d \in \{2, 3\}\), and time interval \((0, \infty)\), given by
where \(u: \Omega_t \to \mathbb{R}^d\) is the velocity field, \(p: \Omega_t \to \mathbb{R}\) is the pressure field, \(f: \Omega_t \to \mathbb{R}^d\) is a prescribed force, \(\nu \in \mathbb{R}^+\) is the kinematic viscosity, and \(\Omega_t := \Omega \times (0, \infty)\).
The problem is supplemented with the initial condition
and boundary condition
where \(u_0: \Omega \to \mathbb{R}^d\) is a prescribed initial velocity field which satisfies the divergence free condition. The pressure field is only determined up to a constant, so we seek the unique pressure field satisfying
Discrete problem
We begin by introducing the function spaces
The local spaces \(V_h(K)\) and \(Q_h(K)\) should satisfy
in order for mass to be conserved exactly. Suitable choices on affine simplex cells include
or
Let two cells \(K^+\) and \(K^-\) share a facet \(F\). The trace of a piecewise smooth vector valued function \(\phi\) on F taken approaching from inside \(K^+\) (resp. \(K^-\)) is denoted \(\phi^{+}\) (resp. \(\phi^-\)). We now introduce the average \(\renewcommand{\avg}[1]{\left\{\!\!\left\{#1\right\}\!\!\right\}}\)
and jump \(\renewcommand{\jump}[1]{[\![ #1 ]\!]}\)
operators, where \(n\) denotes the outward unit normal to \(\partial K\). Finally, let the upwind flux of \(\phi\) with respect to a vector field \(\psi\) be defined as
where \(\Gamma^\psi = \left\{x \in \Gamma; \; \psi(x) \cdot n(x) < 0\right\}\).
The semi-discrete version problem (in dimensionless form) is: find \((u_h, p_h) \in V_h^{u_D} \times Q_h\) such that
where \(\renewcommand{\sumK}[0]{\sum_{K \in \mathcal{T}_h}}\) \(\renewcommand{\sumF}[0]{\sum_{F \in \mathcal{F}_h}}\)
Implementation
We begin by importing the required modules and functions
import importlib.util
if importlib.util.find_spec("petsc4py") is not None:
import dolfinx
if not dolfinx.has_petsc:
print("This demo requires DOLFINx to be compiled with PETSc enabled.")
exit(0)
else:
print("This demo requires petsc4py.")
exit(0)
from mpi4py import MPI
import numpy as np
import ufl
from dolfinx import default_real_type, fem, io, mesh
from dolfinx.fem.petsc import apply_lifting, assemble_matrix, assemble_vector, set_bc
try:
from petsc4py import PETSc
import dolfinx
if not dolfinx.has_petsc:
print("This demo requires DOLFINx to be compiled with PETSc enabled.")
exit(0)
except ModuleNotFoundError:
print("This demo requires petsc4py.")
exit(0)
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(ufl.inner(v, v) * ufl.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, default_real_type(1.0)) * ufl.dx)),
op=MPI.SUM,
)
return (1 / vol) * msh.comm.allreduce(fem.assemble_scalar(fem.form(v * ufl.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])))
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(\text{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))
VQ = ufl.MixedFunctionSpace(V, Q)
# Function 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, p = ufl.TrialFunctions(VQ)
v, q = ufl.TestFunctions(VQ)
delta_t = fem.Constant(msh, default_real_type(t_end / num_time_steps))
alpha = fem.Constant(msh, default_real_type(6.0 * k**2))
h = ufl.CellDiameter(msh)
n = ufl.FacetNormal(msh)
def jump(phi, n):
return ufl.outer(phi("+"), n("+")) + ufl.outer(phi("-"), n("-"))
We solve the Stokes problem for the initial condition, omitting the convective term:
a = (1.0 / Re) * (
ufl.inner(ufl.grad(u), ufl.grad(v)) * ufl.dx
- ufl.inner(ufl.avg(ufl.grad(u)), jump(v, n)) * ufl.dS
- ufl.inner(jump(u, n), ufl.avg(ufl.grad(v))) * ufl.dS
+ (alpha / ufl.avg(h)) * ufl.inner(jump(u, n), jump(v, n)) * ufl.dS
- ufl.inner(ufl.grad(u), ufl.outer(v, n)) * ufl.ds
- ufl.inner(ufl.outer(u, n), ufl.grad(v)) * ufl.ds
+ (alpha / h) * ufl.inner(ufl.outer(u, n), ufl.outer(v, n)) * ufl.ds
)
a -= ufl.inner(p, ufl.div(v)) * ufl.dx
a -= ufl.inner(ufl.div(u), q) * ufl.dx
a_blocked = fem.form(ufl.extract_blocks(a))
f = fem.Function(W)
u_D = fem.Function(V)
u_D.interpolate(u_e_expr)
L = ufl.inner(f, v) * ufl.dx + (1 / Re) * (
-ufl.inner(ufl.outer(u_D, n), ufl.grad(v)) * ufl.ds
+ (alpha / h) * ufl.inner(ufl.outer(u_D, n), ufl.outer(v, n)) * ufl.ds
)
L += ufl.inner(fem.Constant(msh, default_real_type(0.0)), q) * ufl.dx
L_blocked = fem.form(ufl.extract_blocks(L))
# Boundary conditions
boundary_facets = mesh.exterior_facet_indices(msh.topology)
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(a_blocked, bcs=bcs)
A.assemble()
b = assemble_vector(L_blocked, kind=PETSc.Vec.Type.MPI)
bcs1 = fem.bcs_by_block(fem.extract_function_spaces(a_blocked, 1), bcs)
apply_lifting(b, a_blocked, bcs=bcs1)
b.ghostUpdate(addv=PETSc.InsertMode.ADD, mode=PETSc.ScatterMode.REVERSE)
bcs0 = fem.bcs_by_block(fem.extract_function_spaces(L_blocked), bcs)
set_bc(b, bcs0)
# 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() # type: ignore
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: # type: ignore
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)
p_file = io.VTXWriter(msh.comm, "p.bp", p_h)
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 = ufl.conditional(ufl.gt(ufl.dot(u_n, n), 0), 1, 0)
u_uw = lmbda("+") * u("+") + lmbda("-") * u("-")
a += (
ufl.inner(u / delta_t, v) * ufl.dx
- ufl.inner(u, ufl.div(ufl.outer(v, u_n))) * ufl.dx
+ ufl.inner((ufl.dot(u_n, n))("+") * u_uw, v("+")) * ufl.dS
+ ufl.inner((ufl.dot(u_n, n))("-") * u_uw, v("-")) * ufl.dS
+ ufl.inner(ufl.dot(u_n, n) * lmbda * u, v) * ufl.ds
)
a_blocked = fem.form(ufl.extract_blocks(a))
L += (
ufl.inner(u_n / delta_t, v) * ufl.dx
- ufl.inner(ufl.dot(u_n, n) * (1 - lmbda) * u_D, v) * ufl.ds
)
L_blocked = fem.form(ufl.extract_blocks(L))
# Time stepping loop
bcs1 = fem.bcs_by_block(fem.extract_function_spaces(a_blocked, 1), bcs)
for n in range(num_time_steps):
t += delta_t.value
A.zeroEntries()
fem.petsc.assemble_matrix(A, a_blocked, bcs=bcs) # type: ignore
A.assemble()
with b.localForm() as b_loc:
b_loc.set(0)
assemble_vector(b, L_blocked)
apply_lifting(b, a_blocked, bcs=bcs1)
b.ghostUpdate(addv=PETSc.InsertMode.ADD, mode=PETSc.ScatterMode.REVERSE)
set_bc(b, bcs0)
# 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, ufl.div(u_h))
# This scheme conserves mass exactly, so check this
assert np.isclose(e_div_u, 0.0, atol=float(1.0e5 * np.finfo(default_real_type).eps))
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}")