Divergence Issue in Solving Incompressible Navier-Stokes with Linear Approximation of Convection Term in FEniCSx

Hello everyone,

I am working on solving the incompressible Navier-Stokes equations for the benchmark problem of flow around a cylinder using monolithic mixed finite elements in FEniCSx. Previously, I successfully implemented both a nonlinear solver using Newton’s method and a linear solver (using an approximation of the convection term) in FEniCS, and everything worked perfectly.

After transitioning to FEniCSx, I first re-implemented the nonlinear solver, which works well. However, I am now trying to solve the problem as a linear one by approximating the nonlinear convection term. The issue I’m facing is that while the code converges for a specific mesh and function space degree, it starts to diverge (producing inf values) when I refine the mesh or change the degree of the function spaces.

I am unsure of what might be causing this instability. Has anyone encountered similar issues or could you guide how to stabilize the solver with mesh refinement or function space degree changes?

Here is the code I’m using for reference:

import gmsh
import os
import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl
import time
import tqdm.autonotebook

from mpi4py import MPI


from petsc4py import PETSc

from basix.ufl import element

from dolfinx.cpp.mesh import to_type, cell_entity_type
from dolfinx.fem import (Constant, Function, functionspace,
                         assemble_scalar, dirichletbc, form, locate_dofs_topological, set_bc)
from dolfinx.fem.petsc import (apply_lifting, assemble_matrix, assemble_vector,create_vector,
                               create_matrix, set_bc, NonlinearProblem)
from dolfinx.graph import adjacencylist
from dolfinx.geometry import bb_tree, compute_collisions_points, compute_colliding_cells
from dolfinx.io import (VTXWriter, distribute_entity_data, gmshio, XDMFFile)
from dolfinx.mesh import (create_mesh, meshtags_from_entities, CellType, compute_midpoints, 
                           create_unit_cube, create_unit_square, meshtags, locate_entities_boundary)

from dolfinx.nls.petsc import NewtonSolver

from ufl import (FacetNormal, Identity, Measure, TestFunction, TrialFunction, TestFunctions, TrialFunctions,
                 as_vector, div, dot, ds, dx, inner, lhs, grad, nabla_grad, rhs, sym, system, split)

import ufl
import pyvista
import basix
import dolfinx.plot as plot
from dolfinx import io, nls, log

from dolfinx.cpp.log import set_log_level, LogLevel
#set_log_level(LogLevel.INFO)

#              ********* Gmsh part *********

gmsh.initialize()
L = 2.2
H = 0.41
c_x = c_y = 0.2
r = 0.05
gdim = 2
mesh_comm = MPI.COMM_WORLD
model_rank = 0
if mesh_comm.rank == model_rank:
    rectangle = gmsh.model.occ.addRectangle(0, 0, 0, L, H, tag=1)
    obstacle = gmsh.model.occ.addDisk(c_x, c_y, 0, r, r)
"""    
The next step is to subtract the obstacle from the channel, such that we do not mesh the interior of the circle.
"""
if mesh_comm.rank == model_rank:
    fluid = gmsh.model.occ.cut([(gdim, rectangle)], [(gdim, obstacle)])
    gmsh.model.occ.synchronize()
"""    
To get GMSH to mesh the fluid, we add a physical volume marker
"""

fluid_marker = 1
if mesh_comm.rank == model_rank:
    volumes = gmsh.model.getEntities(dim=gdim)
    assert (len(volumes) == 1)
    gmsh.model.addPhysicalGroup(volumes[0][0], [volumes[0][1]], fluid_marker)
    gmsh.model.setPhysicalName(volumes[0][0], fluid_marker, "Fluid")

"""
To tag the different surfaces of the mesh, we tag the inflow (left hand side) with marker 2, the outflow (right hand side) with marker 3 and the fluid walls with 4 and obstacle with 5. We will do this by computing the center of mass for each geometrical entity.
"""

