How to improve accuracy of enforced integral constraints involving derivatives

If the constraint does not involve a derivative, like say \int_\Omega u dx = C, the constraint is satisfied to within machine precision, however this is not the case if a derivative is involved. I could always just increase the order of the functionspace, but that is highly expensive, and depending on the use case, that can make it highly impractical. So, I want to know if there are any ways to get around this issue.

Now, let’s get into the PDE and provide the code. Our Poisson Equation with its integral constraint is

\nabla^2 u = -1
u = 0 \ \ \ \ \forall (x,y)\in \partial \Omega
\oint \nabla u\cdot\hat{\textbf{n}}ds = h = 1.05

(As user @spcvanschie pointed out in my previous post, this is a Poisson equation with Dirichlet boundary conditions, ergo its solution is unique, so to add an integral constraint without overdetermining the solution, the forcing term will need to be modified by our Lagrange multiplier \nabla^2 u = -1 +\lambda.) (This specific Poisson Equation I did not come up with using any physical problem as a reference, it is one I came up with using arbitrarily values for everything.) We can now set up the variational form of our constrained Poisson Equation using the Lagrange Multiplier method:

\int_\Omega \nabla u \cdot \nabla v dx + \int_\Omega \lambda v dx = \int_\Omega fv dx
\int_\Omega 0\cdot u \delta\lambda dx + \int_\Omega \lambda \delta\lambda dx = \int_\Omega f \delta\lambda dx + h \delta\lambda.

Setting up the matrices
A = \pmatrix{\int_\Omega \nabla u \cdot \nabla v dx & \int_\Omega \lambda v dx \\ \int_\Omega 0\cdot u \delta\lambda dx & \int_\Omega \lambda \delta\lambda dx}

L = \pmatrix{\int_\Omega fv dx \\ \int_\Omega f \delta\lambda dx}

and we build the constrained mixed variational form and compare the constrained solution to the original in (the latest version of) Dolfinx (where I have primarily taken code from this tutorial to help me along)

from mpi4py import MPI
import dolfinx as df
import ufl
from petsc4py import PETSc
import numpy as np
from scifem import create_real_functionspace
from scifem.petsc import apply_lifting_and_set_bc
from dolfinx.fem.petsc import LinearProblem
import pyvista4dolfinx as p4d

Nx,Ny = 20,20
mesh = df.mesh.create_unit_square(
    MPI.COMM_WORLD, Nx, Ny, df.mesh.CellType.triangle, dtype=np.float64
)
n = ufl.FacetNormal(mesh) # unit normal vector (used later)
t = ufl.perp(n)
order = 2
# Real space
V = df.fem.functionspace(mesh, ("CG", order))
R = create_real_functionspace(mesh)
# Mixed space
W = ufl.MixedFunctionSpace(V, R)
u, lmbda = ufl.TrialFunctions(W)
du, dl = ufl.TestFunctions(W)

#---- define boundary condition ----
fdim, tdim = mesh.topology.dim-1, mesh.topology.dim
mesh.topology.create_connectivity(fdim,tdim)
exterior_facets = df.mesh.exterior_facet_indices(mesh.topology)
exterior_dofs = df.fem.locate_dofs_topological(V,fdim,exterior_facets)
bc = df.fem.dirichletbc(
    df.fem.Constant(
        mesh,df.default_scalar_type(0)),exterior_dofs,V)

#---- Integral constraint ----
h = 1.05

# Forcing term
f = df.fem.Constant(mesh, df.default_scalar_type(-1))

#--------------------------------------------------
#---- Build Matrix --------------------------------
#--------------------------------------------------

# ---- Build components: bilinear form ----
a00 = ufl.inner(ufl.grad(u), ufl.grad(du)) * ufl.dx
a01 = ufl.inner(lmbda,du) * ufl.dx
a10 = df.fem.Constant(mesh, df.default_scalar_type(0)) * u * dl * ufl.dx
a11 = ufl.inner(lmbda,dl) * ufl.dx
# ---- Build components: linear form ----
L0 = ufl.inner(f, du) * ufl.dx
L1 = ufl.inner(f,dl) * ufl.dx

# ---- Build matrix: A ----

bcs = [bc]
a = a00 + a01 + a10 + a11
a_blocks = ufl.extract_blocks(a)
a_compiled = df.fem.form(a_blocks)
A = df.fem.petsc.assemble_matrix(a_compiled, bcs=bcs)
A.assemble()

# ---- Build matrix: L ----
L = L0 + L1
L_blocks = ufl.extract_blocks(L)
L_compiled = df.fem.form(L_blocks)

#--------------------------------------------------
#---- Apply boundary conditions -------------------
#--------------------------------------------------

b = df.fem.petsc.assemble_vector(L_compiled, kind="mpi")
apply_lifting_and_set_bc(b, a_compiled, bcs=bcs)

#--------------------------------------------------
#---- Solve ---------------------------------------
#--------------------------------------------------
uh = df.fem.Function(V, name="u")

