Newton Solver Not Converging in FEniCSx for Boussinesq Flow (Transition from FEniCS)

Hi everyone,

I implemented a FEniCS code for Boussinesq flow using mixed elements ([P2, P1, P1]), and it worked well. However, after transitioning to FEniCSx, the nonlinear Newton solver fails to converge, with the residual going to -nan.

The problem is the differentially heated square cavity for natural convection. The boundary conditions are:

Velocity: No-slip at all walls
Temperature: High temperature on the left wall, lower temperature on the right wall
Pressure: A constraint at the bottom right corner for better convergence

I have carefully verified my weak forms, which worked correctly in FEniCS. I suspect the issue may be related to how boundary conditions are applied in FEniCSx.

Below is my FEniCSx implementation:

import numpy as np
from mpi4py import MPI
from petsc4py import PETSc
from dolfinx import mesh, fem, plot
from dolfinx.plot import vtk_mesh
from dolfinx.io import XDMFFile
from dolfinx.mesh import create_unit_square, CellType, locate_entities_boundary
import basix
from basix.ufl import element, mixed_element
from ufl import split, grad, inner, dx, div, sym, dot, Constant, TestFunctions, TrialFunctions, as_vector, derivative
from dolfinx.fem.petsc import NonlinearProblem
from dolfinx.nls.petsc import NewtonSolver

from dolfinx.log import set_log_level, LogLevel
import pyvista as pv
import pyvistaqt as pvqt
from pyvista.utilities import xvfb
set_log_level(LogLevel.INFO)  # Enables logging to monitor solver progress

def plot_vector_field(field, function_space, mesh, field_name, output_filename, scale_factor=0.2):
    pv.OFF_SCREEN = True
    pv.start_xvfb()
    field_collapsed = field.collapse()
    topology, cell_types, geometry = plot.vtk_mesh(function_space)
    values = np.zeros((geometry.shape[0], 3), dtype=np.float64)
    values[:, :len(field_collapsed)] = field_collapsed.x.array.real.reshape((geometry.shape[0], len(field_collapsed)))
    function_grid = pv.UnstructuredGrid(topology, cell_types, geometry)
    function_grid[field_name] = values
    glyphs = function_grid.glyph(orient=field_name, factor=scale_factor)
    mesh.topology.create_connectivity(mesh.topology.dim, mesh.topology.dim)
    grid = pv.UnstructuredGrid(*plot.vtk_mesh(mesh, mesh.topology.dim))
    plotter = pv.Plotter(off_screen=True)
    plotter.add_mesh(grid, style="wireframe", color="k")
    plotter.add_mesh(glyphs, show_scalar_bar=True)
    plotter.view_xy()
    plotter.screenshot(output_filename)
    print(f"{field_name} visualization saved as {output_filename}")

def plot_scalar_field(field, function_space, field_name, output_filename):
    pv.OFF_SCREEN = True
    pv.start_xvfb()
    u_n = field.collapse()
    grid = pv.UnstructuredGrid(*plot.vtk_mesh(function_space))
    grid.point_data[field_name] = u_n.x.array.real
    grid.set_active_scalars(field_name)
    plotter = pv.Plotter(off_screen=True)
    plotter.add_mesh(grid, show_edges=True, show_scalar_bar=True)
    plotter.view_xy()
    plotter.screenshot(output_filename)
    print(f"{field_name} field visualization saved as {output_filename}")

# Create a mesh
nx, ny = 32, 32
domain = create_unit_square(MPI.COMM_WORLD, nx, ny)

# Define vector and scalar elements using basix.element
P2 = element("P", domain.basix_cell(), degree=2, shape=(domain.geometry.dim,))  # Velocity element
P1 = element("P", domain.basix_cell(), degree=1)  # Pressure and temperature elements

# Create a mixed function space
W = fem.functionspace(domain, mixed_element([P2, P1, P1]))

# Define trial and test functions
w = fem.Function(W)
w0 = fem.Function(W)  # Previous solution
(v, p, T) = split(w)  # Trial functions
(v_test, p_test, T_test) = TestFunctions(W)  # Test functions

w0.x.array[:] = 0.0


# Collapse the subspaces
V_vel, _ = W.sub(0).collapse()
T_space, _ = W.sub(2).collapse()
p_space, _ = W.sub(1).collapse()

# Define initialization functions
def T_init(x):
    values = 1.0 - x[0]  # Linearly decreasing with respect to x-axis
    return values.reshape(1, -1)

def V_init(x):
    return np.zeros((domain.geometry.dim, x.shape[1]))