inlet_marker, outlet_marker, wall_marker, obstacle_marker = 2, 3, 4, 5
inflow, outflow, walls, obstacle = [], [], [], []
if mesh_comm.rank == model_rank:
    boundaries = gmsh.model.getBoundary(volumes, oriented=False)
    for boundary in boundaries:
        center_of_mass = gmsh.model.occ.getCenterOfMass(boundary[0], boundary[1])
        if np.allclose(center_of_mass, [0, H / 2, 0]):
            inflow.append(boundary[1])
        elif np.allclose(center_of_mass, [L, H / 2, 0]):
            outflow.append(boundary[1])
        elif np.allclose(center_of_mass, [L / 2, H, 0]) or np.allclose(center_of_mass, [L / 2, 0, 0]):
            walls.append(boundary[1])
        else:
            obstacle.append(boundary[1])
    gmsh.model.addPhysicalGroup(1, walls, wall_marker)
    gmsh.model.setPhysicalName(1, wall_marker, "Walls")
    gmsh.model.addPhysicalGroup(1, inflow, inlet_marker)
    gmsh.model.setPhysicalName(1, inlet_marker, "Inlet")
    gmsh.model.addPhysicalGroup(1, outflow, outlet_marker)
    gmsh.model.setPhysicalName(1, outlet_marker, "Outlet")
    gmsh.model.addPhysicalGroup(1, obstacle, obstacle_marker)
    gmsh.model.setPhysicalName(1, obstacle_marker, "Obstacle")
    
"""    
In our previous meshes, we have used uniform mesh sizes. In this example, we will have variable mesh sizes to resolve the flow solution in the area of interest; close to the circular obstacle. To do this, we use GMSH Fields.
"""
# Create distance field from obstacle.
# Add threshold of mesh sizes based on the distance field
# LcMax -                  /--------
#                      /
# LcMin -o---------/
#        |         |       |
#       Point    DistMin DistMax
res_min = r / 7
if mesh_comm.rank == model_rank:
    distance_field = gmsh.model.mesh.field.add("Distance")
    gmsh.model.mesh.field.setNumbers(distance_field, "EdgesList", obstacle)
    threshold_field = gmsh.model.mesh.field.add("Threshold")
    gmsh.model.mesh.field.setNumber(threshold_field, "IField", distance_field)
    gmsh.model.mesh.field.setNumber(threshold_field, "LcMin", res_min)
    gmsh.model.mesh.field.setNumber(threshold_field, "LcMax", 0.15 * H)
    gmsh.model.mesh.field.setNumber(threshold_field, "DistMin", r)
    gmsh.model.mesh.field.setNumber(threshold_field, "DistMax", 2.0 * H)
    min_field = gmsh.model.mesh.field.add("Min")
    gmsh.model.mesh.field.setNumbers(min_field, "FieldsList", [threshold_field])
    gmsh.model.mesh.field.setAsBackgroundMesh(min_field)

"""    
Generating the mesh

We are now ready to generate the mesh. However, we have to decide if our mesh should consist of triangles or quadrilaterals. In this demo, to match the DFG 2D-3 benchmark, we use second order quadrilateral elements.
"""

if mesh_comm.rank == model_rank:
    gmsh.option.setNumber("Mesh.Algorithm", 5)
    #gmsh.option.setNumber("Mesh.RecombinationAlgorithm", 2)   #
    #gmsh.option.setNumber("Mesh.RecombineAll", 1)             #  Uncomment if you want a quadrilateral mesh
    #gmsh.option.setNumber("Mesh.SubdivisionAlgorithm", 1)     #
    gmsh.model.mesh.generate(gdim)
    gmsh.model.mesh.setOrder(2)
    gmsh.model.mesh.optimize("Netgen")
    
#
#              ********* end of the Gmsh part *********
#

mesh, _, ft = gmshio.model_to_mesh(gmsh.model, mesh_comm, model_rank, gdim=gdim)
ft.name = "Facet markers"


t = 0.0
T = 0.1                      # Final time
dt = 1 / 100                 # Time step size
num_steps = int(T / dt)
k = Constant(mesh, PETSc.ScalarType(dt))
nu = Constant(mesh, PETSc.ScalarType(0.001))  # Dynamic viscosity
rho = Constant(mesh, PETSc.ScalarType(1))     # Density

n = FacetNormal(mesh)  # Normal pointing out of obstacle


Ve = element("CG", mesh.topology.cell_name(), 2, shape=(mesh.topology.dim, ))
Pe = element("CG", mesh.topology.cell_name(), 1)

We = basix.ufl.mixed_element([Ve,Pe])


W = functionspace(mesh, We)

V_, _ = W.sub(0).collapse()
Q_, _ = W.sub(1).collapse()


w00 = Function(W)
nx = len(w00.x.array)
print('DoFs on this level:', nx) 

