\(L^2\) projection

This demo is implemented in demo_L2_projection.py. It illustrates:

  • How to perform a \(L^2\) projection using QUGaR and FEniCSx.

  • How to export the generated result to VTK files.

Problem definition

Let us consider the domain \(\Omega \subset \mathbb{R}^n\), immersed in a mesh \(\mathcal{T}(\Omega)\) (see Divergence theorem demo first for further details about the immersed setting), and the function \(f:\Omega\to\mathbb{R}\) defined as \(f(x,y,z) = \sin(x)\cos(y)\sin(z)\). Let us also consider a finite element space \(V\) defined over the mesh \(\mathcal{T}(\Omega)\), then, computing the \(L^2\) projection of \(f\) onto \(V\) is equivalent to solving the following variational problem: \( \begin{align} \text{Find } u\in V \text{ such that } \forall v\in V: \int_{\Omega} u v \text{d} x = \int_{\Omega} f v \text{d} x \end{align} \)

Implementation

Modules import

First we add the needed modules and functions:


from qugar.utils import has_FEniCSx, has_PETSc

if not has_FEniCSx:
    raise ValueError("FEniCSx installation not found is required.")

if not has_PETSc:
    raise ValueError("petsc4py installation not found is required.")

from pathlib import Path

from mpi4py import MPI

import dolfinx.fem
import dolfinx.fem.petsc
import dolfinx.io
import numpy as np
import ufl
from dolfinx import default_real_type as dtype

import qugar
import qugar.impl
from qugar.dolfinx import LinearProblem, form_custom
from qugar.mesh import create_unfitted_impl_Cartesian_mesh

Geometry and mesh

We define the geometry to be considered: in this case a Schoen gyroid in 3D defined through an implicit function. The domain can be easily changed by just choosing a different implicit function among the available ones (see Implicit functions demo for some examples).

impl_func = qugar.impl.create_Schoen([1.0, 1.0, 1.0])

We create an unfitted Cartesian mesh (corresponding to \(\mathcal{T}\)) in which we embed the domain \(\Omega\). This is a Cartesian mesh corresponding to the domain \([0,1]^3\) and with n_cells cells per direction.

n_cells = 8

unf_mesh = create_unfitted_impl_Cartesian_mesh(
    MPI.COMM_WORLD, impl_func, n_cells, exclude_empty_cells=True, dtype=dtype
)

The option exclude_empty_cells (set to True) prevents the generation of empty cells in the mesh (those denoted as \(\mathcal{T}_{\text{empty}}\) in Divergence theorem demo). This is useful to eliminate inactive degrees of freedom in the problem.

Spaces and functions

We create the function \(\mathbf{f}\).

x = ufl.SpatialCoordinate(unf_mesh)
f = ufl.sin(x[0]) * ufl.cos(x[1]) * ufl.sin(x[2])

and the finite element space \(V\) over the unfitted mesh (corresponding to the mesh \(\mathcal{T}\)) needed to define the test and trial functions \(u\) and \(v\). The finite element space is defined as a Lagrange space of the given degree (2 in this case).

degree = 2
V = dolfinx.fem.functionspace(unf_mesh, ("Lagrange", degree))
u, v = ufl.TrialFunction(V), ufl.TestFunction(V)

Linear forms

We define the variational problem to be solved, which is equivalent to the \(L^2\) projection of \(f\) onto \(V\), and generate the corresponding bilinear and linear forms a and L using QUGaR’s custom form functions. The number of quadrature points per cell (or integration cell in the case of cut cells) is set to degree + 1 to prevent DOLFINx from using a higher-order quadrature rule (because of the trigonometric right-hand side expression).

n_quad_pts = degree + 1
quad_degree = 2 * n_quad_pts + 1
F = (u - f) * v * ufl.dx(degree=quad_degree)

Linear system solution

We solve the associated linear system \(A\mathbf{u} = \mathbf{b}\), where \(\mathbf{u}\) is the solution of the problem. The solution is stored in a a finite element function uh defined over the same finite element space \(V\) as the trial functions.

