PETSc solving interface#

In this section, we will cover how we can work directly with the PETSc Krylov subspace solvers.

Warning

Native Windows On native Windows, we do not have support for PETSc, as it is not package for conda. To run any demo with PETSc you should install DOLFINx using conda under WSL2 or docker.

Problem specification#

We consider the equations of linear elasticity,

\[\begin{split} \begin{align} -\nabla \cdot \sigma (u) &= f && \text{in } \Omega\\ u &= u_D && \text{on } \partial\Omega_D\\ \sigma(u) \cdot n &= T && \text{on } \partial \Omega_N \end{align} \end{split}\]

where

\[\begin{split} \begin{align} \sigma(u)&= \lambda \mathrm{tr}(\epsilon(u))I + 2 \mu \epsilon(u)\\ \epsilon(u) &= \frac{1}{2}\left(\nabla u + (\nabla u )^T\right) \end{align} \end{split}\]

where \(\sigma\) is the stress tensor, \(f\) is the body force per unit volume, \(\lambda\) and \(\mu\) are Lamé’s elasticity parameters for the material in \(\Omega\), \(I\) is the identity tensor, \(\mathrm{tr}\) is the trace operator on a tensor, \(\epsilon\) is the symmetric strain tensor (symmetric gradient), and \(u\) is the displacement vector field. Above we have assumed isotropic elastic conditions.

We will consider a beam of dimensions \([0,0,0] \times [L,W,H]\), where

\[\begin{split} \begin{align} u_D(0,y,z) &= (0,0,0)\\ u_D(L,y,z) &= (0,0,-g)\\ \end{align} \end{split}\]

where \(g\) is a prescribed displacement. In other words we are clamping the beam on one end, and applying a given displacement on the other end. All other boundaries will be traction free, i.e. \(T=(0,0,0)\).

from mpi4py import MPI
from petsc4py import PETSc

import numpy as np

import dolfinx
import dolfinx.fem.petsc
import ufl

L = 10.0
W = 3.0
H = 3.0
mesh = dolfinx.mesh.create_box(
    MPI.COMM_WORLD,
    [[0.0, 0.0, 0.0], [L, W, H]],
    [15, 7, 7],
    cell_type=dolfinx.mesh.CellType.hexahedron,
)
tdim = mesh.topology.dim
V = dolfinx.fem.functionspace(mesh, ("Lagrange", 2, (mesh.geometry.dim,)))

Locate exterior facets#

We start by locate the various facets for the different boundary conditions. First, we find all boundary facets (those facets that are connected to only one cell)

mesh.topology.create_connectivity(tdim - 1, tdim)
boundary_facets = dolfinx.mesh.exterior_facet_indices(mesh.topology)

Locate subset of exterior facets#

Next we find those facets that should be clamped, and those that should have a non-zero traction on it. We pass in a Python function that takes in a (3, num_points) array, and returns an 1D array of booleans indicating if the point satisfies the condition or not.

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


clamped_facets = dolfinx.mesh.locate_entities_boundary(mesh, tdim - 1, left_facets)

An equivalent way to find the facets is to use Python lambda functions, which are anonymous functions (they are not bound to a variable name). Here we find the facets on the right boundary, where \(x = L\)

As all mesh entities are represented as integers, we can find the boundary facets by remaining facets using numpy set operations

free_facets = np.setdiff1d(boundary_facets, np.union1d(clamped_facets, prescribed_facets))

Defining a mesh marker#

Next, we can define a meshtag object for all the facets in the mesh

num_facets = mesh.topology.index_map(tdim - 1).size_local
markers = np.zeros(num_facets, dtype=np.int32)
clamped = 1
prescribed = 2
free = 3
markers[clamped_facets] = clamped
markers[prescribed_facets] = prescribed
markers[free_facets] = free
facet_marker = dolfinx.mesh.meshtags(mesh, tdim - 1, np.arange(num_facets, dtype=np.int32), markers)

The variational formulation#

We have now seen this variational formulation a few times

x = ufl.SpatialCoordinate(mesh)
T_0 = dolfinx.fem.Constant(mesh, (0.0, 0.0, 0.0))
E = dolfinx.fem.Constant(mesh, 1.4e3)
nu = dolfinx.fem.Constant(mesh, 0.3)
mu = E / (2.0 * (1.0 + nu))
lmbda = E * nu / ((1.0 + nu) * (1.0 - 2.0 * nu))
f = dolfinx.fem.Constant(mesh, (0.0, 0.0, 0.0))


def epsilon(u):
    return ufl.sym(ufl.grad(u))


def sigma(u):
    return 2.0 * mu * epsilon(u) + lmbda * ufl.tr(epsilon(u)) * ufl.Identity(len(u))


ds = ufl.Measure("ds", domain=mesh, subdomain_data=facet_marker)
u = ufl.TrialFunction(V)
v = ufl.TestFunction(V)
a = ufl.inner(sigma(u), epsilon(v)) * ufl.dx
L = ufl.inner(f, v) * ufl.dx + ufl.inner(T_0, v) * ds(3)

Alternative lifting procedure#

We locate the constrained degrees of freedom

Next, we define the prescribed displacement

u_prescribed = dolfinx.fem.Constant(mesh, (0.0, 0.0, -H / 2))
u_clamped = dolfinx.fem.Constant(mesh, (0.0, 0.0, 0.0))