#-------------------------------
# Define the inlet velocity 
#------------------------------
#
def InletVelocity(t):
    g = Function(V_)
    g.interpolate(
         lambda x : np.vstack((
                4 * 1.5 * np.sin(t * np.pi / 8) * x[1] * (0.41 - x[1]) / (0.41**2), 
                np.full(x.shape[1], 0.0, dtype=np.float64))))
    return g   
#
# --------------------------------
#  Define the boundary conditions
#---------------------------------
u_in = Function(V_)
u_zero = Function(V_)
p_zero = Function(Q_)
#
u_in.interpolate(InletVelocity(t))       
u_zero.interpolate(lambda x : np.vstack((np.full(x.shape[1], 0.0, dtype=np.float64), np.full(x.shape[1], 0.0, dtype=np.float64))))
p_zero.interpolate(lambda x : np.full(x.shape[1], 0.0, dtype=np.float64))
#
fdim = mesh.topology.dim - 1
#
inlet  = locate_entities_boundary(mesh, fdim, lambda x: np.isclose(x[0], 0.0))
outlet = locate_entities_boundary(mesh, fdim, lambda x: np.isclose(x[0], 2.2))
walls  = locate_entities_boundary(mesh, fdim, lambda x: np.isclose(x[1], 0.0) | np.isclose(x[1], 0.41))
cylin  = locate_entities_boundary(mesh, fdim, lambda x: np.isclose((x[0]-0.2)**2 + (x[1]-0.2)**2, 0.05**2))     
#
inlet_bc  = dirichletbc( u_in,   locate_dofs_topological((W.sub(0), V_), fdim, inlet),  W.sub(0))
Walls_bc  = dirichletbc( u_zero, locate_dofs_topological((W.sub(0), V_), fdim, walls),  W.sub(0))
cylin_bc  = dirichletbc( u_zero, locate_dofs_topological((W.sub(0), V_), fdim, cylin),  W.sub(0))
outlet_bc = dirichletbc( p_zero, locate_dofs_topological((W.sub(1), Q_), fdim, outlet), W.sub(1))
#
bcu = [inlet_bc, Walls_bc, cylin_bc, outlet_bc]
#
#----------------------------------
# Define trial and test functions 
#----------------------------------
#
u, p = TrialFunctions(W)
v, q = TestFunctions(W)
#
w = Function(W)
#
w_old = Function(W)
u_old, p_old = split(w_old)

#
# Weak formulation
#
F  = ufl.dot((u - u_old)/k, v)*ufl.dx 
F += ufl.dot(ufl.dot(u_old, ufl.nabla_grad(u)), v) * ufl.dx 
F += ufl.dot(ufl.dot(u, ufl.nabla_grad(u_old)), v) * ufl.dx 
F -= ufl.dot( ufl.dot(u_old, ufl.nabla_grad(u_old)), v) * ufl.dx
F += ufl.inner(nu*ufl.nabla_grad(u), ufl.nabla_grad(v))*ufl.dx
F -= ufl.dot(p,ufl.div(v))*ufl.dx 
F -= ufl.dot(ufl.div(u),q)*ufl.dx 
        
a = form(lhs(F))
L = form(rhs(F))
A = create_matrix(a)
b = create_vector(L)

# set the solver
solver = PETSc.KSP().create(mesh.comm)
solver.setType(PETSc.KSP.Type.PREONLY)
solver.getPC().setType(PETSc.PC.Type.LU)

    
cells, types, x = plot.vtk_mesh(V_)
gridu = pyvista.UnstructuredGrid(cells, types, x)

# Create plotter
plotter = pyvista.Plotter() 
plotter.show(interactive_update=True)
    