In this case we use a direct solver (Cholesky) to solve the linear system. However, due to the potentially ill-conditioning of the matrix, we use a (symmetric) Jacobi preconditioner. It is known that Jacobi preconditioners are not very effective for Lagrange elements, but still help.

petsc_options = {
    "ksp_type": "preonly",
    "pc_type": "cholesky",
    "ksp_diagonal_scale": True,  # Jacobi
    # "ksp_diagonal_scale_fix": True, # transformsa back A an b after Jacobi
}

problem = LinearProblem(ufl.lhs(F), ufl.rhs(F), petsc_options=petsc_options)
problem.solve()

uh = problem.u

Error calculation

The \(L^2\) error of the projection is computed. In this case we slightly increase the number of quadrature points used to compute the error for achieving a higher accuracy.

n_quad_pts = 2 * degree + 2
quad_degree = 2 * n_quad_pts + 1
error_form: qugar.dolfinx.CustomForm = form_custom(
    (uh - f) ** 2 * ufl.dx(degree=quad_degree), dtype=dtype
)
error_L2 = np.sqrt(
    unf_mesh.comm.allreduce(
        dolfinx.fem.assemble_scalar(error_form, coeffs=error_form.pack_coefficients()),
        op=MPI.SUM,
    )
)
if unf_mesh.comm.rank == 0:
    print(f"L2-error: {error_L2:.2e}")

Visualization

Finally, we visualize the obtained solution by generating an auxiliar mesh that reparameterizes the unfitted domain. This mesh approximates the domain using an arbitrary order fitted tessellation made of discontinuous (and possibly degenerated) Lagrange elements.

An extra mesh corresponding to the wirebasket (the line intersections of the domain \(\Omega\) with the Cartesian mesh \(\mathcal{T}\) iso-planes) is also generated to improve the solution’s visualization.

rep_degree = 3
reparam = qugar.reparam.create_reparam_mesh(unf_mesh, degree=rep_degree, levelset=False)
rep_mesh = reparam.create_mesh()
rep_mesh_wb = reparam.create_mesh(wirebasket=True)

In order to visualize the computed solution, we interpolate it into the generated meshes. For doing so we create new spaces associated to the meshes and interpolate the solution into them (using the interpolate_nonmatching DOLFINx’s function). Note that even if the element types of the new spaces Vrep and Vrep_wb are continuous (CG), the underlying meshes are discontinuous, so they will be the generated functions.

Vrep = dolfinx.fem.functionspace(rep_mesh, ("CG", rep_degree))
interp_data = qugar.reparam.create_interpolation_data(Vrep, V)
uh_rep = dolfinx.fem.Function(Vrep, dtype=dtype)
uh_rep.interpolate_nonmatching(uh, *interp_data)

Vrep_wb = dolfinx.fem.functionspace(rep_mesh_wb, ("CG", rep_degree))
interp_data_wb = qugar.reparam.create_interpolation_data(Vrep_wb, V)
uh_rep_wb = dolfinx.fem.Function(Vrep_wb, dtype=dtype)
uh_rep_wb.interpolate_nonmatching(uh, *interp_data_wb)

Both meshes are then exported to VTK files and can be visualized using ParaView. In the case in which rep_degree > 1, the parameter Nonlinear Subdivision Level value (under the advanced properties menu in ParaView) can be adjusted to generate higher-quality visualizations.

results_folder = Path("results")
results_folder.mkdir(exist_ok=True, parents=True)
filename = results_folder / "demo_L2_projection"

with dolfinx.io.VTKFile(rep_mesh.comm, filename.with_suffix(".pvd"), "w") as vtk:
    vtk.write_function(uh_rep)
    vtk.write_function(uh_rep_wb)

Other DOLFINx file writers (as VTXWriter or XDMFFile) could be also used, however, be aware that between VTKFFile and XDMFFile, the former is the recommended option for high-degrees.