Checkpointing a submesh#
A submesh created with dolfinx.mesh.create_submesh() cannot be
checkpointed the way an ordinary mesh is, for two reasons that both come from
how DOLFINx builds it.
First, when the submesh has co-dimension greater than zero its cells are entities of the parent, and the vertices of an entity are ordered by their current global vertex index. Reading the parent back re-partitions it, which renumbers the vertices, so the same cell comes back with its vertices in a different order and its degrees of freedom in different positions.
Second, create_submesh builds its vertex
map allowing ownership to move, so a process can own a vertex and a degree of
freedom that is only incident to the cells it ghosts.
Such a dof has no position in any cell that process owns,
which is exactly what the checkpoint reader needs to find it.
So io4dolfinx never reads a function onto a re-derived submesh. It stores the submesh as an ordinary, independent mesh, reads it back as one, and then moves the data across to a submesh re-derived from the parent when you need the entity maps that make mixed-dimensional assembly possible.
from pathlib import Path
from mpi4py import MPI
import dolfinx
import numpy as np
import io4dolfinx
mesh = dolfinx.mesh.create_unit_cube(
MPI.COMM_WORLD, 6, 6, 6, ghost_mode=dolfinx.mesh.GhostMode.shared_facet
)
tdim = mesh.topology.dim
We take the face \(x=0\) as the submesh. It is a two-dimensional mesh embedded in three dimensions – a manifold – which the checkpoint handles just as well as a co-dimension 0 submesh.
fdim = tdim - 1
mesh.topology.create_entities(fdim)
facets = dolfinx.mesh.locate_entities(mesh, fdim, lambda x: np.isclose(x[0], 0.0))
submesh, cell_map, vertex_map, node_map = dolfinx.mesh.create_submesh(mesh, fdim, facets)
V = dolfinx.fem.functionspace(submesh, ("Lagrange", 2))
u = dolfinx.fem.Function(V, name="u")
u.interpolate(lambda x: np.sin(3 * x[1]) + x[2])
Writing#
The parent goes in first. io4dolfinx.write_submesh() then stores the
submesh under a name of its own, together with its post codes: each parent
entity that became a submesh cell is tagged, by its parent geometry nodes, with
the index that cell has in the stored submesh. Addressing the entities that way
is what lets them be found again in a re-partitioned parent, and the values are
what the data is later routed by.
The post codes tag entities of the parent, so they are stored alongside the parent.
Here everything goes in one file; pass parent_filename to keep the submesh in
a file of its own.
filename = Path("submesh_checkpoint.bp")
io4dolfinx.write_mesh(filename, mesh)
io4dolfinx.write_submesh(filename, submesh, mesh, cell_map, mesh_name="wall")
io4dolfinx.write_function(filename, u, time=0.0, mode=io4dolfinx.FileMode.append, mesh_name="wall")
Reading#
Both meshes are read independently. Because the submesh was stored as an ordinary mesh, reading a function on it is the ordinary path – no special handling, and the values come back exactly.
parent = io4dolfinx.read_mesh(filename, MPI.COMM_WORLD)
stored = io4dolfinx.read_mesh(filename, MPI.COMM_WORLD, mesh_name="wall")
V_stored = dolfinx.fem.functionspace(stored, ("Lagrange", 2))
u_stored = dolfinx.fem.Function(V_stored, name="u")
io4dolfinx.read_function(filename, u_stored, time=0.0, name="u", mesh_name="wall")
If all you wanted was the data, stop here. If you need to assemble a
mixed-dimensional form over the parent and the submesh, you need a submesh
DOLFINx recognises as derived from this parent, with the entity maps to prove
it. io4dolfinx.read_submesh() re-derives one.
checkpoint = io4dolfinx.read_submesh(filename, parent, mesh_name="wall")
V_sub = dolfinx.fem.functionspace(checkpoint.submesh, ("Lagrange", 2))
u_sub = dolfinx.fem.Function(V_sub, name="u")
io4dolfinx.transfer_submesh_function(u_stored, u_sub, checkpoint.post_code)
The checkpoint stores the
cell_map and
vertex_map and
node_map that are
similar to the ones created by dolfinx.mesh.create_submesh().
It also carries the
post_code that the transfer
above was given, which is worth a word of its own.
The post office#
The stored submesh and the re-derived one are partitioned independently, so
neither knows where the other put a given cell. What they agree on is a cell’s
post code: the index it had in the stored submesh. The stored submesh knows
it as its ordinary topology.original_cell_index, being a mesh like any other.
The re-derived one cannot – dolfinx.mesh.create_submesh() does not
give a submesh an original cell index, and for a submesh of co-dimension
greater than zero there is none to give, since the parent’s input data numbers
cells and not facets. So the post code is what
io4dolfinx.write_submesh() tags onto the parent and
io4dolfinx.read_submesh() hands back.
Resolving one works like posting a letter. No process holds a global table of
who owns what. Instead the post code itself decides which process acts as its
post office, by a rule every process applies identically
(io4dolfinx.utils.index_owner(), an equal split of the global cell range).
Each process publishes to the post office of every cell it owns, saying where it
keeps that cell; each process then asks the post office of every cell it wants.
Two neighbourhood exchanges, and the memory per process stays proportional to
the cells it actually touches.
The reply; a rank and a local cell, is what makes the transfer a routing rather than a search. Each interpolation point goes straight to the process and cell that can evaluate it, so no geometric point location is involved, there is no search tolerance to tune, and nothing can silently fail to find a point.
reference = dolfinx.fem.Function(V_sub)
reference.interpolate(lambda x: np.sin(3 * x[1]) + x[2])
num_owned = V_sub.dofmap.index_map.size_local * V_sub.dofmap.index_map_bs
error = np.max(np.abs(u_sub.x.array[:num_owned] - reference.x.array[:num_owned]))
print(f"Max error after transfer: {parent.comm.allreduce(error, MPI.MAX):.3e}")
Max error after transfer: 8.882e-16
import ufl # noqa: E402
one = dolfinx.fem.Function(V_sub)
one.x.array[:] = 1.0
area_form = dolfinx.fem.form(one * ufl.ds(domain=parent), entity_maps=[checkpoint.cell_map])
area = parent.comm.allreduce(dolfinx.fem.assemble_scalar(area_form), MPI.SUM)
print(f"Area of the submesh assembled over the parent: {area:.3f}")
Area of the submesh assembled over the parent: 1.000
H(div) and H(curl) on a manifold#
On a submesh of co-dimension greater than zero, H(div) and H(curl) spaces depend on two DOLFINx changes that are on the main branch but in no release yet (0.11 and earlier lack them):
FEniCS/dolfinx#4511 gives a finite element its value size in physical space, which differs from the reference one when
tdim < gdim(FEniCS/dolfinx#3619).FEniCS/dolfinx#4625 signs the facets on which neighbouring cells disagree on the normal, without which a contravariant Piola space such as Raviart-Thomas is not conforming. Call
create_facet_orientationson the topology of every manifold mesh before creating such a space on it – the original submesh, the stored one and the re-derived one – or usefenicsx_compat.create_facet_orientations, which does nothing on DOLFINx versions without it.
With both, writing, reading and transferring are exact for these spaces too.
On older DOLFINx they can still be written and read exactly, as under
Writing and Reading above, but not transferred to a re-derived submesh:
transfer_submesh_function raises NotImplementedError. There,
interpolate_nonmatching returns successfully with values that are wrong,
and the guard makes that an error rather than a plausible-looking result.
There is no sound workaround: the stored submesh is an independent mesh,
derived from nothing, so a projection – or any other form assembled between it
and the re-derived submesh – would have no basis.