Fixing a mean value with an affine multi-point constraint#
Author Jørgen S. Dokken
This demo shows how an affine multi-point constraint can enforce a constraint on an integral of the solution,
and how that relates to the more familiar way of imposing such a condition,
a Lagrange multiplier in a real space
(basix.ufl.real_element()), as seen in
SciFEM real space demo.
The two are algebraically equivalent; they differ sharply in cost, and this demo measures the difference.
Mathematical formulation#
We solve the pure Neumann Poisson problem
on \(\Omega=(0,1)^2\). The problem is singular: constants lie in the kernel, so \(u\) is determined only up to an additive constant, and the data must satisfy the compatibility condition \(\int_\Omega f~\mathrm{d}x + \int_{\partial\Omega} h~\mathrm{d}s = 0\). Prescribing the mean value \(\int_\Omega u~\mathrm{d}x = \gamma\) selects the unique solution.
We start by import the required modules:
Problem data#
We use the manufactured solution \(u_{ex} = x^2 - x + C\). Everything else is derived from it with UFL rather than worked out by hand: the source is \(f=-\Delta u_{ex}\), the boundary flux is \(h=\nabla u_{ex}\cdot n\), and the target \(\gamma\) is the integral of \(u_{ex}\) itself. Deriving \(f\) and \(h\) from the same expression makes the compatibility condition hold by construction, since \(\int_\Omega -\Delta u_{ex}~\mathrm{d}x + \int_{\partial\Omega} \nabla u_{ex}\cdot n~\mathrm{d}s = 0\) is the divergence theorem. Since \(u_{ex}\) is a second order polynomial it lies in the second order Lagrange space, so the discrete solution should reproduce it up to solver accuracy.
C = 3.0
degree = 2
Transforming an integral condition into a multi-point constraint#
Expanding \(u_h=\sum_i u_i\phi_i\) turns the integral condition into a single linear equation between all the coefficients,
The weights \(w\) are just the assembled vector of the linear form
\(v\mapsto\int_\Omega v~\mathrm{d}x\). Picking one degree of freedom \(s\) with
\(w_s\neq 0\) and solving for it gives exactly the affine multi-point
constraint \(u = K\hat{u} + g\) of dolfinx_mpc.MultiPointConstraint,
So \(s\) is the single slave, every other degree of freedom in the support of the functional is a master, the coefficients are \(-w_i/w_s\).
There are two new concepts in this demo compared to the others:
The inhomogeneity \(g_s=\frac{\gamma}{w_s}\) is computed and assigned to the constraint.
The constraint involves an integral, so the weights \(w_i\) are computed by assembling a linear form rather than reading them off a mesh entity. We create a convenience function
build_constraintthat takes in:the function space
Vthat ourdolfinx_mpc.MultiPointConstraintwill be built onthe
linear formweight_formthat defines the weightsThe target value
value, which is \(\gamma\)Any
boundary conditionsbcsthat should be applied to the constraint. The function returns the finalized MPC as well as the number of masters in the MPC constraint.
def build_constraint(V, weight_form, value, bcs=()):
"""Constrain ``L(u) = value`` and report how many masters survived."""
mpc = MultiPointConstraint(V, bcs=list(bcs))
mpc.add_integral_constraint(weight_form, value, bcs=list(bcs))
mpc.finalize()
kept = sum(len(mpc.masters.links(s)) for s in mpc.slaves[: mpc.num_local_slaves])
return mpc, V.mesh.comm.allreduce(kept, op=MPI.SUM)
Variational problem#
The forms are the standard pure Neumann Poisson ones; nothing in them knows about the constraint.
def build_poisson_forms(V: fem.FunctionSpace, u_ex: ufl.core.expr.Expr) -> tuple[ufl.Form, ufl.Form]:
"""Bilinear and linear form of the pure Neumann Poisson problem.
The source and the boundary flux are both differentiated out of ``u_ex``, so
the data is compatible by construction.
"""
u, v = ufl.TrialFunction(V), ufl.TestFunction(V)
n = ufl.FacetNormal(V.mesh)
f = -ufl.div(ufl.grad(u_ex))
a = ufl.inner(ufl.grad(u), ufl.grad(v)) * ufl.dx
L = ufl.inner(f, v) * ufl.dx + ufl.inner(ufl.dot(ufl.grad(u_ex), n), v) * ufl.ds
return a, L
def exact_mean(domain: mesh.Mesh, u_ex):
"""The target value, integrated from the manufactured solution."""
compiled_mean = fem.form(u_ex * ufl.dx(domain=domain))
return compiled_mean.mesh.comm.allreduce(fem.assemble_scalar(compiled_mean), op=MPI.SUM)
Setting up the problem#
The reduced operator of a cell integral constraint is dense, so the mesh is deliberately coarse; the cost table at the end of the demo shows why.
N = 16
domain = mesh.create_unit_square(MPI.COMM_WORLD, N, N)
comm = domain.comm
V = fem.functionspace(domain, ("Lagrange", degree))
x = ufl.SpatialCoordinate(domain)
u_ex = x[0] ** 2 - x[0] + C
a, L = build_poisson_forms(V, u_ex)
gamma = exact_mean(domain, u_ex)
We use the form ufl.TestFunction(V) * ufl.dx to compute the weights \(w_i\)
of the integral constraint.
comm.Barrier()
_t0 = time.perf_counter()
mpc, num_masters = build_constraint(V, ufl.conj(ufl.TestFunction(V)) * ufl.dx, gamma)
comm.Barrier()
_t1 = time.perf_counter()
t_constraint = _t1 - _t0
Solving#
dolfinx_mpc.LinearProblem handles the affine constraint for us: it
refreshes the offset with
update_constants
and applies dolfinx_mpc.apply_mpc_lifting(), which contributes the
\(-K^TAg\) term. Note that no null space has to be attached even though \(A\) is
singular: the constraint removes the constant kernel, because
\(w\cdot\mathbf{1} = |\Omega| \neq 0\).
petsc_options = {
"ksp_type": "preonly",
"pc_type": "lu",
"pc_factor_mat_solver_type": "mumps",
"ksp_error_if_not_converged": True,
}
problem = LinearProblem(a, L, mpc, bcs=[], petsc_options=petsc_options)
uh = problem.solve()
comm.Barrier()
t_mpc = time.perf_counter() - _t1
def operator_stats(A, comm, singular=False, root=0):
"""Global nnz and 2-norm condition number of an assembled operator.
The condition number is computed from a dense SVD on ``root``, so this is a
diagnostic for demo sized problems only. Used just below to make the
verification tolerance conditioning-aware; see "Cost and conditioning"
further down for why $K^TAK$ needs this at all.
"""
# petsc4py defaults to MatInfoType.GLOBAL_SUM, so this is already reduced
# over the communicator and must not be summed again.
nnz = int(A.getInfo()["nz_used"])
A_csr = dolfinx_mpc.utils.gather_PETScMatrix(A, root=root)
cond = None
if comm.rank == root:
sv = np.linalg.svd(A_csr.toarray(), compute_uv=False)
# A is singular for the pure Neumann problem, so compare against the
# smallest *nonzero* singular value.
cond = sv[0] / (sv[-2] if singular else sv[-1])
return nnz, comm.bcast(cond, root=root)
Verification#
Three things are checked: that the constraint is satisfied and that the manufactured solution is recovered.
mean_value = comm.allreduce(fem.assemble_scalar(fem.form(uh * ufl.dx)), op=MPI.SUM)
error = np.sqrt(comm.allreduce(fem.assemble_scalar(fem.form((uh - u_ex) ** 2 * ufl.dx)), op=MPI.SUM))
_, cond_mpc = operator_stats(problem.A, comm)
----Verification----
dofs 1089
masters 799
mean(u_h) 2.833333333333336 (target 2.833333333333333)
|mean(u_h) - gamma| 3.109e-15
L2(u_h - u_ex) 4.748e-11
cond(K^TAK) 4.576e+05
tol = 100 * np.finfo(default_scalar_type()).eps
assert abs(mean_value - gamma) < tol
u_ex lies in the discrete space, so the discretization is exact. The
remaining error is the conditioning of the dense reduced operator: a direct
solve loses roughly \(\log_{10}\kappa\) digits, so the bound below scales with
the measured \(\kappa(K^TAK)\) rather than a fixed constant – a flat bound
tight enough for float64 would be far too strict for a lower-precision
build, where tol itself is orders of magnitude larger to begin with.
assert error < 10 * cond_mpc * tol
Relation to a real space#
The constrained problem has the saddle point form
where \(\lambda\) is the scalar Lagrange multiplier carried by a real space. Since \(\mathrm{range}(K)=\{v: w^Tv=0\}\), the first block row says \(b-Au \in \mathrm{span}(w)\), i.e. \(K^T(b-Au)=0\); together with \(w^Tu=\gamma\) that is the reduced system \(K^TAK\hat{u}=K^T(b-Ag)\).
The same problem, solved as a saddle point system with a scalar multiplier in
a real space. Because the real basis function is identically one,
inner(lam, v) * dx assembles the dense row \(w\) directly. The right hand side
entry of the constraint row must be \(\gamma\) itself, so the constant we
integrate against the real test function is \(\gamma/|\Omega|\).
def solve_real_space(domain, degree, u_ex, value):
"""Reference solve using a Lagrange multiplier in a real space."""
V = fem.functionspace(domain, ("Lagrange", degree))
R = fem.functionspace(domain, basix.ufl.real_element(domain.basix_cell(), dtype=domain.geometry.x.dtype))
W = ufl.MixedFunctionSpace(V, R)
u, lam = ufl.TrialFunctions(W)
v, mu = ufl.TestFunctions(W)
n = ufl.FacetNormal(domain)
f = -ufl.div(ufl.grad(u_ex))
volume = domain.comm.allreduce(
fem.assemble_scalar(fem.form(fem.Constant(domain, default_scalar_type(1.0)) * ufl.dx)), op=MPI.SUM
)
a = ufl.inner(ufl.grad(u), ufl.grad(v)) * ufl.dx + ufl.inner(lam, v) * ufl.dx + ufl.inner(u, mu) * ufl.dx
L = (
ufl.inner(f, v) * ufl.dx
+ ufl.inner(ufl.dot(ufl.grad(u_ex), n), v) * ufl.ds
+ ufl.inner(fem.Constant(domain, default_scalar_type(value / volume)), mu) * ufl.dx
)
problem = fem.petsc.LinearProblem(
ufl.extract_blocks(a),
ufl.extract_blocks(L),
bcs=[],
kind="mpi",
petsc_options_prefix="demo_mean_value_real_",
petsc_options={
"ksp_type": "preonly",
"pc_type": "lu",
"pc_factor_mat_solver_type": "mumps",
"ksp_error_if_not_converged": True,
},
)
uh, lamh = problem.solve()
# The problem is returned alongside the solution: it owns the PETSc matrix,
# which is destroyed with it.
return uh, lamh, problem
We compare the solution of the Lagrange multiplier problem to the one with the multi-point constraint.
_t2 = time.perf_counter()
u_real, lam_real, real_problem = solve_real_space(domain, degree, u_ex, gamma)
comm.Barrier()
t_real = time.perf_counter() - _t2
num_owned = V.dofmap.index_map.size_local * V.dofmap.index_map_bs
assert isinstance(uh, fem.Function)
_diff = np.max(np.abs(uh.x.array[:num_owned] - u_real.x.array[:num_owned])) if num_owned else 0.0
mpc_vs_real = comm.allreduce(_diff, op=MPI.MAX)
# The real-space (saddle point) solve keeps A's own, much better conditioning,
# so this difference is dominated by uh's error and needs the same
# conditioning-aware bound as the check against u_ex above.
assert mpc_vs_real < 10 * cond_mpc * tol
print(f" max|u_mpc - u_real| {mpc_vs_real:.3e}")
max|u_mpc - u_real| 1.148e-10
Cost and conditioning#
Eliminating the slave is not free. Order the degrees of freedom with the masters \(m\) first and the slave \(s\) last, so that the constraint above is \(u=K\hat{u}+g\) and both \(A\) and \(K\) split as
where \(c\) collects the coefficients of the one slave and \(A_{ss}\) is a scalar. Multiplying out,
The final term is a rank one matrix that is dense over every pair of
masters that survive filtering, since \(c\) has no near-zero entries left once
they are dropped. For a cell integral nearly every degree of freedom is a
master – only the P2 vertex dofs are filtered out, since a P2 vertex basis
function integrates to exactly zero on simplices – so \(K^TAK\) is essentially
full, and its norm is inflated by \(\lVert c\rVert^2\), which costs roughly a
factor \(M\) in the condition number. The saddle point system pays neither
price: it keeps \(A\) intact and
appends one row and column. That row is itself dense – every \(w_i\) is stored,
so the system grows by exactly \(2N\) entries – but \(2N\) is linear in the
problem size, against the \(M^2\) of the elimination. operator_stats,
defined and used above to make the verification tolerance conditioning-aware,
measures both effects; the sweep below repeats it over a refinement range.
Cost and conditioning, measured#
The helper below repeats the solve over a small refinement sweep so that the growth is visible rather than asserted.
def measure(N: int) -> dict:
"""Solve at resolution ``N`` and collect cost, conditioning and timings."""
domain = mesh.create_unit_square(MPI.COMM_WORLD, N, N)
comm = domain.comm
V = fem.functionspace(domain, ("Lagrange", degree))
x = ufl.SpatialCoordinate(domain)
u_ex = x[0] ** 2 - x[0] + C
a, L = build_poisson_forms(V, u_ex)
gamma = exact_mean(domain, u_ex)
comm.Barrier()
t0 = time.perf_counter()
mpc, num_masters = build_constraint(V, ufl.conj(ufl.TestFunction(V)) * ufl.dx, gamma)
comm.Barrier()
t1 = time.perf_counter()
problem = LinearProblem(a, L, mpc, bcs=[], petsc_options=petsc_options)
problem.solve()
comm.Barrier()
t2 = time.perf_counter()
t3 = time.perf_counter()
_, _, real_problem = solve_real_space(domain, degree, u_ex, gamma)
comm.Barrier()
t_real = time.perf_counter() - t3
A_plain = fem.petsc.assemble_matrix(fem.form(a), bcs=[])
A_plain.assemble()
nnz_A, cond_A = operator_stats(A_plain, comm, singular=True)
nnz_mpc, cond_mpc = operator_stats(problem.A, comm)
nnz_real, cond_real = operator_stats(real_problem.A, comm)
A_plain.destroy()
return {
"N": V.dofmap.index_map.size_global * V.dofmap.index_map_bs,
"M": num_masters,
"norm_c": float(np.linalg.norm(mpc.coefficients()[0])),
"nnz_A": nnz_A,
"nnz_mpc": nnz_mpc,
"nnz_real": nnz_real,
"cond_A": cond_A,
"cond_mpc": cond_mpc,
"cond_real": cond_real,
"t_constraint": t1 - t0,
"t_mpc": t2 - t1,
"t_real": t_real,
}
rows = [measure(N) for N in (8, 12, 16)]
COLUMNS = {
"N": "dofs",
"M": "masters",
"norm_c": "||c||",
"nnz_A": "nnz(A)",
"nnz_mpc": "nnz(KtAK)",
"nnz_real": "nnz(saddle)",
"cond_A": "cond(A)",
"cond_mpc": "cond(KtAK)",
"cond_real": "cond(saddle)",
"t_constraint": "build [s]",
"t_mpc": "mpc [s]",
"t_real": "real [s]",
"mpc/real": "mpc/real",
}
FORMATS = {
"||c||": "{:.2f}",
"cond(A)": "{:.3e}",
"cond(KtAK)": "{:.3e}",
"cond(saddle)": "{:.3e}",
"build [s]": "{:.4f}",
"mpc [s]": "{:.4f}",
"real [s]": "{:.4f}",
"mpc/real": "{:.2f}",
}
table = pandas.DataFrame(rows)
table["mpc/real"] = table["t_mpc"] / table["t_real"]
table = table[list(COLUMNS)].rename(columns=COLUMNS).set_index("dofs")
table.style.format(FORMATS)
| masters | ||c|| | nnz(A) | nnz(KtAK) | nnz(saddle) | cond(A) | cond(KtAK) | cond(saddle) | build [s] | mpc [s] | real [s] | mpc/real | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| dofs | ||||||||||||
| 289 | 207 | 13.53 | 3073 | 46967 | 3651 | 3.300e+02 | 3.088e+04 | 3.300e+02 | 0.0014 | 0.0112 | 0.0065 | 1.72 |
| 625 | 455 | 20.47 | 6817 | 216141 | 8067 | 7.018e+02 | 1.486e+05 | 7.018e+02 | 0.0011 | 0.0472 | 0.0073 | 6.46 |
| 1089 | 799 | 27.40 | 12033 | 654503 | 14211 | 1.212e+03 | 4.576e+05 | 1.212e+03 | 0.0012 | 0.1494 | 0.0091 | 16.42 |
The table shows nnz(KtAK) growing like \(M^2\) and cond(KtAK) like
\(\lVert c\rVert^2\sim M\), because the rank one term \(cc^TA_{ss}\) couples every
master to every other. Here the functional is supported on the whole mesh, so
\(M\) is the number of degrees of freedom (bar the vertex dofs, whose weights
vanish for P2 on simplices) and the reduced operator is essentially full. A
real space instead appends a single row and column – dense, but only \(2N\)
entries, so nnz(saddle) grows linearly where nnz(KtAK) grows quadratically
– and leaves the conditioning of \(A\) untouched.
The timings say the same in wall clock terms. Building the constraint is cheap and mesh independent; what costs is solving with the operator it produces, and the ratio to the real space solve grows with the mesh. For a functional supported on a facet \(M\) is far smaller and both penalties shrink with it, see Prescribing a boundary average with an affine multi-point constraint.
Visualization#
Each process builds a PyVista grid over the cells it owns, so a shared cell is not drawn twice, and the grids are gathered onto one process and drawn into a single figure with common colour limits.
pyvista.global_theme.allow_empty_mesh = True
def gather_grids(u: fem.Function, V: fem.FunctionSpace, name: str, root: int = 0):
"""Owned-cell PyVista grids with ``u`` attached, gathered on ``root``.
Returns the list of grids on rank 0 (``None`` elsewhere) and the global
value range, so that every piece can be drawn with the same colour limits.
"""
comm = V.mesh.comm
bs = V.dofmap.index_map_bs
tdim = V.mesh.topology.dim
owned_cells = np.arange(V.mesh.topology.index_map(tdim).size_local, dtype=np.int32)
grid = pyvista.UnstructuredGrid(*plot.vtk_mesh(V, entities=owned_cells))
# vtk_mesh emits one point per dof block, in local numbering
values = u.x.array.real[: grid.n_points * bs]
grid.point_data[name] = values if bs == 1 else values.reshape(-1, bs)
local = np.linalg.norm(values.reshape(-1, bs), axis=1) if bs > 1 else values
lo = comm.allreduce(float(local.min()) if local.size else np.inf, op=MPI.MIN)
hi = comm.allreduce(float(local.max()) if local.size else -np.inf, op=MPI.MAX)
# gather returns the list on `root` and None everywhere else, so the caller
# can test the result instead of comparing ranks itself
return comm.gather(grid, root=root), [lo, hi]
# `uh` lives in the constraint's extended space, which carries the master dofs as
# extra ghosts, so its array is longer than the original space in parallel. The
# extended index map keeps the original dofs first, so the leading entries are
# exactly the values of the original space.
u_plot = fem.Function(V)
u_plot.x.array[:] = uh.x.array[: u_plot.x.array.size]
pieces, clim = gather_grids(u_plot, V, "u")
# The solution varies only with $x$, and the constraint has picked the vertical
# offset $C-1/6$ out of the one parameter family the pure Neumann problem admits.
# Warping by the value makes that offset visible as the height above zero.
if pieces is not None: # only the root process received the grids
plotter = pyvista.Plotter(window_size=[700, 500])
plotter.add_text(f"mean(u) = {mean_value:.6f}", font_size=10)
for piece in pieces:
plotter.add_mesh(
piece.warp_by_scalar("u", factor=0.15),
scalars="u",
cmap="viridis",
clim=clim,
show_edges=False,
scalar_bar_args={"vertical": True},
)
plotter.view_isometric() # type: ignore[call-arg]
plotter.camera.zoom(1.4)
# The figure is named after the demo, as the gallery of the documentation expects
figure = Path("demo_mean_value_constraint.py")
if pyvista.OFF_SCREEN:
plotter.screenshot(figure.with_suffix(".png"))
else:
# The interactive scene, for the gallery
plotter.export_vtksz(figure.with_suffix(".vtksz"))
plotter.show(screenshot=figure.with_suffix(".png"))
/tmp/ipykernel_2376/1621171634.py:59: PyVistaDeprecationWarning: Plotter.export_vtksz is deprecated. Use plotter.trame.export_vtksz(...) instead. Install via `pip install trame-pyvista`.
plotter.export_vtksz(figure.with_suffix(".vtksz"))