def P_init(x):
    return np.zeros((1, x.shape[1]))


# Assign the interpolated values back to the mixed function space
w0.sub(0).interpolate(V_init)
w0.sub(1).interpolate(P_init)
w0.sub(2).interpolate(T_init)
# w.x.scatter_forward()


# w.sub(2).interpolate(T_init)  # Small perturbation

print(w0.sub(0).x.array[:5])
print(w0.sub(1).x.array[:5])
print(w0.sub(2).x.array[:5])

print("Initial velocity max:", np.max(w0.sub(0).x.array))
print("Initial pressure max:", np.max(w0.sub(1).x.array))
print("Initial temperature max:", np.max(w0.sub(2).x.array))

(v0, p0, T0) = split(w0)  # Previous solution

# Time-stepping parameters
dt = 0.005
T_end = 0.5
t = 0.0
num_steps = int(T_end / dt)

# Define material properties
rho_0 = 1.0  # Density
mu = 1.0  # Dynamic viscosity
K = 1.0  # Thermal conductivity
C_p = 1.0  # Heat capacity
alpha = K / (rho_0 * C_p)  # Thermal diffusivity
Ra = 1.e3  # Rayleigh number

# # Define buoyancy force
# def f_expr(x):
#     """Expression for the applied buoyancy force"""
#     force = Ra * T * C_p * mu / K  
#     return np.vstack((np.zeros_like(x[0]), force))  # Force applied in y-direction

# f_B = fem.Function(V_vel)
# f_B.interpolate(f_expr)
f_B = as_vector([0, Ra * T * C_p * mu / K])

v_t = (v - v0) / dt
T_t = (T - T0) / dt

# Define variational forms
dx = dx(metadata={"quadrature_degree": 3 * 2})

F_mass = -div(v) * p_test * dx

F_momentum = (rho_0 * dot(v_t, v_test) * dx
              + rho_0 * dot(dot(grad(v), v), v_test) * dx
              + 2 * mu * inner(sym(grad(v)), sym(grad(v_test))) * dx
              - p * div(v_test) * dx
              - rho_0 * dot(f_B, v_test) * dx)

F_energy = (dot(T_t, T_test) * dx
            - dot(v * T, grad(T_test)) * dx
            + alpha * dot(grad(T), grad(T_test)) * dx)

# Combine all forms
F = F_mass + F_momentum + F_energy

# Define boundary markers
def walls(x):
    return np.logical_or.reduce((
        np.isclose(x[0], 0.0),  # Left wall
        np.isclose(x[0], 1.0),  # Right wall
        np.isclose(x[1], 0.0),  # Bottom wall
        np.isclose(x[1], 1.0)   # Top wall
    ))

def hot_wall(x):
    return np.isclose(x[0], 0.0)

def cold_wall(x):
    return np.isclose(x[0], 1.0)

def corner_point(x):
    return np.logical_and(np.isclose(x[0], 1.0), np.isclose(x[1], 0.0))

boundary_facets = locate_entities_boundary(domain, domain.topology.dim - 1, walls)
hot_wall_facets = locate_entities_boundary(domain, domain.topology.dim - 1, hot_wall)
cold_wall_facets = locate_entities_boundary(domain, domain.topology.dim - 1, cold_wall)
corner_facets = locate_entities_boundary(domain, domain.topology.dim - 1, corner_point)

# Collapse the subspaces
V_vel, _ = W.sub(0).collapse()
T_space, _ = W.sub(2).collapse()
p_space, _ = W.sub(1).collapse()

# Define boundary function values
u_noslip_func = fem.Function(V_vel)
u_noslip_func.interpolate(lambda x: np.zeros((domain.geometry.dim, x.shape[1])))

T_hot_func = fem.Function(T_space)
T_hot_func.interpolate(lambda x: np.ones((1, x.shape[1])))

T_cold_func = fem.Function(T_space)
T_cold_func.interpolate(lambda x: np.zeros((1, x.shape[1])))

p_corner_func = fem.Function(p_space)
p_corner_func.interpolate(lambda x: np.zeros((1, x.shape[1])))

# Locate boundary DOFs using topology
boundary_dofs_v = fem.locate_dofs_topological(W.sub(0), domain.topology.dim - 1, boundary_facets)
boundary_dofs_T_hot = fem.locate_dofs_topological(W.sub(2), domain.topology.dim - 1, hot_wall_facets)
boundary_dofs_T_cold = fem.locate_dofs_topological(W.sub(2), domain.topology.dim - 1, cold_wall_facets)
corner_dofs = fem.locate_dofs_geometrical(p_space, corner_point) #pointwise 