from pathlib import Path
folder = Path("results")
folder.mkdir(exist_ok=True, parents=True)
#
progress = tqdm.autonotebook.tqdm(desc="Solving PDE", total=num_steps)
#
for i in range(num_steps):
    progress.update(1)
    # Update current time step
    t += dt
    #
    # Update inlet velocity
    u_in.interpolate(InletVelocity(t))
    # 
    # solve the system
    A.zeroEntries()
    assemble_matrix(A, a, bcs=bcu)
    A.assemble()
    with b.localForm() as loc:
        loc.set(0)
    assemble_vector(b, L)
    #
    apply_lifting(b, [a], [bcu])    
    b.ghostUpdate(addv=PETSc.InsertMode.ADD_VALUES, mode=PETSc.ScatterMode.REVERSE)
    #
    set_bc(b, bcu)
    solver.setOperators(A)
    solver.solve(b, w.vector)
    w.x.scatter_forward()
    
    # Update variable with solution form this time step
    with w.vector.localForm() as loc_w, w_old.vector.localForm() as loc_w_old:
        loc_w.copy(loc_w_old)
    #
    # Plot the solution
    uw, pw = w_old.split()
    u_ = uw.collapse()
    p_ = pw.collapse()    
    #
    plotter.clear()
    #
    values = np.zeros((x.shape[0], 3), dtype=np.float64)
    values[:, :len(u_)] = u_.x.array.real.reshape((x.shape[0], len(u_)))
    gridu["u"] = values
    glyphs = gridu.glyph(orient="u", factor=0.08)
    #c    
    plotter.add_mesh(glyphs, cmap='jet')
    plotter.add_bounding_box(line_width=0.5, color='black')
    plotter.update()
    plotter.view_xy()
    
plotter.show()

Any advice would be greatly appreciated! Thanks in advance.

Best regards,
Abdelouahed

Hi Abdelouahed,

Could you elaborate on the linearization method and how you approximate the nonlinear term? I am curious as to why there are three terms in the variational form that resemble the convective term (\mathbf{u}\cdot\nabla) \mathbf u.

Also, when you talk about increasing the degree of the polynomial spaces, do you increase the degree of both the velocity and pressure spaces simultaneously, or only one of them?

Cheers,
Halvor

Hi Halvor,

I am using a Newton linearization of the convection term following this paper. I use a single iteration where the convection term (u\cdot \nabla) u is approximated as:

(u^{n+1}\cdot \nabla) u^{n+1} \approx (u^{n+1}\cdot \nabla) u^{n} + (u^{n}\cdot \nabla) u^{n+1} - (u^{n}\cdot \nabla) u^{n}

This is where the three terms in the variational form come from.

Regarding the function spaces, I increase the polynomial degrees simultaneously, with the degree of the pressure function space always being one degree lower than that of the velocity function space. This method helps maintain stability during refinement.

Regards,
Abdelouahed

All right, thanks for clarifying those points.

I seem to recall some issue with using ufl.split to assign functions in subspaces (@dokken I think we discussed this in some thread previously where you provided a thorough reasoning for why [something with the mapping of the subspaces?], but I’m not able to locate the thread atm). I’m referring to the function definitions

u_old, p_old = split(w_old)

that you have on line 219. I think the proper way to do this in dolfinx is to use the sub-spaces of the mixed space, namely the spaces

V_, _ = W.sub(0).collapse()
Q_, _ = W.sub(1).collapse()

that you defined on lines 164 and 165, to generate your functions:

u_old = Function(V_)
p_old = Function(Q_)

Could you try out and see if this solves your issue?

1 Like

I think the way w_old, u_old and p_old is used in this code is fine, as the copying takes place at

which copies the whole vector over.

Another note is that you shouldn’t need to call solver.setOperators at every time step (as it keeps the reference to A which you do not re-define.

I would probably also add a convergence check after solver.solve(...)
i.e.

assert solver.getConvergedReason() >0 to ensure that the direct solver doesn’t fail.

2 Likes

Hi Halvor,

I’m pretty sure the issue doesn’t stem from using ufl.split since it works with the nonlinear solver.

Hi @dokken,

The direct solver does not converge when using "DistMax" greater than or equal to 1.

gmsh.model.mesh.field.setNumber(threshold_field, "DistMax", 2.0 * H)

But it does converge for values less than 0.95.

Do you have an idea what could be the problem?

Thanks

So you are saying that if the mesh is coarser in a larger region, the solver converges?

That sounds like a CFL issue:

which gives you a bound on how fine your mesh can be with a given temporal resolution to avoid instabilities.

I don’t believe the issue is related to the CFL condition. The CFL number is calculated based on the smallest edge in the mesh, and even when I refine the coarser parts of the mesh near the outlet, the mesh around the cylinder remains finer. As a result, the CFL condition doesn’t change significantly. Moreover, I’m using a fully implicit monolithic scheme, which is unconditionally stable, meaning the CFL condition doesn’t need to be satisfied. Despite this, I experimented with smaller time step sizes, but the solver still diverges.