Bulk–surface coupling through a submesh#
Author Jørgen S. Dokken
Some boundary conditions carry a differential equation of their own, posed on the
boundary: a Wentzell condition [Wen59], a membrane bonded to
a body, a thin conducting layer. A natural discretisation puts that equation on a
submesh of the boundary facets, with its own function space, and ties the surface
field to the trace of the bulk field. This demo makes the tie a dolfinx_mpc.MultiPointConstraint
between the two spaces, created with
dolfinx_mpc.MultiPointConstraint.create_submesh_constraint(). The relation
between a facet of the submesh and the cell of the parent mesh holding it comes from
the entity map of dolfinx.mesh.create_submesh(), so no geometric search is
needed.
As in Fixing a mean value with an affine multi-point constraint, the constraint is compared with a Lagrange multiplier imposing the same tie weakly. The two give the same solution; they differ in the system that has to be solved.
Mathematical formulation#
Let \(\Omega=(0,1)^2\) and \(\Gamma=(0,1)\times\{1\}\) its top edge. We seek \(u\) with
where \(\Delta_\Gamma = \partial^2/\partial x^2\) is the Laplacian along \(\Gamma\). The weak form seeks \(u\in H^1(\Omega)\) with trace \(u|_\Gamma\in H^1(\Gamma)\) such that
for all such \(v\). Writing \(u_\Gamma\) for the surface field, the problem becomes one for the pair \((u, u_\Gamma)\in V\times V_\Gamma\), with \(V\) on \(\Omega\) and \(V_\Gamma\) on \(\Gamma\), under the constraint \(u_\Gamma = u|_\Gamma\):
The form is block diagonal, each block assembled on its own mesh. The constraint makes every degree of freedom of \(u_\Gamma\) a slave of the trace of \(u\), so the reduced operator \(K^TAK\) adds the surface operator to the bulk degrees of freedom on \(\Gamma\).
We use the manufactured solution \(u=\cos(\pi x)(1+y^2)\), whose normal derivative vanishes on the three other edges and whose tangential derivative vanishes at the ends of \(\Gamma\). Then \(f=-\Delta u+u\) and, on \(y=1\), \(g=\partial_y u-\partial_{xx}u+u=\cos(\pi x)\left(2y+(\pi^2+1)(1+y^2)\right)\).
The two meshes#
The surface mesh is a submesh of the facets on \(\Gamma\), created with
dolfinx.mesh.create_submesh(). Besides the submesh, it returns an
EntityMap relating each cell of the submesh to its
facet of the parent mesh, a second one relating their vertices, and the parent’s
geometry node of each node of the submesh. Only the first map is needed here.
def create_meshes(N: int):
msh = mesh.create_unit_square(MPI.COMM_WORLD, N, N, dtype=default_real_type)
fdim = msh.topology.dim - 1
facets = mesh.locate_entities_boundary(msh, fdim, lambda x: np.isclose(x[1], 1.0))
surface, entity_map, _, _ = mesh.create_submesh(msh, fdim, facets)
tags = mesh.meshtags(msh, fdim, facets, np.full(len(facets), 1, dtype=np.int32))
return msh, surface, entity_map, tags
The coupled problem#
The constraint is created on the constraint of the surface space, with the bulk space as the master space, and the two constraints are finalized together, as their blocks depend on each other.
def create_forms(V, V_G):
"""The bulk and surface problems, without the tie between them."""
msh, surface = V.mesh, V_G.mesh
W = ufl.MixedFunctionSpace(V, V_G)
u, u_G = ufl.TrialFunctions(W)
v, v_G = ufl.TestFunctions(W)
x, x_G = ufl.SpatialCoordinate(msh), ufl.SpatialCoordinate(surface)
dx, dx_G = ufl.Measure("dx", domain=msh), ufl.Measure("dx", domain=surface)
f = -ufl.div(ufl.grad(u_exact(x))) + u_exact(x)
a = (ufl.inner(ufl.grad(u), ufl.grad(v)) + ufl.inner(u, v)) * dx
a += (ufl.inner(ufl.grad(u_G), ufl.grad(v_G)) + ufl.inner(u_G, v_G)) * dx_G
L = ufl.inner(f, v) * dx + ufl.inner(g_exact(x_G), v_G) * dx_G
return ufl.extract_blocks(a), ufl.extract_blocks(L)
def tie(V, V_G, entity_map):
"""Tie the surface field to the trace of the bulk field."""
mpc = dolfinx_mpc.MultiPointConstraint(V)
mpc_G = dolfinx_mpc.MultiPointConstraint(V_G)
mpc_G.create_submesh_constraint(V_G, V, entity_map)
dolfinx_mpc.finalize_multipointconstraints([mpc, mpc_G])
return [mpc, mpc_G]
def solve_coupled(V, V_G, constraints, kind):
a, L = create_forms(V, V_G)
problem = dolfinx_mpc.LinearProblem(
a,
L,
constraints,
bcs=[],
kind=kind,
petsc_options_prefix=f"demo_bulk_surface_{kind}_",
petsc_options=direct,
)
uh, uh_G = problem.solve()
# The problem owns the matrix, which is destroyed with it
return uh, uh_G, problem
def create_spaces(msh, surface, degree: int):
return fem.functionspace(msh, ("Lagrange", degree)), fem.functionspace(surface, ("Lagrange", degree))
The same tie with a Lagrange multiplier#
Instead of eliminating \(u_\Gamma\), the tie can be imposed weakly with a multiplier \(\lambda\in\Lambda\) on \(\Gamma\), which adds a third field and a saddle point:
The coupling terms are integrated over the facets of \(\Gamma\) on the bulk mesh and carry arguments from the surface mesh, so they need the entity map. With \(\Lambda\) the same space as \(V_\Gamma\), the last equation holds for \(\mu = u_\Gamma - u\), so \(u_\Gamma = u|_\Gamma\) exactly, and the solution is that of the constraint.
def solve_multiplier(V, V_G, entity_map, tags):
msh, surface = V.mesh, V_G.mesh
Q = V_G.clone()
W = ufl.MixedFunctionSpace(V, V_G, Q)
u, u_G, lmbda = ufl.TrialFunctions(W)
v, v_G, mu = ufl.TestFunctions(W)
x, x_G = ufl.SpatialCoordinate(msh), ufl.SpatialCoordinate(surface)
dx, dx_G = ufl.Measure("dx", domain=msh), ufl.Measure("dx", domain=surface)
ds = ufl.Measure("ds", domain=msh, subdomain_data=tags, subdomain_id=1)
f = -ufl.div(ufl.grad(u_exact(x))) + u_exact(x)
a = (ufl.inner(ufl.grad(u), ufl.grad(v)) + ufl.inner(u, v)) * dx
a += (ufl.inner(ufl.grad(u_G), ufl.grad(v_G)) + ufl.inner(u_G, v_G)) * dx_G
a += ufl.inner(lmbda, v_G - v) * ds + ufl.inner(u_G - u, mu) * ds
# The multiplier has no load, which `ufl.extract_blocks` would drop, so the blocks of the
# right-hand side are given by hand
L = [ufl.inner(f, v) * dx, ufl.inner(g_exact(x_G), v_G) * dx_G, ufl.ZeroBaseForm((mu,))]
problem = dolfinx.fem.petsc.LinearProblem(
ufl.extract_blocks(a),
L,
kind="mpi",
entity_maps=[entity_map],
petsc_options_prefix="demo_bulk_surface_multiplier_",
petsc_options=direct,
)
uh, uh_G, _ = problem.solve()
return uh, uh_G, problem
Verification#
Both layouts of the constrained system, nest and monolithic, against the multiplier formulation, for the bulk and the surface field.
msh, surface, entity_map, tags = create_meshes(16)
V, V_G = create_spaces(msh, surface, 1)
u_ref, u_ref_G, _ = solve_multiplier(V, V_G, entity_map, tags)
tol = 500 * np.finfo(default_real_type).eps
def max_difference(uh, u_ref):
n = u_ref.function_space.dofmap.index_map.size_local
difference = np.max(np.abs(uh.x.array[:n] - u_ref.x.array[:n]), initial=0)
return msh.comm.allreduce(difference, op=MPI.MAX)
u_max = msh.comm.allreduce(np.max(np.abs(u_ref.x.array), initial=0), op=MPI.MAX)
for kind in ("nest", "mpi"):
uh, uh_G, _ = solve_coupled(V, V_G, tie(V, V_G, entity_map), kind)
differences = (max_difference(uh, u_ref), max_difference(uh_G, u_ref_G))
if msh.comm.rank == 0:
print(f"kind={kind!s:5} max |u - u_ref| = {differences[0]:.2e}, max |u_G - u_ref_G| = {differences[1]:.2e}")
assert max(differences) < tol * u_max
kind=nest max |u - u_ref| = 5.11e-15, max |u_G - u_ref_G| = 5.55e-15
kind=mpi max |u - u_ref| = 5.11e-15, max |u_G - u_ref_G| = 5.55e-15
The bulk solution, raised by its value, and the surface field, drawn as a tube on its top edge, where it lies on the trace of the bulk solution. Each process draws the cells it owns, which are gathered on the first process.
pyvista.global_theme.allow_empty_mesh = True
def gathered_grid(u, V, name):
"""The cells this process owns as a PyVista grid with the values of `u`, gathered on the first process.
`u` may live in the extended space of a constraint, whose leading entries are those of `V`.
"""
tdim = V.mesh.topology.dim
owned = np.arange(V.mesh.topology.index_map(tdim).size_local, dtype=np.int32)
grid = pyvista.UnstructuredGrid(*plot.vtk_mesh(V, entities=owned))
grid.point_data[name] = u.x.array.real[: grid.n_points]
pieces = V.mesh.comm.gather(grid, root=0)
# A process may own no cells, as of an interface, and its grid then holds no values
return None if pieces is None else [piece for piece in pieces if piece.n_points > 0]
def value_range(pieces, name):
return [min(p[name].min(initial=np.inf) for p in pieces), max(p[name].max(initial=-np.inf) for p in pieces)]
bulk_pieces = gathered_grid(uh, V, "u")
surface_pieces = gathered_grid(uh_G, V_G, "u")
if bulk_pieces is not None: # only the first process received the grids
u_range = value_range(bulk_pieces + surface_pieces, "u")
plotter = pyvista.Plotter(window_size=[800, 600])
for piece in bulk_pieces:
warped = piece.warp_by_scalar("u", factor=0.2, normal=(0.0, 0.0, 1.0))
plotter.add_mesh(
warped,
scalars="u",
cmap="viridis",
clim=u_range,
show_edges=True,
edge_color="gray",
scalar_bar_args={"vertical": True, "position_x": 0.85, "position_y": 0.2},
)
for piece in surface_pieces:
warped = piece.warp_by_scalar("u", factor=0.2, normal=(0.0, 0.0, 1.0))
plotter.add_mesh(warped, color="red", line_width=8, render_lines_as_tubes=True)
plotter.view_isometric()
# The figure is named after the demo, as the gallery of the documentation expects
figure = Path("demo_bulk_surface.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_1963/261869298.py:48: 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"))
Cost and conditioning#
As in Fixing a mean value with an affine multi-point constraint, both formulations are solved with MUMPS, and compared through the number of nonzeros and the 2-norm condition number of the operator, against the bulk and surface problems without the tie, \(A\).
The constrained operator \(K^TAK\) keeps a row for every degree of freedom of both spaces, the rows of the slaves holding only their diagonal, and stays symmetric positive definite. The multiplier adds a third block of the size of \(V_\Gamma\) and makes the system a saddle point.
slaves nnz(A) nnz(KtAK) nnz(saddle) cond(A) cond(KtAK) cond(saddle) build [s] mpc [s] multiplier [s] mpc/multiplier
dofs
90 9 522 572 654 2.523e+03 1.574e+03 5.438e+04 0.0005 0.0064 0.0081 0.79
182 13 1118 1192 1314 8.007e+03 4.860e+03 1.874e+05 0.0004 0.0056 0.0072 0.77
306 17 1938 2036 2198 1.836e+04 1.099e+04 4.501e+05 0.0005 0.0060 0.0076 0.79
| slaves | nnz(A) | nnz(KtAK) | nnz(saddle) | cond(A) | cond(KtAK) | cond(saddle) | build [s] | mpc [s] | multiplier [s] | mpc/multiplier | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| dofs | |||||||||||
| 90 | 9 | 522 | 572 | 654 | 2.523e+03 | 1.574e+03 | 5.438e+04 | 0.0005 | 0.0064 | 0.0081 | 0.79 |
| 182 | 13 | 1118 | 1192 | 1314 | 8.007e+03 | 4.860e+03 | 1.874e+05 | 0.0004 | 0.0056 | 0.0072 | 0.77 |
| 306 | 17 | 1938 | 2036 | 2198 | 1.836e+04 | 1.099e+04 | 4.501e+05 | 0.0005 | 0.0060 | 0.0076 | 0.79 |
Each slave has a single master, the bulk degree of freedom at the same point, so the reduced operator is barely denser than the problems without the tie: the surface operator is added to rows of \(A\) that already couple the same degrees of freedom. Its condition number, \(1.6\times10^3\) to \(1.1\times10^4\) on these meshes, is even somewhat below that of \(A\), while the saddle point is 35 to 41 times worse than \(K^TAK\), and the gap widens under refinement. Both systems are small enough here that MUMPS solves them in a few milliseconds, the constrained one slightly faster; the conditioning is what an iterative solver would feel.
Compare with Fixing a mean value with an affine multi-point constraint, where every degree of freedom is a master of the one slave and the reduced operator fills in. A tie between a submesh and its parent is the opposite case: one master per slave, and the constraint is the cheaper of the two formulations.
Convergence#
Both fields converge at the optimal rate, \(h^{k+1}\) in \(L^2\) for degree \(k\).
def l2_error(uh, x):
error = fem.form(ufl.inner(uh - u_exact(x), uh - u_exact(x)) * ufl.dx)
return np.sqrt(uh.function_space.mesh.comm.allreduce(fem.assemble_scalar(error), op=MPI.SUM).real)
# P2 stops at $N=8$, as its error at $N=16$ approaches rounding in single precision
for degree, resolutions in ((1, (8, 16, 32)), (2, (4, 8))):
previous = None
for N in resolutions:
msh, surface, entity_map, tags = create_meshes(N)
V, V_G = create_spaces(msh, surface, degree)
uh, uh_G, _ = solve_coupled(V, V_G, tie(V, V_G, entity_map), "mpi")
errors = (l2_error(uh, ufl.SpatialCoordinate(msh)), l2_error(uh_G, ufl.SpatialCoordinate(surface)))
if previous is not None:
rates = [np.log2(e0 / e1) for e0, e1 in zip(previous, errors)]
if msh.comm.rank == 0:
print(
f"P{degree} N={N:3d} |u-u_ex| = {errors[0]:.2e} (rate {rates[0]:.2f})"
f" |u_G-u_ex| = {errors[1]:.2e} (rate {rates[1]:.2f})"
)
previous = errors
assert min(rates) > degree + 0.8
P1 N= 16 |u-u_ex| = 2.83e-03 (rate 1.99) |u_G-u_ex| = 5.34e-03 (rate 1.99)
P1 N= 32 |u-u_ex| = 7.09e-04 (rate 2.00) |u_G-u_ex| = 1.34e-03 (rate 2.00)
P2 N= 8 |u-u_ex| = 3.58e-04 (rate 2.99) |u_G-u_ex| = 4.90e-04 (rate 2.98)
A. D. Wentzell. On boundary conditions for multidimensional diffusion processes. Theory of Probability and its Applications, 4(2):164–177, 1959. doi:10.1137/1104014.