# Define boundary conditions for velocity (no-slip condition)
u_noslip = np.array((0,) * domain.geometry.dim, dtype=PETSc.ScalarType)
bc_v = fem.dirichletbc(u_noslip_func, boundary_dofs_v)#, W.sub(0))

# Define boundary conditions for temperature
bc_T_hot = fem.dirichletbc(T_hot_func, boundary_dofs_T_hot) #, W.sub(2))
bc_T_cold = fem.dirichletbc(T_cold_func, boundary_dofs_T_cold) #, W.sub(2))

# Define the Dirichlet condition for pressure at the corner
bc_p_corner = fem.dirichletbc(p_corner_func, corner_dofs) #, W.sub(1))

# Apply boundary conditions
bcs = [bc_v, bc_T_hot, bc_T_cold, bc_p_corner]

if np.any(np.isnan(w.x.array)):
    raise RuntimeError(f"NaN detected in solution at step {step}, time {t:.3f}")

# Create nonlinear problem and solver
problem = NonlinearProblem(F, w, bcs)
solver = NewtonSolver(domain.comm, problem)
solver.rtol = 1e-6  # tolerance
solver.atol = 1e-6  # absolute tolerance
solver.max_it = 50  # max iterations (default is 10)
solver.convergence_criterion = "residual"
solver.relaxation_parameter = 0.7  # Add relaxation
solver.report = True

# Configure PETSc linear solver options
ksp = solver.krylov_solver
opts = PETSc.Options()
option_prefix = ksp.getOptionsPrefix()
# opts[f"{option_prefix}ksp_type"] = "gmres"
# opts[f"{option_prefix}pc_type"] = "ilu"
opts[f"{option_prefix}ksp_type"] = "preonly"  # Use direct solver
opts[f"{option_prefix}pc_type"] = "lu"  # Use LU factorization
opts[f"{option_prefix}pc_factor_mat_solver_type"] = "mumps"  # Set MUMPS as the solver

ksp.setFromOptions()

# Prepare output file
output_file = XDMFFile(MPI.COMM_WORLD, "output.xdmf", "w")
output_file.write_mesh(domain)

# Visualization setup
xvfb.start_xvfb()
topology, cell_types, x = plot.vtk_mesh(T_space)
grid = pv.UnstructuredGrid(topology, cell_types, x)
grid.point_data["T"] = w.sub(2).collapse().x.array
plotter = pv.Plotter()
plotter.open_gif("temperature_evolution.gif", fps=5)
plotter.add_mesh(grid, scalars="T", show_edges=True)
plotter.add_text(f"time: {t:.3f}", font_size=12, name="timelabel")

# Time-stepping loop
for step in range(num_steps):
    print("Max velocity:", np.max(w.sub(0).x.array))
    print("Max pressure:", np.max(w.sub(1).x.array))
    print("Max temperature:", np.max(w.sub(2).x.array))

    if np.any(np.isnan(w.x.array)):
        raise RuntimeError(f"NaN detected in solution at step {step}, time {t:.3f}")

    t += dt
    num_iterations, converged = solver.solve(w)
    if not converged:
        raise RuntimeError(f"Solver failed to converge at step {step}, time {t:.3f}")

    w0.x.array[:] = w.x.array  # Update previous solution

    print(f"Time: {t:.3f}, Newton iterations: {num_iterations}")

    # Save results
    output_file.write_function(w.sub(2), t)

    # Update visualization
    grid.point_data["T"][:] = w.sub(2).collapse().x.array
    plotter.update_scalars(grid.point_data["T"])
    plotter.write_frame()

# Finalize visualization
plotter.close()
output_file.close()
print("Simulation complete!")

# Plot the final results for velocity and pressure
plot_vector_field(w.sub(0), V_vel, domain, "Velocity", "velocity_final.png", scale_factor=0.5)
plot_scalar_field(w.sub(2), T_space, "Temperature", "temperature_final.png")
plot_scalar_field(w.sub(1), p_space, "Pressure", "pressure_final.png")
print("Simulation complete!")

Has anyone encountered similar issues when moving from FEniCS to FEniCSx? Any insights or suggestions would be greatly appreciated.

Hi there! Did you managed to solve your problem in the end?

I am struggling with the same problem in some sorts, but for the temperature constraint, I have a Neumann heat source on one of my boundaries, and I would like to get both a heat map and a velocity field in the end.

Thanks in advance!