The eikonal equation on a Möbius strip#
This demo computes the distance to the boundary of a Möbius strip by solving the eikonal equation with the latent variable proximal point (LVPP) algorithm of Dokken et al. [DFK+25]. It illustrates how to
solve a problem with a pointwise inequality constraint, here \(|\nabla_\Omega u| \le 1\), without ever forming the constraint,
use an \(H(\mathrm{div})\)-conforming latent variable on a surface that cannot be oriented.
Difference from the paper
Dokken et al. [DFK+25] solves the eikonal equation in this Raviart-Thomas mixed form on flat surfaces, and on manifolds without a Raviart-Thomas space. What is new here is the combination: a Raviart-Thomas latent variable on a manifold, and one that cannot be oriented at that.
Download sources
Equation and problem definition#
The distance to the boundary of a domain \(\Omega\) solves the eikonal equation
where \(\nabla_\Omega\) is the gradient along the surface. It is the limit of a problem with an inequality constraint: among all \(u\) with \(|\nabla_\Omega u| \le 1\) and \(u = 0\) on the boundary, the distance function is the largest, so it maximises \(\int_\Omega f u\) for \(f \equiv 1\).
The latent variable#
LVPP never imposes the constraint directly. It introduces a latent variable \(\psi\), unconstrained, and recovers the gradient from it through a pointwise map whose range is exactly the feasible set. For the unit ball that map is
so \(|\nabla_\Omega u| < 1\) holds for every \(\psi\), and the constraint becomes active only in the limit \(|\psi| \to \infty\). The non-smoothness of the constraint is thereby traded for the growth of \(\psi\), which is what makes each subproblem a smooth one that Newton’s method handles.
The proximal point iteration supplies that limit. Given the previous latent variable \(\psi^{k-1}\) and a step size \(\alpha_k > 0\), the \(k\)-th subproblem is to find \((u^k, \psi^k)\) with
Increasing \(\alpha_k\) drives \(|\psi^k|\) up and \(|\nabla_\Omega u^k|\) towards one. The iteration stops when \(u\) settles.
Mixed form#
Taking \(\psi\) in an \(H(\mathrm{div})\)-conforming space and \(u\) in a discontinuous Lagrange space, and testing the second equation against \(\tau\) with an integration by parts that leaves no boundary term because \(u = 0\) on \(\partial\Omega\), the subproblem reads: find \((u, \psi) \in Q \times V\) with
for all \((v, \tau) \in Q \times V\). The Dirichlet condition on \(u\) is natural here, so no boundary condition is imposed on the space.
Raviart-Thomas is mapped by the contravariant Piola map, which on a surface needs the two cells of each facet to agree on its normal. On a Möbius strip no orientation of the cells achieves that, so the facets where they disagree carry a sign instead; see Mixed Poisson on a Möbius strip.
Implementation#
import contextlib
from mpi4py import MPI
import numpy as np
import basix
import basix.ufl
import dolfinx
import dolfinx.fem.petsc
import ufl
The strip is n cells around and m across, of half-width width,
on a mesh of degree geometry_degree. The latent variable is of
degree degree and \(u\) one lower.
n, m = 48, 8
geometry_degree = 2
degree = 3
width = 0.92
The mesh#
The strip is built from its parametrisation, the half twist being that the column closing the ring is column \(0\) read upside down. Equispaced Lagrange nodes lie on a lattice of step \(1/k\), so taking every node from the \(k\)-times finer parameter grid makes neighbouring cells share the nodes on their common edge, which is what keeps the geometry continuous. See Mixed Poisson on a Möbius strip, which builds the same surface.
def surface_point(t, s):
"""Map the parameters onto the strip."""
rho = 1 + s * np.cos(t / 2)
z = s * np.sin(t / 2)
return np.column_stack([rho * np.cos(t), rho * np.sin(t), z])
def mobius_mesh(n, m, width, k):
"""A Möbius strip, ``n`` cells around and ``m`` across."""
dtype = dolfinx.default_real_type
lattice = basix.create_element(
basix.ElementFamily.P, basix.CellType.triangle, k, basix.LagrangeVariant.equispaced
).points
if MPI.COMM_WORLD.rank == 0:
ni, nj = k * n, k * m
def node(i, j):
return i * (nj + 1) + j if i < ni else nj - j # the half twist
i_grid = np.repeat(np.arange(ni), nj + 1)
j_grid = np.tile(np.arange(nj + 1), ni)
x = surface_point(2 * np.pi * i_grid / ni, -width + 2 * width * j_grid / nj)
cells = []
for i in range(n):
for j in range(m):
a = np.array([k * i, k * j])
b = np.array([k * (i + 1), k * j])
c = np.array([k * i, k * (j + 1)])
d = np.array([k * (i + 1), k * (j + 1)])
for p0, p1, p2 in ((a, b, c), (b, d, c)):
ij = np.rint(
p0 + np.outer(lattice[:, 0], p1 - p0) + np.outer(lattice[:, 1], p2 - p0)
).astype(np.int64)
cells.append([node(int(p), int(q)) for p, q in ij])
cells = np.asarray(cells, dtype=np.int64)
else:
x = np.zeros((0, 3))
cells = np.zeros((0, len(lattice)), dtype=np.int64)
domain = ufl.Mesh(
basix.ufl.element(
"Lagrange", "triangle", k, basix.LagrangeVariant.equispaced, shape=(3,), dtype=dtype
)
)
return dolfinx.mesh.create_mesh(MPI.COMM_WORLD, cells, domain, x.astype(dtype))
mesh = mobius_mesh(n, m, width, geometry_degree)
mesh.topology.create_facet_orientations()
V = dolfinx.fem.functionspace(mesh, ("RT", degree))
Q = dolfinx.fem.functionspace(mesh, ("DG", degree - 1))
W = ufl.MixedFunctionSpace(Q, V)
u = dolfinx.fem.Function(Q, name="u")
psi = dolfinx.fem.Function(V, name="psi")
v, tau = ufl.TestFunctions(W)
# The previous iterate
u0 = dolfinx.fem.Function(Q)
psi0 = dolfinx.fem.Function(V)
The subproblem#
Each step solves the two equations above as one nonlinear system, with
the step size \(\alpha_k\) carried by a Constant so that the forms are compiled once.
dx = ufl.Measure("dx", domain=mesh)
f = dolfinx.fem.Constant(mesh, dolfinx.default_scalar_type(1))
alpha = dolfinx.fem.Constant(mesh, dolfinx.default_scalar_type(1))
latent_map = 1 / ufl.sqrt(1 + ufl.dot(psi, psi))
F = ufl.inner(ufl.div(psi) - ufl.div(psi0), v) * dx + alpha * ufl.inner(f, v) * dx
F += ufl.inner(u, ufl.div(tau)) * dx + latent_map * ufl.inner(psi, tau) * dx
F_blocked: list[ufl.Form] = ufl.extract_blocks(F) # type: ignore[assignment]
tol = max(1e-5, 1000 * float(np.finfo(dolfinx.default_real_type).eps))
problem = dolfinx.fem.petsc.NonlinearProblem(
F_blocked,
[u, psi],
bcs=[],
petsc_options={
"snes_type": "newtonls",
"snes_rtol": tol,
"snes_atol": tol,
"snes_max_it": 100,
"snes_error_if_not_converged": True,
"ksp_type": "preonly",
"pc_type": "lu",
"pc_factor_mat_solver_type": "mumps",
},
petsc_options_prefix="lvpp_step_",
)
Output#
VTX builds its points from the dofmap, so neither a Raviart-Thomas function nor the gradient can be written directly. Both are interpolated into discontinuous Lagrange spaces of the degree of the geometry first.
S = dolfinx.fem.functionspace(mesh, ("DG", mesh.geometry.cmaps[0].degree))
u_out = dolfinx.fem.Function(S, name="u")
S_vec = dolfinx.fem.functionspace(mesh, ("DG", mesh.geometry.cmaps[0].degree, (mesh.geometry.dim,)))
grad_out = dolfinx.fem.Function(S_vec, name="grad_u")
psi_out = dolfinx.fem.Function(S_vec, name="psi")
grad_expr = dolfinx.fem.Expression(ufl.grad(u), S_vec.element.interpolation_points)
The proximal point loop#
The step size is increased geometrically, up to a cap, and the iteration stops once \(u\) stops moving, measured in \(L^2\).
change = dolfinx.fem.form(ufl.inner(u - u0, u - u0) * dx)
newton_iterations = []
with contextlib.ExitStack() as stack:
writers = []
if dolfinx.has_adios2:
writers = [
stack.enter_context(dolfinx.io.VTXWriter(mesh.comm, filename, functions, engine="BP4"))
for filename, functions in [
("mobius_eikonal_u.bp", [u_out]),
("mobius_eikonal_psi.bp", [grad_out, psi_out]),
]
]
for i in range(1, 100):
alpha.value = min(2**i, 10)
problem.solve()
newton_iterations.append(problem.solver.getIterationNumber())
diff = np.sqrt(abs(mesh.comm.allreduce(dolfinx.fem.assemble_scalar(change), op=MPI.SUM)))
if mesh.comm.rank == 0:
print(
f"LVPP {i}: alpha={alpha.value.real:g}, "
f"Newton iterations={newton_iterations[-1]}, |u - u_prev|={diff:.3e}"
)
u0.x.array[:] = u.x.array
psi0.x.array[:] = psi.x.array
u_out.interpolate(u)
grad_out.interpolate(grad_expr)
psi_out.interpolate(psi)
for writer in writers:
writer.write(i)
if diff < 5 * tol:
break
if mesh.comm.rank == 0:
print(f"{i} LVPP iterations, {sum(newton_iterations)} Newton iterations in total")
The constraint is satisfied throughout, and becomes active away from the kink of the distance function. We report the mean slope, which approaches one as the iteration converges.
one = dolfinx.fem.Constant(mesh, dolfinx.default_scalar_type(1))
grad_norm = ufl.sqrt(ufl.inner(ufl.grad(u), ufl.grad(u)))
def integrate(expr):
"""Integrate a real-valued expression over the surface."""
local = dolfinx.fem.assemble_scalar(dolfinx.fem.form(expr * dx))
return abs(mesh.comm.allreduce(local, op=MPI.SUM))
mean_slope = integrate(grad_norm) / integrate(one)
if mesh.comm.rank == 0:
print(f"mean |grad(u)| = {mean_slope:.6f} (1 in the limit)")
References#
Jørgen S. Dokken, Patrick E. Farrell, Brendan Keith, Ioannis P. A. Papadopoulos, and Thomas M. Surowiec. The latent variable proximal point algorithm for variational problems with inequality constraints. Computer Methods in Applied Mechanics and Engineering, 445:118181, 2025. doi:10.1016/j.cma.2025.118181.