We define the Dirichlet boundary condition object as

The lifting procedure from PETSc solving interface is used in both C++ and Python, and what it does under the hood is to compute the local matrix-vector products of \(A_{d, bc}\) and \(g\) (no global matrix vector products are involved). However, we can use UFL to do this in a simpler fashion in Python

g = dolfinx.fem.Function(V)
g.x.array[:] = 0
dolfinx.fem.set_bc(g.x.array, bcs)
g.x.scatter_forward()
L_lifted = L - ufl.action(a, g)

What happens here?

ufl.action() reduces the bi-linear form to a linear form (and would reduce a linear form to a scalar) by replacing the trial function with the function \(g\), that is only non-zero at the Dirichlet condition

The new assembly of the linear and bi-linear form would be

New assembly commands!

In all previous sections we have used dolfinx.fem.assemble_matrix() and dolfinx.fem.assemble_vector(), while we now use dolfinx.fem.petsc.assemble_vector() and dolfinx.fem.petsc.assemble_matrix(). The difference here is that we assemble into PETSc Matrix and Vector objects, which can be easily used with the PETSc solvers. Even if the Native DOLFINx matrices supports MPI distributed vectors and matrices, scipy doesn’t. PETSc has a notion of MPI distributed matrices, which means that we can finally run our problems in parallel!

Now that we have created our PETSc Matrix and PETSc Vector, we can create a PETSc Krylov subspace solver.

Next, we can choose from all the different methods PETSc support. We will limit ourself to a direct solver that can be executed in parallel.

ksp.setType("preonly")
ksp.getPC().setType("lu")
ksp.getPC().setFactorSolverType("mumps")

We attach the matrix to the operator, so that we can modify the entries later, and it will be reflected in a solve call.

ksp.setOperators(A)

Next we can solve the linear system

uh = dolfinx.fem.Function(V)
ksp.solve(b, uh.x.petsc_vec)
assert ksp.getConvergedReason() > 0, "Solver did not converge"
uh.x.scatter_forward()
ksp.destroy()
_ = b.destroy()

Destruction of PETSc objects

PETSc does not handle the destruction of Python objects. Thus we manually have to call their destructor to avoid memory leaks.

Hide code cell source

import pyvista

grid = dolfinx.plot.vtk_mesh(uh.function_space)
pyvista_grid = pyvista.UnstructuredGrid(*grid)
values = uh.x.array.reshape(-1, 3)
pyvista_grid.point_data["u"] = values
warped = pyvista_grid.warp_by_vector("u")
plotter = pyvista.Plotter()
plotter.show_axes()
plotter.add_mesh(pyvista_grid, style="points")
plotter.add_mesh(warped, scalars="u", lighting=True)
plotter.show()
2025-10-30 19:19:21.282 (   0.817s) [    7F2C2039B140]vtkXOpenGLRenderWindow.:1458  WARN| bad X server connection. DISPLAY=

Convenience wrapper for Linear problems#

As many users will solve linear problems over and over again, DOLFINx provides a simplfied user-interface, that takes care of creation, assembly and destruction of matrices and solvers. Given a and L from above, we show this interface:

u_new = dolfinx.fem.Function(V)
options = {
    "ksp_type": "preonly",
    "pc_type": "lu",
    "pc_factor_mat_solver_type": "mumps",
    "ksp_error_if_not_converged": True,
    "ksp_monitor": None,
}
problem = dolfinx.fem.petsc.LinearProblem(
    a, L, bcs=bcs, u=u_new, petsc_options=options, petsc_options_prefix="elasticity_"
)
problem.solve()
assert problem.solver.getConvergedReason() > 0, "Solver did not converge"
  Residual norms for elasticity_ solve.
  0 KSP Residual norm 2.385274137531e+03
  1 KSP Residual norm 2.876138276706e-11

We verify that the solution is the same as above

np.testing.assert_allclose(uh.x.array, u_new.x.array, atol=1e-12)

PETSc nonlinear solver#

We could rewrite the problem above as a non-linear problem, by replacing the trial-function with the unknown uh

uh_new = dolfinx.fem.Function(V)
F = a - L
F = ufl.replace(F, {u: uh_new})

Next, we use the high-level wrapper of PETSc.SNES to solve the non-linear problem

options["snes_error_if_not_converged"] = True
options["snes_monitor"] = None
problem = dolfinx.fem.petsc.NonlinearProblem(
    F, uh_new, bcs=bcs, petsc_options=options, petsc_options_prefix="elasticity_nonlinear_"
)
problem.solve()
  0 SNES Function norm 2.385274137531e+03
    Residual norms for elasticity_nonlinear_ solve.
    0 KSP Residual norm 2.385274137531e+03
    1 KSP Residual norm 2.876138276706e-11
  1 SNES Function norm 7.648994483138e-11
Coefficient(FunctionSpace(Mesh(blocked element (Basix element (P, hexahedron, 1, gll_warped, unset, False, float64, []), (3,)), 0), blocked element (Basix element (P, hexahedron, 2, gll_warped, unset, False, float64, []), (3,))), 3)
converged = problem.solver.getConvergedReason()
num_iterations = problem.solver.getIterationNumber()

Observe that since the problem is linear, we converge in one iteration

converged=3 num_iterations=1