#---- Insert constraint into R ----
rh = df.fem.Function(R)
rh.x.array[0] = h
# Next we need to add this value to the existing right hand side vector.
# Therefore we create assign 0s to the primal space
b_real_space = b.duplicate()
uh.x.array[:] = 0
# Transfer the data to `b_real_space`
df.fem.petsc.assign([uh, rh], b_real_space)
# And accumulate the values in the right hand side vector
b.axpy(1, b_real_space)
# We destroy the temporary work vector after usage
b_real_space.destroy()

#---- KSP solver ----
ksp = PETSc.KSP().create(mesh.comm)
ksp.setOperators(A)
ksp.setType("preonly")
pc = ksp.getPC()
pc.setType("lu")
pc.setFactorSolverType("mumps")
xh = b.duplicate()

#---- Solve ----
ksp.solve(b, xh)
xh.ghostUpdate(addv=PETSc.InsertMode.INSERT, mode=PETSc.ScatterMode.FORWARD)

uh = df.fem.Function(V, name="u")
df.fem.petsc.assign(xh, [uh, rh])

b.destroy()
xh.destroy()
A.destroy()
ksp.destroy()

#--------------------------------------------------
#---- Compare -------------------------------------
#--------------------------------------------------

# ---- Uncontstrained problem ----
u = ufl.TrialFunction(V)
v = ufl.TestFunction(V)
a = ufl.dot(ufl.grad(u), ufl.grad(v)) * ufl.dx
L = f * v * ufl.dx
problem2 = LinearProblem(
    a,
    L,
    bcs=[bc],
    petsc_options={"ksp_type": "preonly", "pc_type": "lu"},
    petsc_options_prefix="Poisson",
)
psi_I = problem2.solve()

# ---- Compare fluxes ----
print("-"*40,"\nNo constraint")
print("-"*40)
print("∫∇u ⋅ n ds = ",df.fem.assemble_scalar(df.fem.form(ufl.dot(n,ufl.grad(psi_I)) * ufl.ds)),"\n")
print("-"*40,f"\nWith constraint C(u):= ∫∇u⋅n ds = {h}")
print("-"*40)
print(f"∫∇u_c ⋅ n ds (should be approximately {h}) = ",df.fem.assemble_scalar(df.fem.form(ufl.dot(n,ufl.grad(uh)) * ufl.ds )))

Additionally, the constrained potential preserved desired properties which the unconstrained one satisfied. I also produced plots seen below which show that their gradients are nearly identical (this is a property I also wanted to see in my constrained solution, so this is a good thing)

The only issue is that for the enforced condition, if we take a look at what is printed when we run the code block, it does not satisfy it to within machine precision like want. The result is 1.0471563447846715 instead of 1.05. I want to know if there are any known ways to get around this unwanted error that arises when a derivative is involved with the constraint. It is highly important that integral constraints are as closely satisfied as possible for what I am doing.

There are 2 other things which I tried:

  1. breaking up the problem into a dual form and solving the mixed variational problem using the functionspaces RT_2, DG_1 and R (where R is a real space). The vector solution \nabla u = \boldsymbol\sigma did exactly satisfy the constraint to within machine precision, however, every other desired property of this vector solution was violated (and I suspect this is because I lacked known Neumann boundary conditions). The vector field compared to the unconstrained gradient of the scalar potential looked quite bad with very large vectors near the center.

  2. Letting the Dirichlet boundary conditions “float” by using a weak imposition via Nitsche’s Method (where a tutorial on how to do this in Dolfinx can be found here). This method satisfied all the desired properties which the standard LMM method satisfied, and the lower I set my penalty \alpha, the closer it came to satisfying the integral constraint to machine precision. At alpha = 10E-12, the net flux of the gradient of the solution was 1.0500000000000123. However, the trade-off was that the boundary values were no longer homogeneous, and they became a constant value of around 2*10E-4 (for this problem, I have not checked the values for other cases). I do not know what the implications would be to take this solution knowing that it does not exactly satisfy homogeneous boundary conditions, so I am a bit weary to say “good enough” and just take this solution for further steps in my problem. If providing my full exact problem would help deduce whether or not this would be good enough, let me know, and I will provide it. Below is a plot of the solution using Nitsche’s method for \alpha = 10E-12.

If anyone would like to inspect my equation setups and code for the dual form approach or the Nitsche method approach, let me know, and I will add the full variational forms and associated code blocks.

If this is really the case you’re interested in, then you can simply compute the required additional forcing in one step. No need to change weak forms or matrices:

By divergence theorem: \int_\Omega \nabla\cdot\nabla u = \int_{\partial\Omega} \nabla u \cdot \boldsymbol{n} = h. And from the PDE: \int_\Omega \nabla\cdot\nabla u = -\int_\Omega ( 1 + \lambda) = - ( 1 + \lambda ) \, \text{Vol} , with \text{Vol} = \int_\Omega d \Omega.

So \lambda = - h/\text{Vol} - 1