Conservation laws

A conservation law is a type of PDE that describes the transport of extensive quantities like mass, momentum, and energy. The most general form of a hyperbolic conservation law for a field $q$ is

$$\frac{\partial q}{\partial t} + \nabla\cdot f(q) = s,$$

where $f$ is the flux function and $s$ the sources. The solution variable $q$ could be a scalar, vector, or tensor field. Here we'll look at the simplest conservation law of them all, the advection equation: $q$ is a scalar field and $f(q) = qu$ for some velocity field $u$. As we'll see, there are lots of ways to screw up something as simple as the advection equation, so learning what the common error modes are will help when attacking harder problems like the shallow water or Euler equations.

One of the challenging things about solving hyperbolic problems is that the class of reasonable solutions includes functions with jump discontinuities, and for some problems this is true even when the initial data are smooth. Compare that to, say, elliptic problems, where the solution is almost always smoother than the input data. For elliptic problems, it's common to use continuous basis functions, but when we try to use the same basis for conservation laws we can run up against some very nasty stability problems. It's possible to work around these issues by using tricks like the streamlined upwind Petrov-Galerkin method. But almost any approach to stabilizing a CG discretization introduces (1) free parameters that can be difficult to tune right and (2) an unrealistic level of numerical diffusion. For these reasons, the discontinuous Galerkin method is very popular for these kinds of problems. The DG method has good local conservation properties, it can achieve high-order accuracy where the solution is smooth, and there are more options in how you guarantee a stable scheme.

DG is a huge subject and I couldn't possibly do justice to it here. If you want to read more about it, this paper by Cockburn and Shu is a great reference, as are these notes by Ralf Hartmann and this dissertation by Michael Crabb. Instead, I'll focus here on the effects of some of the choices you have to make when you solve these types of problems.

Input data

First, we want to create a domain, some function spaces, and a divergence-free velocity field $u$. The classic example is a material in uniform solid-body rotation around some fixed point $y$:

$$u(x) = \hat k \times (x - y)$$

where $\hat k$ is the unit vector in the $z$ direction.

import firedrake
from firedrake import inner, Constant, as_vector
mesh = firedrake.UnitSquareMesh(64, 64, diagonal='crossed')
x = firedrake.SpatialCoordinate(mesh)
y = Constant((.5, .5))
r = x - y
u = as_vector((-r[1], r[0]))

To have a stable timestepping scheme, we'll need to satisfy the Courant-Friedrichs-Lewy condition, which means calculating the maximum speed and the minimum cell diameter. Calculating the maximum speed exactly can be challenging; if $u$ is represented with piecewise linear basis functions, then $|u|^2$ is a quadratic function and thus might not attain its maximum value at the interpolation points. You could work around this by changing to a basis of Bernstein polynomials, but for our purposes it'll be enough to evaluate the maximum at the interpolation points and take a smaller timestep than necessary.

import numpy as np
Q = firedrake.FunctionSpace(mesh, family='CG', degree=2)
speed = firedrake.interpolate(inner(u, u), Q)
max_speed = np.sqrt(speed.dat.data_ro.max())

Q0 = firedrake.FunctionSpace(mesh, family='DG', degree=0)
diameters = firedrake.project(firedrake.CellDiameter(mesh), Q0)
min_diameter = diameters.dat.data_ro.min()

cfl_timestep = min_diameter / max_speed
print('Upper bound for CFL-stable timestep: {}'.format(cfl_timestep))
Upper bound for CFL-stable timestep: 0.022097086912079608

The initial data we'll use will be the classic bell and cone:

$$q_0 = \max\{0, 1 - |x - x_c| / r_c\} + \max\{0, 1 - |x - x_b|^2 / r_b^2\}$$

where $x_c$, $r_c$ are the center and radius of the cone and $x_b$, $r_b$ for the bell.

from firedrake import sqrt, min_value, max_value

x_c = as_vector((5/8, 5/8))
R_c = Constant(1/8)

x_b = as_vector((3/8, 3/8))
R_b = Constant(1/8)

q_expr = (
    max_value(0, 1 - sqrt(inner(x - x_c, x - x_c) / R_c**2)) +
    max_value(0, 1 - inner(x - x_b, x - x_b) / R_b**2)
)
q0 = firedrake.project(q_expr, Q0)
import matplotlib.pyplot as plt
import mpl_toolkits.mplot3d
fig = plt.figure()
axes = fig.add_subplot(projection='3d')
firedrake.trisurf(q0, axes=axes);

Fluxes

For our first experiment we'll look at the problem of choosing a numerical flux. As we'll see in the following, we have choices to make in how we determine the discrete approximation to the solution of the conservation law. This is very different from elliptic problems -- once we've decided to use the continous Galerkin method, the only real choice is what polynomial degree we'll use.

The usual procedure to come up with a weak form for a PDE is to multiply by some smooth test function $\phi$, move some derivatives onto $\phi$, and integrate. For the conservation law written above, we would arrive at the weak form

$$\int_\Omega\left(\frac{\partial q}{\partial t}\cdot\phi - f(q)\cdot\nabla\phi\right)dx = \int_\Omega s\cdot\phi\, dx + \ldots$$

where I've used an ellipsis to stand for some boundary terms that don't particularly matter. Unfortunately, this equation doesn't quite tell the whole story. We're using discontinuous basis functions to represent the solution $q$, and ideally we would use the same basis and test functions. What happens when the test functions are discontinuous too?

Let $\phi$ be some basis function and let $K$ be the cell of the domain where $\phi$ is supported. If we apply the usual procedure, we get an element-wise weak form when integrating against $\phi$:

$$\int_K\left(\frac{\partial q}{\partial t}\phi - f(q)\cdot\nabla\phi\right)dx + \int_{\partial K}f(q)\cdot\phi n\, ds = \int_K s\cdot\phi\, dx + \ldots$$

where $n$ is the unit outward normal vector to $K$. Note that we're integrating over only a single element and not the entire domain. The problem here is that if the solution and the basis functions are discontinuous across the element, we can't uniquely define their values on the boundary.

To see why this is so, you can imagine that, instead of having a discontinuous test function, we have a sequence $\phi_\epsilon$ of continuous test functions that converge to $\phi$ in some appropriate norm. If we take the support of each element of the sequence to be contained in the interior of $K$, then the value of $q$ in the boundary integral will its the value approaching the boundary from inside:

$$q_-(x) = \lim_{\epsilon\to 0}q(x - \epsilon n).$$

Alternatively, if we take $K$ to be contained in the interior of the support of each element of the sequence, then the value of the solution in the boundary integral will be its value approach the boundary from the outside:

$$q_+(x) = \lim_{\epsilon\to 0}q(x + \epsilon n).$$

Finally, with the right choice of sequence we could get any weighted average of the values on either side of the interface. As a consequence, we need to make some choice of the numerical flux. The numerical flux $f^*$ is a function of the interface values $q_+$ and $q_-$ and the unit normal vector $n$. The discrete approximation to the solution will satisfy the ODE system

$$\sum_K\left\{\int_K\left(\frac{\partial q}{\partial t}\phi - f(q)\cdot\nabla\phi\right)dx + \int_{\partial K}f^*(q_-, q_+, n)\cdot\phi\, ds\right\} = \sum_K\int_K s\cdot\phi\, dx + \ldots$$

for all test functions $\phi$. What kinds of functions can make a good numerical flux? First, if the solution is continuous across an element boundary, the numerical flux should give the same value as the true physical flux:

$$f^*(q, q, n) = f(q)\cdot n.$$

This condition is called consistency and it guarantees that the exact solution is also a discrete solution. The second property we want is to have some analogue of the conservative nature of the true problem. The important thing about fluxes in physical problems is that they can't create or destroy mass, momentum, energy, etc., they only transport it around the domain. To see how we can attain a similar property for our discrete problem, first observe that the sum over all the boundary integrals is telescoping because two neighboring cells $K_-$, $K_+$ share a common face $E$. We can then rewrite the sum of all the boundary integrals as a sum over all faces $E$ of the mesh:

$$\sum_K\int_{\partial K}f^*(q_-, q_+, n)\phi\, ds = \sum_E\int_E\left\{f^*(q_-, q_+, n_-)\phi_- + f^*(q_+, q_-, n_+)\phi_+\right\}ds$$

here $n_-$, $n_+$ are the unit outwardn ormal vectors to $K_-$ and $K_+$ respectively. Note that $n_+ = -n_-$, i.e. the two normals point in opposite directions to each other. What happens if the test function $\phi$ is identically equal to 1 throughout the entire domain? In that case the facet integrals should sum up to 0 -- fluxes only transport, they don't create or destroy. The numerical flux is conservative if

$$f^*(q_-, q_+, n) + f^*(q_+, q_-, -n) = 0.$$

The most braindead way we can come up with a sane numerical flux is to take the average of the solution values across the cell boundary:

$$f^*(q_-, q_+, n) = \frac{1}{2}(q_- + q_+)\cdot n.$$

This is called the central flux. Let's see how well it works.

from firedrake import grad, dx, ds, dS

q, ϕ = firedrake.TrialFunction(Q0), firedrake.TestFunction(Q0)
m = q * ϕ * dx

q = q0.copy(deepcopy=True)
cell_flux = -inner(grad(ϕ), q * u) * dx

n = firedrake.FacetNormal(mesh)
f = q * inner(u, n)
face_flux = (f('+') - f('-')) * (ϕ('+') - ϕ('-')) * dS

q_in = Constant(0)
influx = q_in * min_value(0, inner(u, n)) * ϕ * ds
outflux = q * max_value(0, inner(u, n)) * ϕ * ds

We'll take our timestep to be 1/4 of the formal CFL-stable timestep. We need at least a factor of 1/2 for the dimension, and probably another factor of 1/2 for triangle shape.

from numpy import pi as π
final_time = 2 * π
num_steps = 4 * int(final_time / cfl_timestep)
dt = Constant(final_time / num_steps)

Since we're repeatedly solving the same linear system, we'll create problem and solver objects so that this information can be reused from one solve to the next. The solver parameters are specially chosen for the fact that the mass matrix with discontinuous Galerkin methods is block diagonal, so a block Jacobi preconditioner with exact solvers on all the blocks is exact for the whole system.

from firedrake import LinearVariationalProblem, LinearVariationalSolver
dq_dt = -(cell_flux + face_flux + influx + outflux)

δq = firedrake.Function(Q0)
problem = LinearVariationalProblem(m, dt * dq_dt, δq)
parameters = {'ksp_type': 'preonly', 'pc_type': 'bjacobi', 'sub_pc_type': 'ilu'}
solver = LinearVariationalSolver(problem, solver_parameters=parameters)
import numpy as np
qrange = np.zeros((num_steps, 2))

import tqdm
for step in tqdm.trange(num_steps, unit='timesteps'):
    solver.solve()
    q += δq
    qrange[step, :] = q.dat.data_ro.min(), q.dat.data_ro.max()
100%|██████████| 1136/1136 [00:05<00:00, 225.35timesteps/s]

After only 250 steps the solution is already attaining values two orders of magnitude greater than what they should, even while using a CFL-stable timestep. The reason for this is that the central flux, while both consistent and conservative, is numerically unstable with forward time-differencing.

fig, axes = plt.subplots()
axes.set_yscale('log')
axes.plot(qrange[:250, 1])
axes.set_xlabel('timestep')
axes.set_ylabel('solution maximum');

Instead, we'll try the upwind numerical flux. The idea of the upwind flux is to sample from whichever side of the interface has the velocity flowing outward and not in. The numerical flux is defined as

$$f^*(q_-, q_+, n) = \begin{cases}q_-u\cdot n && u\cdot n > 0 \\ q_+u\cdot n && u\cdot n \le 0\end{cases}.$$

We can also write this in a more symmetric form as

$$f^*(q_-, q_+, n) = q_-\max\{0, u\cdot n\} + q_+\min\{0, u\cdot n\}.$$

The upwind flux is designed to mimic the stability properties of one-sided finite difference schemes for transport equations.

q = q0.copy(deepcopy=True)
cell_flux = -inner(grad(ϕ), q * u) * dx

n = firedrake.FacetNormal(mesh)
u_n = max_value(inner(u, n), 0)
f = q * u_n
face_flux = (f('+') - f('-')) * (ϕ('+') - ϕ('-')) * dS

q_in = Constant(0)
influx = q_in * min_value(0, inner(u, n)) * ϕ * ds
outflux = q * max_value(0, inner(u, n)) * ϕ * ds
       
dq_dt = -(cell_flux + face_flux + influx + outflux)

δq = firedrake.Function(Q0)
problem = LinearVariationalProblem(m, dt * dq_dt, δq)
parameters = {'ksp_type': 'preonly', 'pc_type': 'bjacobi', 'sub_pc_type': 'ilu'}
solver = LinearVariationalSolver(problem, solver_parameters=parameters)

qs = []
output_freq = 5

for step in tqdm.trange(num_steps, unit='timesteps'):
    solver.solve()
    q += δq
    if step % output_freq == 0:
        qs.append(q.copy(deepcopy=True))
100%|██████████| 1136/1136 [00:05<00:00, 223.81timesteps/s]

We at least get a finite answer as a result, which is a big improvement. Keeping in mind that the original data capped out at a value of 1, the peaks have shrunk considerably, and we can also see that the sharp cone is much more rounded than before.

from firedrake.plot import FunctionPlotter
fn_plotter = FunctionPlotter(mesh, num_sample_points=1)
%%capture
fig, axes = plt.subplots()
axes.set_aspect('equal')
axes.get_xaxis().set_visible(False)
axes.get_yaxis().set_visible(False)
colors = firedrake.tripcolor(
    q, num_sample_points=1, vmin=0., vmax=1., shading="gouraud", axes=axes
)

from matplotlib.animation import FuncAnimation
def animate(q):
    colors.set_array(fn_plotter(q))

interval = 1e3 * output_freq * float(dt)
animation = FuncAnimation(fig, animate, frames=qs, interval=interval)
from IPython.display import HTML
HTML(animation.to_html5_video())

Despite this fact, the total volume under the surface has been conserved to within rounding error.

print(firedrake.assemble(q * dx) / firedrake.assemble(q0 * dx))
0.9999713508961685

Nonetheless, the relative error in the $L^1$ norm is quite poor.

firedrake.assemble(abs(q - q0) * dx) / firedrake.assemble(q0 * dx)
0.6651047426779894

Let's see if we can improve on that by changing the finite element basis.

Higher-order basis functions

One of the main advantages that the discontinuous Galerkin method has over the finite volume method is that achieving higher-order convergence is straightforward if the problem is nice -- you just increase the polynomial degree. (When the problem is not nice, for example if there are shockwaves, everything goes straight to hell and the finite volume method is much less finicky about stability.) Here we'll look at what happens when we go from piecewise constant basis functions to piecewise linear.

One of the first changes we have to make is that the Courant-Friedrichs-Lewy condition is more stringent for higher-order basis functions. For piecewise constant basis functions, we have that $\delta x / \delta t \ge |u|$; for degree-$p$ polynomials, we instead need that

$$\frac{\delta x}{\delta t} \ge (2p + 1)\cdot|u|.$$

One way of looking at this higher-degree CFL condition is that the introduction of more degrees of freedom makes the effective spacing between the nodes smaller than it might be in the piecewise-constant case. The multiplicative factor of $2p + 1$ accounts for the effective shrinkage in the numerical length scale. (For more, see this paper from 2013.) Once again, we'll use a timestep that's 1/4 of the formal CFL timestep to account for the spatial dimension and the mesh quality.

cfl_timestep = min_diameter / max_speed / 3
num_steps = 4 * int(final_time / cfl_timestep)
dt = Constant(final_time / num_steps)

We have to be a bit carefuly about creating the initial data. For discontinuous Galerkin discretizations, we would normally project the expression into the discrete function space. Since this is a projection in $L^2$, we might get negative values for an otherwise strictly positive expression. In this case, the positivity of the solution is vital and so instead I'm interpolating the expression for the initial data, but doing so is a little dangerous.

Q1 = firedrake.FunctionSpace(mesh, family='DG', degree=1)
q0 = firedrake.interpolate(q_expr, Q1)
q0.dat.data_ro.min(), q0.dat.data_ro.max()
(0.0, 1.0)

In almost every other respect the discretization is the same as before.

q, ϕ = firedrake.TrialFunction(Q1), firedrake.TestFunction(Q1)
m = q * ϕ * dx

q = q0.copy(deepcopy=True)
cell_flux = -inner(grad(ϕ), q * u) * dx

n = firedrake.FacetNormal(mesh)
u_n = max_value(inner(u, n), 0)
f = q * u_n
face_flux = (f('+') - f('-')) * (ϕ('+') - ϕ('-')) * dS

q_in = Constant(0)
influx = q_in * min_value(0, inner(u, n)) * ϕ * ds
outflux = q * max_value(0, inner(u, n)) * ϕ * ds

dq_dt = -(cell_flux + face_flux + influx + outflux)

δq = firedrake.Function(Q1)
problem = LinearVariationalProblem(m, dt * dq_dt, δq)
parameters = {'ksp_type': 'preonly', 'pc_type': 'bjacobi', 'sub_pc_type': 'ilu'}
solver = LinearVariationalSolver(problem, solver_parameters=parameters)

for step in tqdm.trange(num_steps, unit='timesteps'):
    solver.solve()
    q += δq
100%|██████████| 3412/3412 [00:40<00:00, 83.82timesteps/s]

The error in the $L^1$ norm is less than that of the degree-0 solution, which was on the order of 40%, but it's far from perfect.

firedrake.assemble(abs(q - q0) * dx) / firedrake.assemble(q0 * dx)
0.09376446683007597

Worse yet, the final value of the solution has substantial over- and undershoots. The mathematical term for this is that the true dynamics are monotonicity-preserving -- they don't create new local maxima or minima -- but the numerical scheme is not.

fig, axes = plt.subplots()
axes.set_aspect('equal')
colors = firedrake.tripcolor(q, axes=axes)
fig.colorbar(colors);

To be precise and for later comparison we'll print out exactly how far outside the initial range the solution goes.

q.dat.data_ro.min(), q.dat.data_ro.max()
(-0.11039252600936499, 1.0315252284314207)

But of course we're only using the explicit Euler timestepping scheme, which is of first order, while our spatial discretization should be 2nd-order accurate. Can we do better if we match the asymptotic accuracy of the errors in time and space?

Timestepping

Choosing a finite element basis or a numerical flux is part of deciding how we'll discretize the spatial part of the differential operator. After that we have to decide how to discretize in time. The explicit Euler, which we used in the preceding code, has the virtue of simplicity. Next we'll try out the strong stability-preserving Runge-Kutta method of order 3. First, we'll create a form representing the rate of change of $q$ with the upwind flux just as we did before.

q = q0.copy(deepcopy=True)
ϕ = firedrake.TestFunction(Q1)
cell_flux = -inner(grad(ϕ), q * u) * dx

n = firedrake.FacetNormal(mesh)
u_n = max_value(inner(u, n), 0)
f = q * u_n
face_flux = (f('+') - f('-')) * (ϕ('+') - ϕ('-')) * dS

q_in = Constant(0)
influx = q_in * min_value(0, inner(u, n)) * ϕ * ds
outflux = q * max_value(0, inner(u, n)) * ϕ * ds

dq_dt = -(cell_flux + face_flux + influx + outflux)

To implement the SSPRK3 timestepping scheme, we'll introduce some auxiliary functions and solvers for the Runge-Kutta stages.

q1 = firedrake.Function(Q1)
q2 = firedrake.Function(Q1)

F2 = firedrake.replace(dq_dt, {q: q1})
F3 = firedrake.replace(dq_dt, {q: q2})

problems = [
    LinearVariationalProblem(m, dt * dq_dt, δq),
    LinearVariationalProblem(m, dt * F2, δq),
    LinearVariationalProblem(m, dt * F3, δq)
]

solvers = [
    LinearVariationalSolver(problem, solver_parameters=parameters)
    for problem in problems
]

The timestepping loop is more involved; we have to separately evaluate the Runge-Kutta stages and then form the solution as an appropriate weighted sum.

for step in tqdm.trange(num_steps, unit='timesteps'):
    solvers[0].solve()
    q1.assign(q + δq)
    
    solvers[1].solve()
    q2.assign(3 * q / 4 + (q1 + δq) / 4)
    
    solvers[2].solve()
    q.assign(q / 3 + 2 * (q2 + δq) / 3)
100%|██████████| 3412/3412 [02:07<00:00, 26.84timesteps/s]

The SSPRK3 scheme gives a huge improvement in how well it agrees with the true solution.

firedrake.assemble(abs(q - q0) * dx) / firedrake.assemble(q0 * dx)
0.028571053235589616

In the eyeball norm, it looks like it stays pretty well wtihin the upper and lower limits of the initial data.

fig, axes = plt.subplots()
axes.set_aspect('equal')
colors = firedrake.tripcolor(q, axes=axes)
fig.colorbar(colors);

But if we explicitly calculate the upper and lower bounds, we see that this scheme also fails to be monotonicity preserving!

q.dat.data_ro.min(), q.dat.data_ro.max()
(-0.023255380690921732, 1.0038686288761318)

The departures are relatively small but for more challenging or nonlinear problems the overshoots can become more severe. There is (unfortunately) a can't-do theorem that tells us why: the Godunov barrier. This theorem states that any linear, monotonicity-preserving scheme for hyperbolic conservation laws can be at most 1st-order accurate.

In principle this might sound like a bit of a bummer; why bother looking for higher-order accurate numerical schemes if they're doomed to do unphysical things that will likely result in instability? The operative word here is a linear scheme. The Godunov barrier does not rule out the possibility of nonlinear monotonicity-preserving schemes. I find it profoundly disturbing that we should be using nonlinear schemes to approximate the solutions of linear conservation laws, but ours is but to do and die I suppose.

Flux limiters

The Godunov barrier motivated the development in the early 80s of post-processing techniques that would turn an otherwise oscillatory scheme into one that does not introduce new local maxima or minima. These ideas fall under the aegis of flux limiters or slope limiters, which apply a transformation that clamps the solution in such a way as to suppress unrealistic gradients near sharp discontinuities but which leave the solution unaltered where it is smooth. The design of limiters is part science and part art. Sweby (1984) established some constraints on the what a good limiter function can look like in order to guarantee that the numerical scheme is variation-diminishing. But there's a very large range within those constraints; Sweby's paper showed three different ones even in 1984 and the wiki article on flux limiters lists 15.

Flux-corrected transport is a huge subject, and rather than try to do it any kind of justice I'll instead refer you to a wonderful book by Dmitri Kuzmin. Instead, let's finish things off by looking at what happens when we add a flux limiter to our simulation above. The application of the limiter will be interleaved with all of the Runge-Kutta stages, and conveniently we can reuse the existing solvers for the SSPRK3 stages.

q.assign(q0)
limiter = firedrake.VertexBasedLimiter(Q1)
for step in tqdm.trange(num_steps, unit='timesteps'):
    solvers[0].solve()
    q1.assign(q + δq)
    limiter.apply(q1)
    
    solvers[1].solve()
    q2.assign(3 * q / 4 + (q1 + δq) / 4)
    limiter.apply(q2)
    
    solvers[2].solve()
    q.assign(q / 3 + 2 * (q2 + δq) / 3)
    limiter.apply(q)
100%|██████████| 3412/3412 [02:37<00:00, 21.72timesteps/s]

The relative error in the 1-norm is just as good as before, but with the flux limiter the solution does a much better job staying within the bounds of the initial data.

firedrake.assemble(abs(q - q0) * dx) / firedrake.assemble(q0 * dx)
0.034105170730422026
q.dat.data_ro.min(), q.dat.data_ro.max()
(1.4278749839079737e-45, 0.958887212115741)

Conclusion

Hyperbolic problems are hard. There are difficult decisions to make even at the level of how to formulate the discrete problem. For this demo, we were looking at a scalar conservation laws, and the upwind flux works quite well. But for systems of conservation laws, like the shallow water equations, things become much more involved. You have to know something at an analytical level about the underlying problem -- the wave speeds. Once we've decided which discrete problem to solve, going beyond first-order accuracy is filled with even more challenges. Some issues, like getting a stable enough timestep, often require manual tuning. For the linear problem shown here, we know what the wave speeds are from the outset and we have reasonable confidence that we can pick a good timestep that will work for the entire simulation. The solutions of nonlinear conservation laws can meander to regions of state space where the wave speeds are much higher than where they started and an initially stable timestep becomes unstable. The Right Thing To Do is to use an adaptive timestepping scheme. But you now have the added implementational difficulty of tracking a higher- and lower-order solution with which to inform the adaptation strategy. Hopefully this has shown what some of the typical pitfalls are and what tools are available to remedy them.

Stokes flow

In the last post, we looked at variational principles by studying the minimal surface equation. Much of what you learn in multivariable calculus carries over equally well to infinite-dimensional spaces and we were able to leverage a lot of this intuition to design efficient solution procedures. For example, the notion of convexity carries over to variatonal problems and using this idea we can show that Newton's method is effective in this setting as well.

When we solved the minimal surface equation, our solution space consists of all functions that satisfy a set of Dirichlet boundary conditions. These conditions are easy to eliminate so our problem is essentially unconstrained. In this post, we'll look at the Stokes equations, which are a constrained optimization problem. For unconstrained problems, the convexity of the objective implies a kind of stability property that we can use to prove that roughly any finite element basis will give a convergent approximation scheme. For constrained problems we have to be much more careful about the choice of basis and this is the content of the Ladyzhenskaya-Babuška-Brezzi or LBB conditions, which I'll describe in a later post. For now, we'll focus on solving the Stokes equations using one particular discretization, the Taylor-Hood element.

The Stokes equations describe slow flow of very viscous, incompressible fluids. The fields we're solving for are the velocity $u$ and the pressure $p$. The incompressibility condition means that the velocity field is divergence-free:

$$\nabla\cdot u = 0.$$

The remaining equations state that the stresses are in balance wth body forces:

$$\nabla\cdot \tau - \nabla p + f = 0,$$

where $\tau$ is the rank-2 stress tensor and $f$ are the body forces. The stress tensor must be related somehow to the velocity field. For a viscous fluid, the stress tensor is related to the rate-of-strain tensor

$$\dot\varepsilon = \frac{1}{2}\left(\nabla u + \nabla u^*\right).$$

(For solids the stress tensor is related to the gradient of the displacement rather than the velocity.) The simplest constitutive relation is that of a Newtonian fluid:

$$\tau = 2\mu\dot\varepsilon,$$

where $\mu$ is the viscosity. There are other nonlinear constitutive relations, but for now we'll just consider Newtonian fluids. If $U$ and $L$ are characteristic velocity and length scales for the particular flow at hand and $\rho$ is the fluid density, the Stokes equations are a good description when the Reynolds number is much less than 1:

$$\text{Re} \equiv \frac{\rho UL}{\mu} \ll 1.$$

When the Reynolds number is closer to or larger than 1, we need to use the full Navier-Stokes equations, which includes inertial effects as well.

The Stokes equations, like the Poisson equation, have a minimization principle, but for two fields instead of one. The variational principle is that the solution $u$, $p$ is a critical point of the rate of decrease of the Gibbs free energy:

$$\dot{\mathscr{G}}(u, p) = \int_\Omega\left(\mu|\dot\varepsilon(u)|^2 - p\nabla\cdot u - f\cdot u\right)dx.$$

You can show using the usual tricks that the Euler-Lagrange equations for this functional are the Stokes equations. The free energy dissipation functional consists of a positive, quadratic term in the velocity, but the pressure $p$ only acts like a Lagrange multiplier enforcing the incompressibility condition. The lack of any positivity in the pressure is part of what makes the Stokes equations so challenging to discretize and solve. While the second derivative of the objective is still symmetric, it is no longer positive-definite.

Demonstration

Here we'll work on a classic problem of flow driven by a moving boundary. The domain will consist of a circle with two holes removed. We'll imagine that these holes are cylindrical turbines that are rotating with some fixed speed and dragging the fluid along with them. As we'll see, getting a linear solver to converge for this problem is much more challenging than for the Poisson equation.

import numpy as np
from numpy import pi as π
def add_ellipse(geometry, x, y, a, b, N, lcar):
    θs = np.array([2 * π * n / N for n in range(N)])
    xs, ys = x + a * np.cos(θs), y + b * np.sin(θs)
    points = [geometry.add_point([x, y, 0], lcar=lcar) for x, y in zip(xs, ys)]
    lines = [geometry.add_line(points[n], points[(n + 1) % N])
             for n in range(N)]

    geometry.add_physical(lines)
    line_loop = geometry.add_line_loop(lines)
    return line_loop
import pygmsh
geometry = pygmsh.built_in.Geometry()

outer_line_loop = add_ellipse(geometry, x=0, y=0, a=1, b=1, N=128, lcar=1/4)
inner_loops = [
    add_ellipse(geometry, x=0, y=+1/2, a=1/8, b=1/8, N=64, lcar=1/4),
    add_ellipse(geometry, x=0, y=-1/2, a=1/8, b=1/8, N=64, lcar=1/4)
]

plane_surface = geometry.add_plane_surface(outer_line_loop, inner_loops)
geometry.add_physical(plane_surface)

with open('mixer.geo', 'w') as geo_file:
    geo_file.write(geometry.get_code())
    
!gmsh -2 -format msh2 -v 0 -o mixer.msh mixer.geo
import firedrake
mesh = firedrake.Mesh('mixer.msh')
import matplotlib.pyplot as plt
fig, axes = plt.subplots()
axes.set_aspect('equal')
firedrake.triplot(mesh, axes=axes)
axes.legend();

For this problem we'll use the Taylor-Hood element: piecewise linear basis functions for the pressure and piecewise quadratic basis functions for the velocity. The Taylor-Hood element is stable for the Stokes equations in that norm of the inverse of the disretized linear system is bounded as the mesh is refined. This is a very special property and not just any element will work.

For scalar problems, the solution is a single field, but for the Stokes equations our solution consists of a pair of a velocity and a pressure field. Firedrake includes a handy algebraic notation for defining the direct product of two function spaces.

Q = firedrake.FunctionSpace(mesh, family='CG', degree=1)
V = firedrake.VectorFunctionSpace(mesh, family='CG', degree=2)
Z = V * Q

We can access the components of a function that lives in this product space using the usual Python indexing operators, but it's more convenient to use the function firedrake.split to give us handles for the two components.

z = firedrake.Function(Z)
u, p = firedrake.split(z)

This way our code to define the objective functional looks as much like the math as possible, rather than have to constantly reference the components.

We'll use a viscosity coefficient $\mu$ of 1000. Since the diameter of the domain and the fluid velocity are both on the order of 1, the viscosity would need to be fairly large for the Stokes equations to actually be applicable.

from firedrake import inner, sym, grad, div, dx
def ε(u):
    return sym(grad(u))

μ = firedrake.Constant(1e3)
𝒢̇ = (μ * inner(ε(u), ε(u)) - p * div(u)) * dx

One of the extra challenging factors about the Stokes equations is that they have a non-trivial null space. To see this, suppose we have some velocity-pressure pair $u$, $p$. The velocity field $u$ is not necessarily divergence-free, but we do need that $u\cdot n = 0$ on the boundary of the domain. If we add a constant factor $p_0$ to the pressure, then the value of the objective functional is unchanged:

$$\begin{align} \dot{\mathscr{G}}(u, p) - \dot{\mathscr{G}}(u, p + p_0) & = \int_\Omega p_0\nabla\cdot u\, dx \\ & = p_0\int_{\partial\Omega}u\cdot n\, ds = 0. \end{align}$$

In order to obtain a unique solution to the system, we can impose the additional constraint that

$$\int_\Omega p\, dx = 0,$$

or in other words that the pressure must be orthogonal to all the constant functions.

from firedrake import MixedVectorSpaceBasis, VectorSpaceBasis
basis = VectorSpaceBasis(constant=True)
nullspace = MixedVectorSpaceBasis(Z, [Z.sub(0), basis])

Next we have to create the boundary conditions. The only extra work we have to do here is to get the right component of the mixed function space $Z$.

x = firedrake.SpatialCoordinate(mesh)

x2 = firedrake.as_vector((0, +1/2))
r2 = firedrake.Constant(1/8)
x3 = firedrake.as_vector((0, -1/2))
r3 = firedrake.Constant(1/8)

q2 = (x - x2) / r2
q3 = (x - x3) / r3

u2 = firedrake.as_vector((-q2[1], q2[0]))
u3 = firedrake.as_vector((-q3[1], q3[0]))

from firedrake import DirichletBC, as_vector
bc1 = DirichletBC(Z.sub(0), as_vector((0, 0)), 1)
bc2 = DirichletBC(Z.sub(0), u2, 2)
bc3 = DirichletBC(Z.sub(0), u3, 3)

Now let's see what happens if we invoke the default linear solver.

from firedrake import derivative

try:
    firedrake.solve(
        derivative(𝒢̇, z) == 0, z,
        bcs=[bc1, bc2, bc3],
        nullspace=nullspace
    )
except firedrake.ConvergenceError:
    print("Oh heavens, it didn't converge!")

We'll take the easy way out and use the sparse direct solver MUMPS to make sure we get an answer. This approach will work for now, but even parallel direct solvers scale poorly to large problems, especially in 3D. The proper incanctation to invoke the direct solver needs a bit of explaining. For mixed problems like the Stokes equations, Firedrake will assemble a special matrix type that exploits the problem's block structure. Unfortunately MUMPS can't work with this matrix format, so we have to specify that it will use PETSc's aij matrix format with the option 'mat_type': 'aij'. Next, we'll request that the solver use the LU factorization with 'pc_type': 'lu'. Without any other options, this will use PETSc's built-in matrix factorization routines. These are fine for strictly positive matrices, but fail when the problem has a non-trivial null space. The option 'pc_factor_mat_solver_type': 'mumps' will use the MUMPS package instead of PETSc's built-in sparse direct solver.

firedrake.solve(
    derivative(𝒢̇, z) == 0, z,
    bcs=[bc1, bc2, bc3],
    nullspace=nullspace,
    solver_parameters={
        'mat_type': 'aij',
        'ksp_type': 'preonly',
        'pc_type': 'lu',
        'pc_factor_mat_solver_type': 'mumps'
    }
)

Some cool features you can observe in the stream plot are the saddle point at the center of the domain and the two counter-rotating vortices that form on either side of it.

u, p = z.split()
fig, axes = plt.subplots()
axes.set_aspect('equal')
kwargs = {'resolution': 1/30, 'seed': 4, 'cmap': 'winter'}
streamlines = firedrake.streamplot(u, axes=axes, **kwargs)
fig.colorbar(streamlines);

The velocity field should be close to divergence-free; if we project the divergence into a DG(2) we can see what the exact value is. There are some small deviations, especially around the boundary of the domain. Part of the problem is that the boundary conditions we've specified are exactly tangent to the idealized domain -- a large circle with two circular holes punched out of it -- but not to its discrete approximation by a collection of straight edges.

S = firedrake.FunctionSpace(mesh, family='DG', degree=2)
div_u = firedrake.project(div(u), S)
fig, axes = plt.subplots()
axes.set_aspect('equal')
kwargs = {'vmin': -0.01, 'vmax': +0.01, 'cmap': 'seismic'}
triangles = firedrake.tripcolor(div_u, axes=axes, **kwargs)
fig.colorbar(triangles);

For now we'll calculate and store the norm of the velocity diverence. When we try to improve on this we'll use this value as a baseline.

linear_coords_divergence = firedrake.norm(div_u)
print(linear_coords_divergence)
0.026532615307004404

Higher-order geometries

We can try to improve on this by using curved edges for the geometry instead of straight ones. The topology of the mesh is the same; we're just adding more data describing how it's embedded into Euclidean space. In principle, gmsh can generate this for us, but reading in the file seems to be awfully annoying. To get a higher-order geometry, we'll proceed by:

  1. making a quadratic vector function space
  2. interpolating the linear coordinates into this space
  3. patching the new coordinate field to conform to the boundary

This approach will work for our specific problem but it requires us to know things about the idealized geometry that aren't always available. So what we're about to do isn't exactly generalizable.

To do the patching in step 3, we'll create boundary condition objects defined on the quadratic function space and then apply them. We need to know the numbering of the various boundary segments in order to do that, so to refresh the memory let's look at the mesh again.

fig, axes = plt.subplots()
axes.set_aspect('equal')
firedrake.triplot(mesh, axes=axes)
axes.legend();

The outer curve is boundary 1, the upper mixer head is boundary 2, and the lower head is boundary 3. With that in mind we can create the new coordinate field.

Vc = firedrake.VectorFunctionSpace(mesh, family='CG', degree=2)
from firedrake import sqrt, Constant
def fixup(x, center, radius):
    distance = sqrt(inner(x - center, x - center))
    return center + radius * (x - center) / distance

centers = [Constant((0., 0.)), Constant((0., +.5)), Constant((0., -0.5))]
radii = [Constant(1.), Constant(1/8), Constant(1/8)]
bcs = [firedrake.DirichletBC(Vc, fixup(x, center, radius), index + 1)
       for index, (center, radius) in enumerate(zip(centers, radii))]

X0 = firedrake.interpolate(mesh.coordinates, Vc)
X = X0.copy(deepcopy=True)
for bc in bcs:
    bc.apply(X)

Just as a sanity check, we'll calculate the average deviation of the new from the old coordinate field to see how different they are.

from firedrake import ds
length = firedrake.assemble(Constant(1.) * ds(mesh))
firedrake.assemble(sqrt(inner(X - X0, X - X0)) * ds) / length
0.000180710597501049

Now we can solve the Stokes equations again on this new mesh using the exact same procedures as before.

qmesh = firedrake.Mesh(X)
Q = firedrake.FunctionSpace(qmesh, family='CG', degree=1)
V = firedrake.VectorFunctionSpace(qmesh, family='CG', degree=2)
Z = V * Q

z = firedrake.Function(Z)
u, p = firedrake.split(z)

𝒢̇ = (μ * inner(ε(u), ε(u)) - p * div(u)) * dx

basis = VectorSpaceBasis(constant=True)
nullspace = MixedVectorSpaceBasis(Z, [Z.sub(0), basis])

x = firedrake.SpatialCoordinate(qmesh)

x2 = firedrake.as_vector((0, +1/2))
r2 = firedrake.Constant(1/8)
x3 = firedrake.as_vector((0, -1/2))
r3 = firedrake.Constant(1/8)

q2 = (x - x2) / r2
q3 = (x - x3) / r3

u2 = firedrake.as_vector((-q2[1], q2[0]))
u3 = firedrake.as_vector((-q3[1], q3[0]))

from firedrake import DirichletBC, as_vector
bc1 = DirichletBC(Z.sub(0), as_vector((0, 0)), 1)
bc2 = DirichletBC(Z.sub(0), u2, 2)
bc3 = DirichletBC(Z.sub(0), u3, 3)

firedrake.solve(
    derivative(𝒢̇, z) == 0, z,
    bcs=[bc1, bc2, bc3],
    nullspace=nullspace,
    solver_parameters={
        'mat_type': 'aij',
        'ksp_type': 'preonly',
        'pc_type': 'lu',
        'pc_factor_mat_solver_type': 'mumps'
    }
)
S = firedrake.FunctionSpace(qmesh, family='DG', degree=2)
div_u = firedrake.project(div(u), S)

The ring of spurious divergences around the outer edge of the domain is substantially reduced with curved elements. Nonetheless, the boundary doesn't perfectly fit the circle and this imperfection means that at some points around the edge the discretized velocity field will have an unphysical, non-zero normal component.

fig, axes = plt.subplots()
axes.set_aspect('equal')
triangles = firedrake.tripcolor(div_u, axes=axes, **kwargs)
fig.colorbar(triangles);

Using a higher-order geometry reduced the norm of the velocity divergence almost by a factor of 4, which is a big improvement.

quadratic_coords_divergence = firedrake.norm(div_u)
print(linear_coords_divergence / quadratic_coords_divergence)
3.6951400937053247

Conclusion

The code above shows how to get an exact (up to rounding-error) solution to the discretized Stokes equations using MUMPS. For larger problems in 3D, using a direct method can become prohibitively expensive. The Firedrake documentation has a demo of how to use PETSc's field split preconditioners, together with matrix-free operators, to solve the Stokes equations efficiently. In subsequent posts, I'll show more about stable discretizations of mixed problems, and how to solve the Stokes equations with more exotic boundary conditions than the standard ones we've shown here.

The velocity field we calculated was not exactly divergence-free and part of this was a consequence of using a boundary condition that adapted poorly to a piecewise-linear discretized geometry. We were able to do better by increasing the polynomial degree of the geometry, and in general this is absolutely necessary to achieve the expected rates of convergence with higher-order finite element bases. Nonetheless, the support for higher-order geometries in common finite element and mesh generation packages should be better given how useful they are. I think this is an area where a little investment in resources could make a really outsized difference. The logical endpoint of this line of thinking is isogeometric analysis, which is an active area of research.

The obstacle problem

In this post, we'll look at the obstacle problem. We've seen in previous posts examples of variational problems -- minimization of some functional with respect to a field. The classic example of a variational problem is to find the function $u$ that minimizes the Dirichlet energy

$$\mathscr{J}(u) = \int_\Omega\left(\frac{1}{2}|\nabla u|^2 - fu\right)dx$$

subject to the homogeneous Dirichlet boundary condition $u|_{\partial\Omega} = 0$. The Poisson equation is especially convenient because the objective is convex and quadratic. The obstacle problem is what you get when you add the additional constraint

$$u \ge g$$

throughout the domain. More generally, we can look at the problem of minimizing a convex functional $\mathscr{J}$ subject to the constraint that $u$ has to live in a closed, convex set $K$ of a function space $Q$. For a totally unconstrained problem, $K$ would just be the whole space $Q$.

Newton's method with line search is a very effective algorithm for solving unconstrained convex problems, even for infinite-dimensional problems like PDEs. Things get much harder when you include inequality constraints. To make matters worse, much of the literature you'll find on this subject is focused on finite-dimensional problems, where techniques like the active-set method work quite well. It's not so obvious how to generalize these methods to variational problems. In the following, I'll follow the approach in section 4.1 of this paper by Farrell, Croci, and Surowiec, whch was my inspiration for writing this post.

Minimizing the action functional $\mathscr{J}$ over the convex set $K$ can be rephrased as an unconstrained problem to minimize the functional

$$\mathscr{J}(u) + \mathscr{I}(u),$$

where $\mathscr{I}$ is the indicator function of the set $K$:

$$\mathscr{I}(u) = \begin{cases}0 & u \in K \\ \infty & u \notin K\end{cases}.$$

This functional is still convex, but it can take the value $\infty$. The reformulation isn't especially useful by itself, but we can approximate it using the Moreau envelope. The envelope of $\mathscr{I}$ is defined as

$$\mathscr{I}_\gamma(u) = \min_v\left(\mathscr{I}(v) + \frac{1}{2\gamma^2}\|u - v\|^2\right).$$

In the limit as $\gamma \to 0$, $\mathscr{I}_\gamma(u) \to \mathscr{I}(u)$. The Moreau envelope is much easier to work with than the original functional because it's differentiable. In some cases it can be computed analytically; for example, when $\mathscr{I}$ is an indicator function,

$$\mathscr{I}_\gamma(u) = \frac{1}{2\gamma^2}\text{dist}\,(u, K)^2$$

where $\text{dist}$ is the distance to a convex set. We can do even better for our specific case, where $K$ is the set of all functions greater than $g$. For this choice of $K$, the distance to $K$ is

$$\text{dist}(u, K)^2 = \int_\Omega(u - g)_-^2dx,$$

where $v_- = \min(v, 0)$ is the negative part of $v$. So, our approach to solving the obstacle problem will be to find the minimzers of

$$\mathscr{J}_\gamma(u) = \int_\Omega\left(\frac{1}{2}|\nabla u|^2 - fu\right)dx + \frac{1}{2\gamma^2}\int_\Omega(u - g)_-^2dx$$

as $\gamma$ goes to 0. I've written things in such a way that $\gamma$ has units of length. Rather than take $\gamma$ to 0 we can instead stop at some fraction of the finite element mesh spacing. At that point, the errors in the finite element approximation are comparable to the distance of the approximate solution to the constraint set.

This is a lot like the penalty method for optimization problems with equality constraints. One of the main practical considerations when applying this regularization method is that the solution $u$ only satisfies the inequality constraints approximately. For the obstacle problem this deficiency isn't so severe, but for other problems we may need the solution to stay strictly feasible. In those cases, another approach like the logarithmic barrier method might be more appropriate.

Demonstration

For our problem, the domain will be the unit square and the obstacle function $g$ will be the upper half of a sphere.

import firedrake
nx, ny = 64, 64
mesh = firedrake.UnitSquareMesh(nx, ny, quadrilateral=True)
Q = firedrake.FunctionSpace(mesh, family='CG', degree=1)
from firedrake import max_value, sqrt, inner, as_vector, Constant

def make_obstacle(mesh):
    x = firedrake.SpatialCoordinate(mesh)
    y = as_vector((1/2, 1/2))
    z = 1/4
    return sqrt(max_value(z**2 - inner(x - y, x - y), 0))

g = firedrake.interpolate(make_obstacle(mesh), Q)
import matplotlib.pyplot as plt
import mpl_toolkits.mplot3d
fig = plt.figure()
axes = fig.add_subplot(projection='3d')
firedrake.trisurf(g, axes=axes);

Next we'll make a few utility procedures to create the Moreau envelope of the objective functional and to calculate a search direction from a given starting guess.

from firedrake import grad, dx, min_value

def make_objective(u, g, γ):
    J_elastic = 0.5 * inner(grad(u), grad(u)) * dx
    J_penalty = 0.5 / γ**2 * min_value(u - g, 0)**2 * dx
    return J_elastic + J_penalty
from firedrake import derivative
def update_search_direction(J, u, v):
    F = derivative(J, u)
    H = derivative(F, u)

    bc = firedrake.DirichletBC(u.function_space(), 0, 'on_boundary')
    params = {'ksp_type': 'cg', 'pc_type': 'icc'}
    firedrake.solve(H == -F, v, bc, solver_parameters=params)

Let's start from a zero initial guess and see what the first search direction will be.

u = firedrake.Function(Q)
γ = Constant(1.)
J = make_objective(u, g, γ)

v = firedrake.Function(Q)
update_search_direction(J, u, v)
fig = plt.figure()
axes = fig.add_subplot(projection='3d')
firedrake.trisurf(v, axes=axes);

To make sure that a Newton-type method will converge, we'll need a routine to perform a 1D minimization along the search direction.

from scipy.optimize import minimize_scalar
from firedrake import assemble, replace

def line_search(J, u, v):
    def J_line(step):
        t = firedrake.Constant(step)
        J_t = replace(J, {u: u + t * v})
        return assemble(J_t)

    result = minimize_scalar(J_line)
    assert result.success
    return result.x
t = line_search(J, u, v)
print(t)
1.0000833865577647

With these steps out of the way we can define a Newton search procedure and calculate a solution for our initial, rough guess of $\gamma$.

from firedrake import action
def newton_search(J, u, tolerance=1e-10, max_num_steps=30):
    v = firedrake.Function(u.function_space())
    F = derivative(J, u)
    
    for step in range(max_num_steps):
        update_search_direction(J, u, v)
        
        Δ = assemble(action(F, v))
        if abs(Δ) < tolerance * assemble(J):
            return

        t = Constant(line_search(J, u, v))
        u.assign(u + t * v)
newton_search(J, u)
fig = plt.figure()
axes = fig.add_subplot(projection='3d')
firedrake.trisurf(u, axes=axes);

The solution we obtain doesn't do a good job of staying above the obstacle because we haven't used a sufficiently small value of $\gamma$.

δ = firedrake.interpolate(max_value(g - u, 0), Q)
print(firedrake.assemble(δ * dx) / firedrake.assemble(g * dx))
0.9680039453017546

Instead, we can use the solution obtained for one value of $\gamma$ to initialize a search for the solution with $\gamma / 2$ and iterate. We've chosen this slightly indirect route rather than start from a small value of $\gamma$ directly because the problem may be very poorly conditioned. The numerical continuation approach can still give a reasonable answer even for poorly-conditioned problems.

def continuation_search(g, γ0, num_steps, contraction=0.5):
    u = g.copy(deepcopy=True)
    γ = Constant(γ0)

    for step in range(num_steps):
        J = make_objective(u, g, γ)
        newton_search(J, u)
        γ.assign(contraction * γ)

    return u

We'll choose a number of steps so that the final value of $\gamma$ is roughly proportional to the mesh spacing.

import numpy as np
num_steps = int(np.log2(nx)) + 1
print(num_steps)

u = continuation_search(g, 1., num_steps)
7

Finally, I'll plot a cross section of the solution and the constraint $g$ so that you can see where the two coincide.

fig, axes = plt.subplots()
num_points = 51
xs = np.linspace(0., 1., num_points)
ys = 0.5 * np.ones(num_points)
X = np.array((xs, ys)).T
axes.plot(xs, g.at(X), color='tab:orange')
axes.plot(xs, u.at(X), color='tab:blue');

Refinement

The code above worked well enough for a single grid, but one of the hard parts about optimization with PDE constraints is making sure that our algorithms do sane things under mesh refinement. Many common algorithms can have different convergence rates depending on the mesh or the degree of the finite element basis. The reasons for this are a little involved, but if you want to read more, I recommend this book by Málek and Strakos.

To really make sure we're doing things right, we should run this experiment at several levels of mesh refinement. We can do this easily using the MeshHierarchy function in Firedrake.

coarse_mesh = firedrake.UnitSquareMesh(nx, ny, quadrilateral=True)
num_levels = 3
mesh_hierarchy = firedrake.MeshHierarchy(coarse_mesh, num_levels)

for level, mesh in enumerate(mesh_hierarchy):
    Q = firedrake.FunctionSpace(mesh, family='CG', degree=1)
    g = firedrake.interpolate(make_obstacle(mesh), Q)
    num_continuation_steps = int(np.log(nx)) + level + 1
    u = continuation_search(g, 1, num_continuation_steps)
    print(assemble(max_value(g - u, 0) * dx))
0.0034424020045330894
0.0009409740393031862
0.00024126075715710023
6.073519021737773e-05

If we plot the volume of the region where $u$ is less than $g$, it decreases roughly by a factor of four on every mesh refinement. This rate of decrease makes sense -- the area of each cell decreases by the same amount on each refinement. Doing a more thorough convergence study would require more computational power, but for now this is a promising sign that our algorithm works right.

Discussion

We were able to get a convergent approximation scheme for the obstacle problem by expressing the constraint as an indicator functional and then using Moreau-Yosida regularization. The idea of regularizing non-smooth optimization problems is a more general trick; we can use it for things like $L^1$ or total variation penalties as well. The Moreau envelope is another angle to look at proximal algorithms from too.

For the obstacle problem, regularization made it possible to describe every part of the algorithm using higher-level concepts (fields, functionals) without having to dive down to lower levels of abstraction (matrices, vectors). In order to implement other approaches, like the active set method, we would have no choice but to pull out the PETSc matrices and vectors that lie beneath, which is a more demanding prospect.

Variational calculus

In this post I'll look at a classic example of a convex variational problem: computing minimal surfaces. The minimal surface problem has a simple physical interpretation in terms of soap films. Suppose you have a wire loop and you stretch a film of soap over it; what shape does the film take? The available energy that the film has to do mechanical work is proportional to the product of the surface tension and the area of the film. When the film is in equilibrium, it will minimize the energy, so it will find the surface of least area that stretches over the hoop. This shape is called a minimal surface.

Here we'll look at a geometrically simpler case where the surface can be described as the graph of a function defined on some footprint domain $\Omega$ that lives in the plane. We'll describe the position of the hoop as a function $g$ that maps the boundary $\partial\Omega$ to the reals, and the surface as a function $u$ on $\Omega$. The surface area of the graph of $u$ is the quantity

$$J(u) = \int_\Omega\sqrt{1 + |\nabla u|^2}\,dx.$$

So, our goal is to minimize the objective functional $J$ among all functions $u$ such that $u|_{\partial\Omega} = g$. This is a classic example in variational calculus, which I'll assume you're familiar with. If you haven't encountered this topic before, I learned about it from Weinstock's book.

The weak form of the Euler-Lagrange equation for $J$ is

$$\int_\Omega\frac{\nabla u\cdot\nabla v}{\sqrt{1 + |\nabla u|^2}}dx = 0$$

for all $v$ that vanish on the boundary. This PDE is just a specific way of stating the general condition that, for $u$ to be an extremum of $J$, its directional derivative along all perturbations $v$ must be 0:

$$\langle dJ(u), v\rangle = 0.$$

We can go a little bit further and calculate the second derivative of $J$ too:

$$\langle d^2J(u)\cdot v, w\rangle = \int_\Omega\frac{I - \frac{\nabla u\cdot \nabla u^*}{1 + |\nabla u|^2}}{\sqrt{1 + |\nabla u|^2}}\nabla v\cdot \nabla w\, dx,$$

Deriving this equation takes a bit of leg work, but the important part is that it looks like a symmetric, positive-definite elliptic operator, only the conductivity tensor depends on the gradient of $u$. Since the second derivative of $J$ is positive-definite, the minimization problem is convex and thus has a unique solution.

There are many approaches you could take to solving the minimal surface equation. I'll examine some here using the finite element modeling package Firedrake. If you're unfamiliar with Firedrake or FEniCS, their main selling point is that, rather than write code to fill matrices and vectors yourself, these packages use an embedded domain-specific language to describe the weak form of the PDE. The library then generates efficient C code on the spot to fill these matrices and vectors. Having done all this by hand for several years I can tell you this is a big improvement!

import firedrake

To keep things simple, we'll use the unit square as our spatial domain, and we'll use piecewise quadratic finite elements.

mesh = firedrake.UnitSquareMesh(100, 100, quadrilateral=True)
Q = firedrake.FunctionSpace(mesh, family='CG', degree=2)

I'll use a test case from some course notes from a class that Douglas Arnold teaches on finite element methods. The boundary curve is

$$g = ax\cdot\sin\left(\frac{5}{2}\pi y\right).$$

In the notes, Arnold uses $a = 1/5$. When the numerical range of $g$ is small relative to the diameter of the domain, the minimal surface equation linearizes to the Laplace equation. I want to instead look at the more nonlinear case of $a > 1$, which will stress the nonlinear solver a good deal more.

x, y = firedrake.SpatialCoordinate(mesh)

from numpy import pi as π
from firedrake import sin
a = firedrake.Constant(3/2)
g = a * x * sin(5 * π * y / 2)

A picture is worth a thousand words of course.

import matplotlib.pyplot as plt
import mpl_toolkits.mplot3d
fig = plt.figure()
axes = fig.add_subplot(projection='3d')
firedrake.trisurf(firedrake.interpolate(g, Q), axes=axes);

Here we'll create the proposed solution $u$, define the objective functional, and try to find the minimizer naively using Firedrake's built-in solver. With the value for $a$ that I chose, the solver won't converge using its default settings.

u = firedrake.interpolate(g, Q)
bc = firedrake.DirichletBC(Q, g, 'on_boundary')

from firedrake import sqrt, inner, grad, dx
J = sqrt(1 + inner(grad(u), grad(u))) * dx
F = firedrake.derivative(J, u)

try:
    firedrake.solve(F == 0, u, bc)
except firedrake.ConvergenceError:
    print('Woops, nonlinear solver failed to converge!')
Woops, nonlinear solver failed to converge!

We could tweak these settings to make the solver converge, but instead let's try and dive deeper into what does and doesn't make for a good nonlinear solver.

Picard's method

This method is predicated on the idea that many nonlinear PDEs look like a linear problem with coefficients that depend on the solution. If you freeze those coefficients at the current guess for the solution, you get something that's fairly easy to solve and hopefully convergent. Suppose we've got a guess $u_n$ for the solution of the minimal surface equation. The Picard method would give us a next guess $u_{n + 1}$ that solves the linear PDE

$$\int_\Omega\frac{\nabla u_{n + 1}\cdot\nabla v}{\sqrt{1 + |\nabla u_n|^2}}dx = 0$$

for all $v$ that vanish on the boundary. This method is easy to implement if you know the functional form of the problem you're solving. Let's see how fast this decreases the area.

u.interpolate(g)
u_n = u.copy(deepcopy=True)
v = firedrake.TestFunction(Q)

G = inner(grad(u), grad(v)) / sqrt(1 + inner(grad(u_n), grad(u_n))) * dx

import numpy as np
num_iterations = 24
Js = np.zeros(num_iterations)
Js[0] = firedrake.assemble(J)
for step in range(1, num_iterations):
    firedrake.solve(G == 0, u, bc)
    u_n.assign(u)
    Js[step] = firedrake.assemble(J)

The method converges in the eyeball norm in about 6 iterations.

fig, axes = plt.subplots()
axes.scatter(list(range(num_iterations)), Js, label='surface area')
axes.set_xlabel('iteration')

axes = axes.twinx()
axes.scatter(list(range(1, num_iterations)), -np.diff(Js) / Js[1:],
             color='tab:orange', label='relative decrease')
axes.set_ylim(1e-6, 1)
axes.set_yscale('log')

fig.legend(loc='upper center');

This looks pretty good -- the iterates converge very rapidly to the minimizer. There are still reasons to look for something better though. Picard's method relies on the problem having special structure, which is true of the minimal surface equation but harder to find for other problems.

Newton's method (take 1)

One of the best known methods is due to Newton. The idea behind Newton's method is to use the Taylor expansion of the objective at the current guess $u_{n - 1}$ up to second order to define a quadratic approximation to the objective:

$$J(u_n + v) = J(u_n) + \langle F, v\rangle + \frac{1}{2}\langle Hv, v\rangle + \ldots$$

where $F = dJ(u_n)$, $H = d^2J(u_n)$ are the first and second derivatives of the objective. We can then define a new iterate as the minimizer of this quadratic problem:

$$u_{n + 1} = u_n + \text{argmin}_v\, \langle F, v\rangle + \frac{1}{2}\langle Hv, v\rangle.$$

The big advantage of Newton's method is that, for a starting guess sufficiently close to the solution, the iterates converge quadratically to the minimizer. Picard's method converges at best linearly.

One of the advantages of Newton's method is that there are many software packages for automatically calculating first and second derivatives of nonlinear functionals. So it's easy to apply to a broad class of problems. It isn't quite so clear how to select the right linear operator for Picard's method.

u.interpolate(g)

F = firedrake.derivative(J, u)
H = firedrake.derivative(F, u)

v = firedrake.Function(Q)

num_iterations = 24
Js = np.zeros(num_iterations + 1)
Js[0] = firedrake.assemble(J)

bc = firedrake.DirichletBC(Q, 0, 'on_boundary')
params = {'ksp_type': 'cg', 'pc_type': 'icc'}
try:
    for step in range(1, num_iterations):
        firedrake.solve(H == -F, v, bc, solver_parameters=params)
        u += v
        Js[step] = firedrake.assemble(J)
except firedrake.ConvergenceError:
    print('Newton solver failed after {} steps!'.format(step))
Newton solver failed after 3 steps!

Doesn't bode very well does it? Let's see what the objective functional did before exploding:

print(Js[:step])
[   4.27174587   13.03299587 2847.93224483]

Not a lot to save from the wreckage here -- the objective functional was increasing, which is just the opposite of what we want. What happened? Newton's method will converge quadratically if initialized close enough to the true solution. We don't have any idea a priori if we're close enough, and if we aren't then there's no guarantee that the iterates will converge at all. The example from Doug Arnold's course notes used a much smaller amplitude $a$ in the boundary data, so the initial guess is already within the convergence basin.

Newton's method (take 2)

But there's always hope! Suppose $v$ is a function such that the directional derivative of $J$ at $u$ along $v$ is negative:

$$\langle dJ(u), v\rangle < 0.$$

Then there must be some sufficiently small real number $t$ such that

$$J(u + t\cdot v) < J(u).$$

If we do have a descent direction in hand, then the problem of finding a better guess $u_{n + 1}$ starting from $u_n$ is reduced to the one-dimensional problem to minimize $J(u_n + t\cdot v)$ with respect to the real variable $t$.

If $H$ is any symmetric, positive-definite linear operator, then

$$v = -H^{-1}dJ(u)$$

is a descent direction for $J$. While the pure Newton method can diverge for some starting guesses, it does offer up a really good way to come up with descent directions for convex problems because the second derivative of the objective is positive-definite. This suggests the following algorithm:

$$\begin{align} v_n & = -d^2J(u_n)^{-1}dJ(u_n) \\ t_n & = \text{argmin}_t\, J(u_n + t\cdot v_n) \\ u_{n + 1} & = u_n + t_n\cdot v_n. \end{align}$$

This is called the damped Newton method or the Newton line search method. We can use standard packages like scipy to do the 1D minimization, as I'll show below.

u.interpolate(g)

F = firedrake.derivative(J, u)
H = firedrake.derivative(F, u)

v = firedrake.Function(Q)
bc = firedrake.DirichletBC(Q, 0, 'on_boundary')

import scipy.optimize
t = firedrake.Constant(1)
def J_t(s):
    t.assign(s)
    return firedrake.assemble(firedrake.replace(J, {u: u + t * v}))

num_iterations = 24
Js = np.zeros(num_iterations)
ts = np.zeros(num_iterations)
Δs = np.zeros(num_iterations)
Js[0] = firedrake.assemble(J)

from firedrake import action
for step in range(1, num_iterations):
    firedrake.solve(H == -F, v, bc, solver_parameters=params)
    Δs[step] = firedrake.assemble(-action(F, v))
    
    line_search_result = scipy.optimize.minimize_scalar(J_t)
    if not line_search_result.success:
        raise firedrake.ConvergenceError('Line search failed at step {}!'
                                         .format(step))
    t_min = firedrake.Constant(line_search_result.x)
    u.assign(u + t_min * v)

    ts[step] = t_min
    Js[step] = firedrake.assemble(J)

The same convergence plot as above for Newton's method paints a very different picture.

fig, axes = plt.subplots()
axes.scatter(list(range(num_iterations)), Js, label='surface area')
axes.set_xlabel('iteration')

axes = axes.twinx()
axes.scatter(list(range(1, num_iterations)), -np.diff(Js) / Js[1:],
             color='tab:orange', label='relative decrease')
axes.set_ylim(1e-16, 1)
axes.set_yscale('log')

fig.legend(loc='upper center');

The algorithm starts converges linearly just like the Picard method does. The second phase starts around iteration 15. By this point, the algorithm has entered the quadratic convergence basin and the errors plummet.

fig, axes = plt.subplots()
axes.scatter(list(range(1, num_iterations)), ts[1:])
axes.set_xlabel('iteration')
axes.set_ylabel('step size');

Another revealing way to look at this is to examine the Newton decrement. The Newton decrement $\Delta_k$ at step $k$ is defined as the directional derivative of the objective functional along the search direction:

$$\Delta_k = \langle dJ(u_k), v_k\rangle.$$

The Newton decrement is approximately half of the difference between the value of the objective at the current guess and the next guess:

$$\begin{align} J(u_{k + 1}) - J(u_k) & = \langle dJ(u_k), v_k\rangle + \frac{1}{2}\langle d^2J(u_k)v_k, v_k\rangle + \ldots \\ & = \langle dJ(u_k), v_k\rangle - \frac{1}{2}\langle dJ(u_k), v_k\rangle + \ldots\\ & = \frac{1}{2}\langle dJ(u_k), v_k\rangle + \ldots \end{align}$$

where we have used the fact that $v_k = -d^2J(u_k)^{-1}dJ(u_k)$ in going from the first line to the second. An informal way of describing the Newton decrement is that it gives an upper bound on how much we can expect reduce the objective functional by one more iteration of Newton's method.

The plot below shows the ratio of the differences in the objective functional to the value of the Newton decrement.

fig, axes = plt.subplots()
axes.scatter(list(range(1, num_iterations)), -2 * np.diff(Js) / Δs[1:])
axes.set_xlabel('iteration')
axes.set_ylabel('Actual decrease / expected decrease');

During the first few iterations while the method is iterating towards the quadratic convergence basin, the Newton decrement is less than half of the actual decrease. Once the method has found the convergence basin the ratio hits almost exactly 1/2 at a few points. Finally, once the method has effectively converged, the ratio drops to 0.

In the code above, I picked a fixed value of the iteration count. For real applications it's better to have a dynamic stopping criterion based on the current and past state. The Newton decrement is a useful quantity in this respect because it depends only on the current state. Here we'll plot the ratio of the Newton decrement to the value of the objective itself.

fig, axes = plt.subplots()
axes.scatter(list(range(1, num_iterations)), Δs[1:] / Js[:-1])
axes.set_ylim(1e-7, 1)
axes.set_yscale('log')
axes.set_xlabel('iteration')
axes.set_ylabel('Newton decrement / objective');

A sensible stopping criterion is that $\Delta_k < \epsilon \cdot J(u_k)$ where the tolerance $\epsilon$ is about $10^{-6}$. It's handy to think about this in terms of the informal description of the Newton decrement -- stop iterating when the expected gain is less than some small fraction of the currrent cost.

For the minimal surface equation, the objective functional is strictly positive and convex. Other problems might be convex but the objective is a sum of parts that can be either positive or negative. For example, for viscous flow problems, the objective can be divided into internal viscous dissipation of energy (strictly positive) and the power from driving stress (positive or negative). For more general problems it would then be incumbent on you as the modeler to know in advance which parts of the objective are strictly positive and use these to set the scale in the convergence criterion.

fig = plt.figure()
axes = fig.add_subplot(projection='3d')
firedrake.trisurf(u, axes=axes);

Kac's conjecture

In a previous post, we gave a numerical demonstration of the Weyl asymptotic law, which relates the eigenvalues of the Laplace operator on a domain to its area and perimeter. If this domain were a drumhead, the eigenvalues are essentially its resonant frequencies. Weyl's law tells you that you can hear the area and perimeter of a drumhead if only you listen carefully. In 1966, Mark Kac posed the tantalizing question of whether you could hear not just the area and perimeter, but the entire shape of a drum. Knowing all the eigenvalues of a domain, can you reconstruct the whole shape? In what must have been a pretty brutal wet blanket moment, John Milnor showed that there are two distinct 16-dimensional manifolds with the same spectrum, so the answer to Kac's question is no in general. Now I've never played a 16-dimensional drum, but in 1992 a counterexample was found in the plane. Here we'll study this problem numerically.

Geometry

The two shapes we'll use were devised by Gordon, Webb, and Wolpert. I got the coordinates for the polygon vertices by looking at this figure from the wikipedia page on hearing the shape of a drum.

import numpy as np
drum1 = np.array([[1, 3],
                  [0, 2],
                  [1, 1],
                  [2, 1],
                  [2, 0],
                  [3, 1],
                  [3, 2],
                  [1, 2]])

drum2 = np.array([[0, 3],
                  [0, 2],
                  [2, 0],
                  [2, 1],
                  [3, 1],
                  [2, 2],
                  [1, 2],
                  [1, 3]])

To solve PDEs on these shapes, we'll first generate outlines using the package pygmsh and save it to a .geo file.

import pygmsh
def make_geo(vertices, name, δx = 0.25):
    geometry = pygmsh.built_in.Geometry()
    N = len(vertices)

    points = [geometry.add_point([x[0], x[1], 0], lcar=δx) for x in vertices]
    lines = [geometry.add_line(points[n], points[(n + 1) % N]) for n in range(N)]
    line_loop = geometry.add_line_loop(lines)
    plane_surface = geometry.add_plane_surface(line_loop)

    for line in lines:
        geometry.add_physical(line)
    geometry.add_physical(plane_surface)

    with open(name, 'w') as geo_file:
        geo_file.write(geometry.get_code())
make_geo(drum1, 'drum1.geo')
make_geo(drum2, 'drum2.geo')

Then we'll use the mesh generator gmsh to triangulate the interiors of each polygon and save the resulting mesh to a .msh file.

!gmsh -2 -format msh2 -v 0 drum1.geo
!gmsh -2 -format msh2 -v 0 drum2.geo

Finally we'll read in the meshes using Firedrake and refine them.

import firedrake
coarse_mesh1 = firedrake.Mesh('drum1.msh')
mesh_hierarchy1 = firedrake.MeshHierarchy(coarse_mesh1, 4)
mesh1 = mesh_hierarchy1[-1]

coarse_mesh2 = firedrake.Mesh('drum2.msh')
mesh_hierarchy2 = firedrake.MeshHierarchy(coarse_mesh2, 4)
mesh2 = mesh_hierarchy2[-1]
%matplotlib inline
import matplotlib.pyplot as plt
fig, axes = plt.subplots(ncols=2, sharex=True, sharey=True)
for ax in axes:
    ax.set_aspect('equal')
    
firedrake.triplot(coarse_mesh1, axes=axes[0])
firedrake.triplot(coarse_mesh2, axes=axes[1]);

Solving the eigenproblem

To solve the eigenproblem, we'll use routines from package SLEPc just like we've done before for Weyl's law and Yau's conjecture. Since Firedrake is built on top of PETSc, any assembled linear operator has a PETSc matrix object living somewhere in the murky depths. Setting up the eigenproblem is just a matter of pulling out the underlying matrix objects and passing them to SLEPc.

from firedrake import inner, grad, dx
from petsc4py import PETSc
from slepc4py import SLEPc

def get_eigenvalues(mesh, num_eigenvalues=200):
    Q = firedrake.FunctionSpace(mesh, 'CG', 1)
    ϕ = firedrake.TestFunction(Q)
    ψ = firedrake.TrialFunction(Q)
    a = inner(grad(ϕ), grad(ψ)) * dx
    m = ϕ * ψ * dx

    bc = firedrake.DirichletBC(Q, firedrake.Constant(0), 'on_boundary')
    A = firedrake.assemble(a, bcs=bc).M.handle
    M = firedrake.assemble(m).M.handle

    opts = PETSc.Options()
    opts.setValue('eps_gen_hermitian', None)
    opts.setValue('eps_target_real', None)
    opts.setValue('eps_smallest_real', None)
    opts.setValue('st_type', 'sinvert')
    opts.setValue('st_ksp_type', 'cg')
    opts.setValue('st_pc-type', 'jacobi')
    opts.setValue('eps_tol', 1e-8)
    
    eigensolver = SLEPc.EPS().create(comm=firedrake.COMM_WORLD)
    eigensolver.setDimensions(num_eigenvalues)
    eigensolver.setOperators(A, M)
    eigensolver.setFromOptions()
    
    eigensolver.solve()
    
    num_converged = eigensolver.getConverged()
    λs = np.array([eigensolver.getEigenvalue(k) for k in range(num_converged)]).real
    return λs

As we expect, the eigenvaue distribution of the two domains is practically the same in the eyeball norm.

Λ1 = get_eigenvalues(mesh1)
Λ2 = get_eigenvalues(mesh2)

fig, axes = plt.subplots()
axes.plot(Λ1, color='tab:green')
axes.plot(Λ2, color='tab:blue');

To be more precise, the relative difference in the first 200 eigenvalues is below a 0.1%.

fig, axes = plt.subplots()
axes.plot(list(range(200)), np.abs((Λ1[:200] - Λ2[:200]) / Λ2[:200]));

Discussion

Steve Zelditch has a wonderful review of this topic on arXiv. As we've shown here experimentally, there are distinct isospectral planar domains, but the domains we used aren't convex. Zelditch proved that the spectrum uniquely characterizes the domain if it's convex and has an analytic boundary.

This type of problem has ramifications beyond just diverting mathematicians. For example, the same underlying mathematics shows up in eigenvalue problems for the Schroedinger equation. Knowing the interatomic potential, it's possible to calculate the quantum-mechanical energy levels. But if it's possible to infer things about potentials from energy levels -- essentially the Kac conjecture but for quantum mechanics -- then this could be useful in experimental physics as well.

Yau's Conjecture

In the last post we looked at the distribution of eigenvalues of the Laplace operator; this time we'll look at eigenfunctions. As a reminder, a function $\phi$ and a real number $\lambda$ are an eigenfunction and eigenvalue of the Laplace operator on a domain $\Omega$ if

$$-\Delta\phi = \lambda^2\phi$$

together with the Dirichlet boundary condition $\phi|_{\partial\Omega} = 0$. I'm using a slightly different convention that emphasizes the positivity of the Laplace operator, and which gives the eigenvalues $\lambda$ units of an inverse length.

The eigenfunctions grow more and more oscillatory for larger values of $\lambda$ and there are number of results that quantify this idea. A classical result is the Courant nodal domain theorem. The nodal domain theorem states that the zero set of the $n$-th eigenfunction of the Laplace operator divides the domain into at most $n$ regions, although the number of nodal domains can be less than $n$. Highly symmetric domains like the sphere or the torus provide interesting cases because, for these kinds of domains, you tend to get very degenerate eigenspaces. The Courant nodal domain theorem only gives a kind of upper bound on how oscillatory the eigenfunctions get, not a lower bound.

To refine the concept a bit further, we'll do a little experiment to verify the Yau conjecture. The Yau conjecture states that, as $\lambda \to \infty$, the area of the nodal set of $\phi_\lambda$ is bounded above and below a multiple of $\lambda$:

$$c\lambda \le \text{area}(\{\phi_\lambda = 0\}) \le C\lambda.$$

Donnelly and Fefferman proved the Yau conjecture for analytic manifolds (with and without boundary) in 1990. For smooth manifolds, Logunov announced a proof of the lower bound in May of 2019, but as far as I know the upper bound hasn't been resolved yet.

import numpy as np
from numpy import pi as π
def add_ellipse(geometry, x, y, a, b, N, lcar):
    θs = np.array([2 * π * n / N for n in range(N)])
    xs, ys = x + a * np.cos(θs), y + b * np.sin(θs)
    points = [geometry.add_point([x, y, 0], lcar=lcar) for x, y in zip(xs, ys)]
    lines = [geometry.add_line(points[n], points[(n + 1) % N])
             for n in range(N)]
    
    geometry.add_physical(lines)
    line_loop = geometry.add_line_loop(lines)
    return line_loop
import pygmsh
geometry = pygmsh.built_in.Geometry()

outer_line_loop = add_ellipse(geometry, x=0, y=0, a=2, b=1, N=256, lcar=1/4)
inner_loops = [
    add_ellipse(geometry, x=0, y=1/2, a=1/8, b=1/8, N=128, lcar=1/4),
    add_ellipse(geometry, x=1/2, y=1/4, a=3/16, b=3/16, N=128, lcar=1/4),
    add_ellipse(geometry, x=1, y=-1/4, a=1/4, b=1/4, N=192, lcar=1/4)
]

plane_surface = geometry.add_plane_surface(outer_line_loop, inner_loops)
geometry.add_physical(plane_surface)

with open('ellipse.geo', 'w') as geo_file:
    geo_file.write(geometry.get_code())
!gmsh -2 -format msh2 -v 0 -o ellipse.msh ellipse.geo
import firedrake
mesh = firedrake.Mesh('ellipse.msh')
import matplotlib.pyplot as plt
fig, axes = plt.subplots()
firedrake.triplot(mesh, axes=axes)
axes.set_aspect('equal')
axes.legend();
Q = firedrake.FunctionSpace(mesh, family='CG', degree=2)
ϕ = firedrake.TestFunction(Q)
ψ = firedrake.TrialFunction(Q)

from firedrake import inner, grad, dx
a = inner(grad(ϕ), grad(ψ)) * dx
m = ϕ * ψ * dx

bc = firedrake.DirichletBC(Q, 0, 'on_boundary')
A = firedrake.assemble(a, bcs=bc).M.handle
M = firedrake.assemble(m).M.handle

Setting up the eigensolver is the same as before.

from petsc4py import PETSc
opts = PETSc.Options()
opts.setValue('eps_gen_hermitian', None)
opts.setValue('eps_target_real', None)
opts.setValue('eps_smallest_real', None)
opts.setValue('st_type', 'sinvert')
opts.setValue('st_ksp_type', 'cg')
opts.setValue('st_pc-type', 'jacobi')
opts.setValue('eps_tol', 1e-8)
from slepc4py import SLEPc
num_values = 250
eigensolver = SLEPc.EPS().create(comm=firedrake.COMM_WORLD)
eigensolver.setDimensions(num_values)
eigensolver.setOperators(A, M)
eigensolver.setFromOptions()
eigensolver.solve()
num_converged = eigensolver.getConverged()
print(num_converged)
288

Now we need to use a bit of trickery to calculate the area of the zero contours:

$$\text{area}(\{\phi_\lambda = 0\}) = \int_{\{\phi_\lambda = 0\}}ds.$$

Firedrake doesn't have built-in support for integrating over interior contours of a domain that aren't already predefined when the mesh was generated, so we'll have to do something a little indirect. To evaluate this integral, I'll use the fact that the gradient of a smooth function is always perpendicular to its level sets. In particular, if $\nu$ is the unit outward normal vector to the super-level set $\{\phi_\lambda \ge 0\}$ and $u$ is any vector field such that $u \cdot \nu = 1$ along the region $\{\phi_\lambda = 0\}$, then

$$\int_{\{\phi_\lambda = 0\}}ds = \int_{\{\phi_\lambda = 0\}}u\cdot\nu\; ds = \int_{\{\phi_\lambda \ge 0\}}\nabla\cdot u\; dx.$$

Now we know that

$$\nu = -\frac{\nabla\phi_\lambda}{|\nabla\phi_\lambda|},$$

so it's enough to take

$$u = -\frac{\nabla\phi_\lambda}{|\nabla\phi_\lambda|}.$$

This vector field is well-defined except at critical points of the eigenfunction. If we wanted to be extra careful we could include a fudge factor somewhere in the denominator, but that doesn't seem to be necessary to get a sensible answer.

from firedrake import sqrt, div, dS
ϕ = firedrake.Function(Q)
sign = firedrake.conditional(ϕ >= 0, 1, 0) - firedrake.conditional(ϕ <= 0, 1, 0)
u = -grad(ϕ) / sqrt(inner(grad(ϕ), grad(ϕ))) * sign
ν = firedrake.FacetNormal(mesh)
J = 0.5 * div(u) * dx

lengths = np.zeros(num_values)
for index in range(num_values):
    Vr, Vi = A.getVecs()
    λ = eigensolver.getEigenpair(index, Vr, Vi)
    ϕ.vector()[:] = Vr
    lengths[index] = firedrake.assemble(J)
Es = np.array([eigensolver.getEigenvalue(k) for k in range(num_values)]).real
λs = np.sqrt(Es)

The plot of the wavenumber against the lengths of the nodal sets looks pretty close to linear in the eyeball norm!

area = firedrake.assemble(firedrake.Constant(1) * dx(mesh))

import matplotlib.pyplot as plt
fig, axes = plt.subplots()
axes.plot(λs, lengths, label='nodal set length')
axes.plot(λs, λs * area / π, label='lower bound')
axes.legend()
axes.set_xlabel('$\lambda$')
axes.set_ylabel('area($\{\phi_\lambda = 0\}$)');

I made a bit of a wild guess here for what the lower bound $c$ is. The area of the nodal sets has units of length${}^{d - 1}$ and the eigenvalues $\lambda$ have units of length${}^{-1}$. So for the constants $c, C$ to be dimensionally correct, they would need to have units of length${}^d$, which suggests that they're proportional to the volume of the domain. I get a pretty reasonable lower bound by dividing by $\pi$, which of course happens to be the volume of the unit disk in $\mathbb{R}^2$. It would be a fun experiment to see how well this holds up for other domains or higher dimensions.

Nitsche's method for nonlinear problems

In the past two posts, we showed how to use Nitsche's method for the Poisson equation and for the Stokes equations. In the latter case Nitsche's method makes it possible to implement a boundary condition that cannot be enforced in the conventional way. I got interested in all of this as a way to attack a particular problem in glacer flow. Stokes flow is a sound physical model for predicting the velocity of glaciers, but for ice the stress tensor is a power law function of the strain rate tensor:

$$\tau = B|\dot\varepsilon|^{\frac{1}{n} - 1}\dot\varepsilon$$

This begs the question of how to extend Nitsche's method for nonlinear PDE. I don't know how to do this and I couldn't find any published papers that had, so here are my attempts.

Rather than go straight to the Stokes equations, I thought it might be best to try things out on the Laplace equation with a power-law nonlinearity. For this problem we're only looking for a scalar field and not a vector field, and there's no incompressibility constraint to deal with. The power-law Laplace problem is to minimize the functional

$$J(u) = \int_\Omega\left(\frac{1}{p}k|\nabla u|^p - fu\right)dx$$

subject to the boundary condition $u|_{\partial\Omega} = g$. For $p = 2$, this is a quadratic minimization problem and so the Euler-Lagrange equation is linear. For other values of $p$ the Euler-Lagrange equations are nonlinear.

The Lagrange multiplier

The first step that we took with Nitsche's method was to compute what the Lagrange multiplier would be if we were to use a constrained optimization-type approach to enforce the boundary conditions. The Lagrangian is

$$L(u, \lambda) = J(u) + \int_{\partial\Omega}\lambda(u - g)ds.$$

Using the same tricks as before, we find that

$$\lambda = -k|\nabla u|^{p - 2}\frac{\partial u}{\partial n},$$

or in other words the Lagrange multiplier is the flux out of the boundary.

The Nitsche functional

When we derived Nitsche's method for linear problems, we were able to start with some guess for the boundary penalty term. The coefficients in the problem provide some physical scales that we were able to use to determine how the penalty would scale. We could then work out the dimensionless part based on some finite element inverse inequalities. Underlying this part of the procedure is the understanding that:

  1. a function $u$ that solves the Poisson equation will live in the Sobolev space $H^1(\Omega)$, and
  2. the boundary values of a function $u$ in $H^1(\Omega)$ live in the space $H^{1/2}(\partial\Omega)$.

The choice of boundary penalty is much less obvious for the p-Laplace equation. The solution $u$ lives in the Sobolev space $W_1^p(\Omega)$, so the 2-norm of the boundary value discrepancy might be either too restrictive or not restrictive enough depending on whether $p$ is less than or greater than 2. We'll start out with the fact that, if $u \in W_1^p(\Omega)$, then $u|_{\partial\Omega}$ is in $L^p(\partial\Omega)$. From this we can see what happens if we try a penalty that looks like:

$$J_{\text{boundary}} = \text{constant} \cdot \int_{\partial\Omega}\text{coefficients}\cdot|u - g|^p ds.$$

But we also need to make the units match up with those of the rest of the Lagrangian, the dimensions of which are

$$[J] = \left[\int_\Omega k|\nabla u|^p dx\right] = [k] \cdot [u]^p \cdot [x]^{d - p}.$$

The factor of $[x]^d$ comes from the measure $dx$, while the factor of $[x]^{-p}$ comes from the gradient of $u$. Since the surface measure $ds$ has units of $[x]^{d - 1}$, we can take the boundary penalty to be

$$J_{\text{boundary}} = \text{constant}\cdot\int_{\partial\Omega}\frac{k}{ph^{p - 1}}|u - g|^p ds.$$

All together now, a sensible-looking Nitsche functional for the power-law Laplace equation is

$$J_\gamma(u) = J(u) - \int_{\partial\Omega}k|\nabla u|^{p - 2}\frac{\partial u}{\partial n}(u - g)ds + \int_{\partial\Omega}\frac{\gamma k}{ph^{p - 1}}|u - g|^pds.$$

As a sanity check, observe that if $p = 2$ this is exactly equal to the Nitsche functional we derived for linear problems. You can also view this penalty as an approximation to the Slobodeckij semi-norm for functions that live in finite element spaces on meshes.

All we have to do now is (1) prove that it's convex for sufficiently large values of $\gamma$ and (2) determine what "sufficiently large" is.

The inverse inequality

To find a value of $\gamma$ that made the Nitsche functional convex for linear problems, we used sharp bounds for the constant in the inverse inequality from a paper by Warburton and Hesthaven. As a reminder, the inverse inequality states that, if $u$ of degree $m$ on a simplex $E$ in dimension $d$ and $Tu$ is the trace of $u$ on the boundary $\partial\Omega$,

$$\|Tu\|_{L^2(\partial\Omega)} \le \left(\frac{(m + 1)(m + d)}{d}\cdot\frac{|\partial E|}{|E|}\right)^{\frac{1}{2}}\|u\|_{L^2(\Omega)}.$$

Warburton and Hesthaven focused on estimates in the 2-norm in their paper. In this setting, the analytical expression for orthogonal polynomials on the simplex is especially handy.

To prove that the Nitsche functional for the power-law Laplace equation is convex, we'll need a sharp bound on the constant in the inverse inequality for general $p$-norms. Without the luxury of orthogonal polynomials as in the $p = 2$ case it might seem like we haven't go anywhere to go, but let's just focus on $p = \infty$ for now. If $u$ is bounded on $\Omega$, then so is its restriction to the boundary, and

$$\|Tu\|_{L^\infty(\partial\Omega)} \le \|u\|_{L^\infty(\Omega)}.$$

Consequently, the operation of taking traces, at least when restricted to degree-$m$ polynomials, is a bounded operator both from $L^2(\Omega) \to L^2(\partial\Omega)$ and from $L^\infty(\Omega) \to L^\infty(\partial\Omega)$. So we can apply the Riesz-Thorin theorem. For any $p$ between 2 and $\infty$, $T$ is well-defined and bounded from $L^p(\Omega)$ to $L^p(\partial\Omega)$, and moreover

$$\|T\|_p \le \|T\|_2^{2/p}\|T\|_\infty^{1 - 2/p} = \left(\frac{(m + 1)(m + d)}{d}\cdot\frac{|\partial E|}{|E|}\right)^{1/p}.$$

Invoking the Riesz-Thorin theorem is a little extreme, but I couldn't find a better way to arrive at the same result. There's another hitch here in that we've only worked things out for $p$ between 2 and $\infty$ but not between 1 and 2. There might be some elegant way to work out the sub-critical case too.

Convexity

Here's where things start to fall apart. I think the most convenient equivalent definition of convexity for this problem is that

$$\langle dJ_\gamma(u) - dJ_\gamma(v), u - v\rangle \ge 0$$

for all $u$ and $v$. To verify whether this condition holds or not, we'll need to calculate the derivative of $J_\gamma$:

$$\begin{align} \langle dJ_\gamma(u), v\rangle & = \int_\Omega\left(k|\nabla u|^{p - 2}\nabla u\cdot\nabla v - fv\right)dx \\ &\quad - \int_{\partial\Omega}k|\nabla u|^{p - 2}\left\{\left(I + (p - 2)\frac{\nabla u\otimes\nabla u}{|\nabla u|^2}\right)\nabla v\right\}\cdot n\cdot (u - g)\,ds \\ &\qquad - \int_{\partial\Omega}k|\nabla u|^{p - 2}\frac{\partial u}{\partial n}v\, ds + \int_{\partial\Omega}\frac{\gamma k}{h^{p - 1}}|u - g|^{p - 2}(u - g)\cdot v\, ds \end{align}$$

The boundary integral in the second line makes this much more complex than in the linear case. So far I haven't succeeded. If we wanted to instead calculate the second derivative and show that it's positive-definite, we'll have many more terms that need to be majorized and that doesn't look simple.

Experiment

I can't prove anything yet, but we can conduct an experiment to see whether my guess for a good penalty parameter will work. First, we'll take $u$, $g$, and $f$ to be random trigonometric polynomials. We can then calculate the second derivative of the Nitsche functional around $u$ symbolically. To show that the second derivative is positive-definite, we can calculate its smallest eigenvalue by pulling out the assembled PETSc matrix and calling an eigensolver from the package SLEPc. If this eigenvalue is negative then I've messed up somewhere. On the other hand, if we try this repeatedly and find that the second derivative is positive-definite for many different choices of $u$ and $g$, then we might just be onto something.

First, we'll create the boundary values and right-hand side as random trigonometric polynomials. I've chosen the number of modes and coefficient decay rate to give interesting-looking but reasonable input data.

import numpy as np
import firedrake

nx, ny = 32, 32
mesh = firedrake.UnitSquareMesh(nx, ny)
degree = 2
Q = firedrake.FunctionSpace(mesh, family='CG', degree=degree)

def random_fourier_series(Q, std_dev, num_modes, exponent):
    mesh = Q.mesh()
    x, y = firedrake.SpatialCoordinate(mesh)
    
    from firedrake import sin, cos
    from numpy import pi as π
    A = std_dev * np.random.randn(num_modes, num_modes)
    B = std_dev * np.random.randn(num_modes, num_modes)
    expr = sum([(A[k, l] * sin(π * (k * x + l * y)) +
                 B[k, l] * cos(π * (k * x + l * y))) / (1 + (k**2 + l**2)**(exponent/2))
                for k in range(num_modes)
                for l in range(int(np.sqrt(num_modes**2 - k**2)))])
    return firedrake.interpolate(expr, Q)
f = random_fourier_series(Q, std_dev=1.0, num_modes=6, exponent=1)
g = random_fourier_series(Q, std_dev=.25, num_modes=5, exponent=1)
import matplotlib.pyplot as plt
fig, axes = plt.subplots()
contours = firedrake.tricontourf(g, axes=axes)
fig.colorbar(contours);

Next I'll make a random field for $u$, create the action functional for the power-law Laplace equation, and create the right boundary terms for the Nitsche functional.

from firedrake import inner, grad, dx

u = random_fourier_series(Q, std_dev=.25, num_modes=5, exponent=1)

p = firedrake.Constant(3)
J_flexion = 1/p * inner(grad(u), grad(u))**(p/2) * dx
J_force = f * u * dx
J_energy = J_flexion - J_force
from firedrake import ds
n = firedrake.FacetNormal(mesh)
h = firedrake.CellSize(mesh)
σ = -inner(grad(u), grad(u))**((p - 2)/2) * grad(u)

J_flux = inner(σ, n) * (u - g) * ds
J_boundary = 1 / (p * h**(p - 1)) * abs(u - g)**p * ds

Finally we have to calculate a good value for $\gamma$ using the Warburton-Hesthaven bound in the inverse inequality and the calculated minimum angle of the mesh.

from numpy.linalg import norm
coords = mesh.coordinates.dat.data_ro
cells = mesh.coordinates.cell_node_map().values

θ = np.inf
for cell in cells:
    for k in range(3):
        x, y, z = coords[np.roll(cell, k)]
        ζ, ξ = y - x, z - x
        angle = np.arccos(np.inner(ζ, ξ) / (norm(ζ) * norm(ξ)))
        θ = min(angle, θ)
        
α = 1/2
γ = 1/α**2 * 2 * degree * (degree + 1) / np.sin(θ) / np.tan(θ/2)

We can now put everything together to make the Nitsche functional. Then we can compute the second derivatives of these functional around $u$ symbolically and assemble them into PETSc matrices.

from firedrake import assemble, derivative
F = derivative(J_energy + J_flux + γ * J_boundary, u)
a = derivative(F, u)

ϕ, ψ = firedrake.TestFunction(Q), firedrake.TrialFunction(Q)
m = ϕ * ψ * dx

A = assemble(a).M.handle
M = assemble(m).M.handle

Finally we'll solve an eigenproblem using SLEPc. The options we pass to SLEPc state that we're solving a generalized Hermitian eigenvalue problem, we're looking for the smallest real eigenvalue, and that we'll shift and invert the matrix to accelerate the solver. I'm using GMRES as a Krylov subspace solve rather than conjugate gradients because we're not sure that the matrix is postive definite.

from petsc4py import PETSc
from slepc4py import SLEPc

opts = PETSc.Options()
opts.setValue('eps_gen_hermitian', None)
opts.setValue('eps_target_real', None)
opts.setValue('eps_smallest_real', None)
opts.setValue('st_type', 'sinvert')
opts.setValue('st_ksp_type', 'gmres')
opts.setValue('st_pc-type', 'jacobi')
opts.setValue('eps_tol', 1e-8)

num_values = 1
eigensolver = SLEPc.EPS().create(comm=firedrake.COMM_WORLD)
eigensolver.setDimensions(num_values)
eigensolver.setOperators(A, M)
eigensolver.setFromOptions()
eigensolver.solve()
num_converged = eigensolver.getConverged()
print(num_converged)
2
Vr, Vi = A.getVecs()
λ = eigensolver.getEigenpair(0, Vr, Vi)

And the moment of truth:

print(λ)
(45.025734865098954+0j)

which is well and truly positive! In every test I've run so far it seems as if the second derivative of the Nitsche functional is positive-definite. Of course these tests are run using a few random fields, which is far from exhaustive. I still need a proof that my proposed value of the penalty parameter is good enough, but for now it's encouraging to know that it might work.

Nitsche's method for the Stokes equations

In a previous post, we saw how to enforce Dirichlet boundary conditions for the Poisson equation using Nitsche's method instead of the usual elimination-based approach. For other problems, elimination doesn't work because the Dirichlet boundary conditions involve, say, a linear combination of the solution degrees of freedom. In this post we'll look at enforcing friction boundary conditions for Stokes flow. Friction boundary conditions are a little exotic, and the elimination approach is no longer feasible. Consequently we have no choice but to use the penalty method or Nitsche's method.

The Stokes equations are what you get when you assume a fluid flows so slowly that acceleration is negligible. The fields we are solving for are the velocity $u$ and presure $p$. In a previous post, I showed that the Stokes equations can be derived from a variational principle. The objective for the Stokes equations is the rate of decrease of the Gibbs free energy:

$$\dot{\mathscr{G}}(u, p) = \int_\Omega\left(\frac{1}{2}\tau : \dot\varepsilon - p\nabla\cdot u - f\cdot u\right)dx,$$

where

$$\dot\varepsilon(u) = \frac{1}{2}(\nabla u + \nabla u^*)$$

is the strain rate tensor and, for a Newtonian fluid,

$$\tau = 2\mu\dot\varepsilon$$

is the stress tensor. The fact that the Stokes equations have a variational principle at all can be viewed as a consequence of the Onsager reciprocity relation, a fairly deep result in non-equilibrium statistical mechanics.

The Stokes equations are a constrained optimization problem and this makes them much harder to solve than unconstrained ones, like the Poisson equation. In the previous post, we showed some approaches for how to solve the linear sytems we get from discretizing the Stokes equations. Here we'll use all of this knowledge to see how we can solve the Stokes equations with an unusual set of boundary conditions.

Friction boundary conditions

For most fluid flow problems we assume that the velocity is fixed to some value $u_\Gamma$ around the boundary of the domain. I make a living modeling the flow of glaciers and this subject throws an interesting curveball in the form of ice sliding. The speed of glacier sliding over bedrock isn't fixed to some predetermined value; instead, the sliding speed is determined by frictional resistance, which is itself a function of the sliding speed. Deformable water-laden sediments offer very little resistance at all, while a hard crystalline bedrock has a lot of surface roughness and thus a high friction coefficient. How should we enforce this boundary condition in practical computations?

The friction boundary condition for Stokes flow is

$$(I - n\otimes n)\tau\cdot n = -\kappa(I - n\otimes n)(u - u_\Gamma),$$

where $n$ is the unit outward normal vector, $I$ is the identity matrix, and $\kappa$ is the drag coefficient. The extra factors of $I - n\otimes n$ restrict everything to the plane tangential to the boundary. We're going to have to write it so often that it's worth introducing the shorthand

$$\Pi v = (I - n\otimes n)v$$

to denote the projection $\Pi$ of a direction vector onto the tangent plane to the surface $\Gamma$.

In the perpendicular direction, the fluid velocity is 0:

$$u\cdot n = 0$$

which represents the fact that material can't leave the domain. Collectively, these are a Robin boundary condition in the tangential direction and a Dirichlet condition in the normal direction. The action functional with friction boundary conditions is

$$\dot{\mathscr{G}} = \int_\Omega\left(\frac{1}{2}\tau:\dot\varepsilon - p\nabla\cdot u - f\cdot u\right)dx + \frac{1}{2}\int_{\partial\Omega}\kappa(u - u_\Gamma)\cdot\Pi(u - u_\Gamma)ds.$$

We haven't addressed the Dirichlet BC, which is difficult to enforce directly. The normal vector $n$ is defined on mesh faces, while for many common finite elements the velocities $u$ are defined on mesh vertices and other facets.

One interesting thing to look at from a physics perspective is that the introduction of the friction coefficient now gives us more dimensionless numbers besides the Reynolds number $\text{Re} = \rho UL / \mu$. The viscosity coefficient has units of stress $\times$ time, while the friction coefficient has units of stress $\times$ time $\times$ length${}^{-1}$. From these two coefficients we can form a new length scale

$$\ell = \mu / \kappa$$

which is completely independent of the domain size $L$. We can then define a new dimensionless number $\ell / L$. You can think of $\ell$ as a length scale over which the velocity in the interior adjusts to the velocity at the boundary. As $\kappa \to \infty$, our friction boundary condition looks more and more like a Dirichlet boundary condition. For the following, we'll choose the friction coefficient so that this ratio is about equal to 1, but it's a fun experiment to see what happens as you make it larger or smaller.

The Lagrange multiplier

For the Poisson equation, introducing a Lagrange multiplier on the boundary did not give a feasible numerical method and the Stokes equations are no different. Nonetheless, we were able to explicitly describe the boundary Lagrange multiplier and this was necessary to figure out the correct form of the Nitsche functional. Let's try the same thing with the Stokes equations. The Lagrangian functional that incorporates the no-penetration constraint is

$$\mathscr{L}(u, p, \lambda) = \dot{\mathscr{G}} - \int_{\partial\Omega}\lambda u\cdot n\; ds.$$

The differential of the Lagrangian along a velocity $v$ is

$$\begin{align} \left\langle\frac{\partial\mathscr{L}}{\partial u}, v\right\rangle & = \int_\Omega\left(2\mu\dot\varepsilon(u):\dot\varepsilon(v) - p\nabla\cdot v - f\cdot v\right)dx \\ & \quad + \int_{\partial\Omega}\kappa(u - u_\Gamma)\cdot\Pi v\; ds - \int_{\partial\Omega}\lambda v\cdot n\; ds. \end{align}$$

We'll pursue the same strategy as before: push the derivatives of $v$ over onto $u$ assuming a strong solution exists, then collect boundary terms. This time we've got more than one direction to work with and that'll force us to be a bit more creative.

First, using Green's theorem and assuming $u$ and $p$ are nice enough, the last equation is

$$\ldots = \int_\Omega\left(-\nabla\cdot\tau + \nabla p - f\right)\cdot v\; dx + \int_{\partial\Omega}\left\{(\tau - pI)\cdot n + \kappa\Pi(u - u_\Gamma) - \lambda n\right\}\cdot v\; ds$$

where we've used the definition of the deviatoric stress tensor. To continue, we'll decompose $v$ into a normal and a tangential component:

$$v = (v\cdot n)n + \Pi v$$

and use the fact that the inner product of a vector normal to the boundary with a tangential vector is zero. The boundary integral then becomes

$$\int_{\partial\Omega}\cdots ds = \int_{\partial\Omega}\left\{n\cdot(\tau - pI)n - \lambda\right\}v\cdot n\; ds + \int_{\partial\Omega}\left(\tau\cdot n + \kappa(u - u_\Gamma)\right)\cdot\Pi v\; ds.$$

In order for $u$, $p$, and $\lambda$ to be a critical point of the Lagrangian, we need all of these terms to be zero. As expected, we recover the friction boundary condition

$$\Pi\tau\cdot n = -\kappa\Pi(u - u_\Gamma),$$

but we also get an exact expression for the Lagrange multiplier:

$$\lambda = n\cdot(\tau - pI)n.$$

We can also recognize that the full (not deviatoric) stress tensor $\sigma$ is defined as $\sigma = \tau - pI$, in which case the last expression can be rewritten as

$$\lambda = n\cdot\sigma n.$$

In other words, the Lagrange multiplier is just the normal stress!

Nitsche's method

The Nitsche functional for our problem is defined the same way as for the Poisson problem -- you substitute the expression for $\lambda$ into the augmented Lagrangian:

$$\dot{\mathscr{G}}_\gamma(u, p) = \dot{\mathscr{G}} - \int_{\partial\Omega}(n\cdot\sigma n)(u\cdot n)ds + \int_{\partial\Omega}\frac{\gamma\kappa}{2h}(u\cdot n)^2ds.$$

From here the derivation for how big $\gamma$ needs to be follows the exact same steps as for the Poisson equation. First, the middle term is broken up into the weighted mean square normal stress and the mean square normal velocity using the Peter-Paul inequality. Then we control the boundary stress in terms of the interior stress dissipation using the inverse inequality, only for tensors instead of vectors this time. From the explicit expression for the constant in the inverse inequality we can then determine just how big the penalty needs to be. The only difference is that, for the Poisson equation, there were $d$ vector components of the gradient, so we had to multiply the inverse inequality constant by $d$; in our case, there are $d^2$ components of the strain rate tensor. This argument gives

$$\gamma = 2\alpha^{-2}\cdot d\cdot p\cdot (d + p - 1)\cdot\sec\theta\cdot\cot\frac{\theta}{2}.$$

Let's see how well this works on a real example.

Geometry

We'll use the same domain as the previous example -- a circle of radius 1 with two circles of radius 1/8 removed from it.

import numpy as np
from numpy import pi as π
def add_ellipse(geometry, x, y, a, b, N, lcar):
    θs = np.array([2 * π * n / N for n in range(N)])
    xs, ys = x + a * np.cos(θs), y + b * np.sin(θs)
    points = [geometry.add_point([x, y, 0], lcar=lcar) for x, y in zip(xs, ys)]
    lines = [geometry.add_line(points[n], points[(n + 1) % N])
             for n in range(N)]

    geometry.add_physical(lines)
    line_loop = geometry.add_line_loop(lines)
    return line_loop
import pygmsh
geometry = pygmsh.built_in.Geometry()

outer_line_loop = add_ellipse(geometry, x=0, y=0, a=1, b=1, N=128, lcar=1/4)
inner_loops = [
    add_ellipse(geometry, x=0, y=+1/2, a=1/8, b=1/8, N=64, lcar=1/4),
    add_ellipse(geometry, x=0, y=-1/2, a=1/8, b=1/8, N=64, lcar=1/4)
]

plane_surface = geometry.add_plane_surface(outer_line_loop, inner_loops)
geometry.add_physical(plane_surface)

with open('mixer.geo', 'w') as geo_file:
    geo_file.write(geometry.get_code())
    
!gmsh -2 -format msh2 -v 0 -o mixer.msh mixer.geo
import firedrake
mesh = firedrake.Mesh('mixer.msh')

We have to do a bit of extra work to calculate the minimum angle of a mesh cell.

from numpy.linalg import norm

def minimum_angle(mesh):
    coords = mesh.coordinates.dat.data_ro
    cells = mesh.coordinates.cell_node_map().values

    if not ((mesh.cell_dimension() == 2) and
            mesh.is_piecewise_linear_simplex_domain()):
        raise ValueError("Only works on 2D triangular mesh!")

    min_angle = np.inf
    for cell in cells:
        for k in range(3):
            x, y, z = coords[np.roll(cell, k)]
            ζ, ξ = y - x, z - x
            angle = np.arccos(np.inner(ζ, ξ) / (norm(ζ) * norm(ξ)))
            min_angle = min(angle, min_angle)

    return min_angle

θ = minimum_angle(mesh)

Just like in the previous example, we'll set the velocity on the outer boundary to be 0, while the inner two circles are rotating with a fixed speed of 1.

from firedrake import Constant, as_vector
x = firedrake.SpatialCoordinate(mesh)

x2 = Constant((0, +1/2))
r2 = Constant(1/8)
x3 = Constant((0, -1/2))
r3 = Constant(1/8)

q2 = (x - x2) / r2
q3 = (x - x3) / r3

u_Γ = (
    as_vector((0., 0.)),
    as_vector((-q2[1], q2[0])),
    as_vector((-q3[1], q3[0]))
)

Solution

Now we can define our problem in much the same way as the last demo. We'll use the same function spaces, viscosity, and solver parameters.

Q = firedrake.FunctionSpace(mesh, family='CG', degree=1)
V = firedrake.VectorFunctionSpace(mesh, family='CG', degree=2)
Z = V * Q

Knowing the minimum angle of the mesh, the spatial dimension, and the polynomial degree for the velocity space, we can calculate the penalty parameter for Nitsche's method with no manual tuning at all.

k, d = 2, 2
η = 2 * d * k * (k + d - 1) / np.cos(θ) / np.tan(θ / 2)

And here we'll set the physical parameters for the problem. For the Stokes equations to be reasonable, we need a much larger viscosity than the product of the domain size and the characteristic speed, so we're using $\mu = 10^3$. We've also chosen a value of the friction coefficient $\kappa$ so that the ratio of the frictional length scale to the domain size is roughly equal to 2.

from firedrake import (
    inner, grad, dx, ds, sym, div, derivative,
    MixedVectorSpaceBasis, VectorSpaceBasis, DirichletBC
)

def ε(u):
    return sym(grad(u))

h = firedrake.CellSize(mesh)
n = firedrake.FacetNormal(mesh)

μ = Constant(1e3)
L = Constant(2.)
κ = Constant(2. * μ / L)

The objective functional has more parts than before, so to make the algebra more tractable we'll make separate variables for each summand.

z = firedrake.Function(Z)
u, p = firedrake.split(z)

u_n = inner(u, n)
Πu = u - u_n * n

I = firedrake.Identity(2)
σ_n = inner(n, (2 * μ * ε(u) - p * I) * n)

Ġ_viscous = μ * inner(ε(u), ε(u)) * dx
Ġ_pressure = p * div(u) * dx

Ġ_friction = 0.5 * κ * sum([inner(Πu - u_γ, Πu - u_γ) * ds(index)
                            for u_γ, index in zip(u_Γ, [1, 2, 3])])
Ġ_lagrange = σ_n * u_n * ds
Ġ_penalty = 0.5 * η * μ / h * u_n**2 * ds
 = Ġ_viscous - Ġ_pressure + Ġ_friction - Ġ_lagrange + Ġ_penalty

The solver parameters specify a direct factorization with MUMPS, which works well for 2D problems but less so for large 3D ones.

parameters = {
    'mat_type': 'aij',
    'ksp_type': 'preonly',
    'pc_type': 'lu',
    'pc_factor_mat_solver_type': 'mumps'
}

basis = VectorSpaceBasis(constant=True)
nullspace = MixedVectorSpaceBasis(Z, [Z.sub(0), basis])

firedrake.solve(
    derivative(, z) == 0, z,
    nullspace=nullspace,
    solver_parameters=parameters
)

u, p = z.split()

Finally we can plot the results. Compared to the outcome in the last demo where we fixed the velocity around the boundaries, the counter-rotating vortices to either side of the hyperbolic fixed point at the origin have vanished. If we increase the friction coefficient by a factor of 10 they reappear. It would be a fun exercise in bifurcation theory to see at what exact value of $\kappa$ the vortices appear.

%matplotlib inline
import matplotlib.pyplot as plt
fig, axes = plt.subplots()
axes.set_aspect('equal')
kwargs = {'resolution': 1/30, 'seed': 0, 'cmap': 'winter'}
streamlines = firedrake.streamplot(u, axes=axes, **kwargs)
fig.colorbar(streamlines);

As another experiment, you can re-run this notebook but reverse the direction of one of the mixer heads, which will remove the fixed point at the origin but will create two more on either side of it.

Conclusion

When we introduced Nitsche's method, we used it to enforce Dirichlet boundary conditions for the Poisson equation, but this was merely an alternative to the conventional approach. For the Stokes equations with friction boundary conditions there effectively is no workable conventional approach. Weird tricks like Nitsche's method are our only hope. In the next post, I'll try to apply Nitsche's method to nonlinear elliptic PDE, which is even more difficult.

Nitsche's method

or: robbing Peter to pay Paul

In this post I'll describe a way to enforce essential boundary conditions when discretizing a PDE using the finite element method. We'll start out with a simple model problem -- finding the minimizer of the functional

$$J(u) = \int_\Omega\left(\frac{1}{2}k|\nabla u|^2 - fu\right)dx$$

for $u$, subject to the Dirichlet boundary condition

$$u|_{\partial\Omega} = g.$$

Minimizing this quadratic functional is equivalent to finding a weak solution of the Poisson equation. (If you want to think of this like a heat conduction problem, $k$ is the thermal conductivity, $f$ is the volumetric heating, and $g$ is the temperature of the surrounding medium.)

In most circumstances you'd impose the Dirichlet boundary condition by changing entries of the matrix and right-hand side of the discretized linear system, but futzing with the linear system in this way is very error-prone. Nitsche's method is a way to impose essential boundary conditions by instead modifying the variational principle.

For other problems, there's no good way to impose the essential boundary conditions at all. An interesting case for me is the Stokes equations for a slow-flowing viscous fluid:

$$J(u, p) = \int_\Omega\left(\frac{1}{2}\tau : \dot\varepsilon - p\nabla\cdot u - f\cdot u\right)dx$$

where $u$ is the fluid velocity vector, $p$ the pressure, $\dot\varepsilon = (\nabla u + \nabla u^\top) / 2$ is the strain rate tensor, and $\tau = 2\mu\dot\varepsilon$ the stress tensor. Lid-driven cavity flow, where the fluid velocity is set to some specified value at the upper side of the box, is a common benchmark problem. The case I'm interested in is where we instead have friction boundary conditions. Rather than set the fluid velocity on the boundary, we instead imagine that the fluid is not in perfect contact with its container and that the movement of the lid merely exerts a drag force. Translating the physics into math gives the following conditions:

$$u\cdot n = 0, \quad (I - n\otimes n)\tau\cdot n = -\kappa(I - n\otimes n)(u - u_\Gamma)$$

on the segment $\Gamma$ of the domain boundary. These two conditions state that (1) the fluid flows tangential to the boundary and (2) the fluid slides through friction along the boundary, which is moving with speed $u_\Gamma$. Friction BCs are a little unusual -- they're like a Dirichlet condition in the normal direction and a Robin condition in the tangential directions. For a curved boundary, we can't impose the normal condition directly and that's where Nitsche's method is a real life-saver.

The old-fashioned way

Before embarking on any funny business, let's see how to solve the variational problem with Dirichlet BCs in the usual way. First, we'll use the unit square as our domain, and we'll use basis functions of degree 2.

import firedrake
nx, ny = 32, 32
mesh = firedrake.UnitSquareMesh(nx, ny, diagonal='crossed')
p = 2
Q = firedrake.FunctionSpace(mesh, family='CG', degree=p)

We need to come up with some reasonable right-hand side $f$ and boundary data $g$. To generate the input data we'll create a Fourier series with random coefficients. On simple domains like the unit square, you can write down the solution analytically in terms of the coefficients, which makes for a nice sanity check. In order to guarantee that we can reproduce the results later if need be, we'll explicitly seed the random number generator rather than let the system seed it.

import numpy as np
from numpy import pi as π
from numpy.random import default_rng

x, y = firedrake.SpatialCoordinate(mesh)

rng = default_rng(seed=0)
def random_fourier_series(std_dev, num_modes, exponent):
    from firedrake import sin, cos
    A = std_dev * rng.standard_normal((num_modes, num_modes))
    B = std_dev * rng.standard_normal((num_modes, num_modes))
    expr = sum([(A[k, l] * sin(π * (k * x + l * y)) +
                 B[k, l] * cos(π * (k * x + l * y)))
                / (1 + (k**2 + l**2)**(exponent/2))
                for k in range(num_modes)
                for l in range(int(np.sqrt(num_modes**2 - k**2)))])
    return firedrake.interpolate(expr, Q)
f = random_fourier_series(std_dev=1.0, num_modes=6, exponent=1)
g = random_fourier_series(std_dev=.25, num_modes=5, exponent=1)
import matplotlib.pyplot as plt
from mpl_toolkits import mplot3d
fig = plt.figure()
axes = fig.add_subplot(projection='3d')
firedrake.trisurf(g, axes=axes);

Now we'll form the action functional $J$ and solve for the minimizer. We have to pass the boundary conditions to the solver explicitly, and internally it will apply these boundary conditions to the solution.

We've also specified that we want to use the sparse direct solver MUMPS for the linear system. For 2D problems with less than roughly 250,000 unknowns, it's pretty hard to beat direct methods. On top of that, the goal of the current exercise is to experimenting with different ways to enforce essential boundary conditions, and we should try to eliminate any other possible sources of variability in our solution. If we used an iterative method like CG, the difference between the solution we'll compute here and the one we'll obtain from Nitsche's method could end up being more a result of a bad preconditioner. Using direct methods eliminates that possibility.

from firedrake import inner, grad, dx, ds
u = g.copy(deepcopy=True)

J = (0.5 * inner(grad(u), grad(u)) - f * u) * dx
F = firedrake.derivative(J, u)

parameters = {
    'solver_parameters': {
        'ksp_type': 'preonly',
        'pc_type': 'lu',
        'pc_factor_mat_solver_type': 'mumps'
    }
}
bc = firedrake.DirichletBC(Q, g, 'on_boundary')
firedrake.solve(F == 0, u, bc, **parameters)
fig = plt.figure()
axes = fig.add_subplot(projection='3d')
firedrake.trisurf(u, axes=axes);

The function firedrake.solve takes the Dirichlet boundary conditions as an extra argument and makes all the necessary changes to the linear system for us. This strategy doesn't work out so well for more complex problems, as I pointed out above for the Stokes equations with friction boundary conditions. Nonetheless, we'll use the Poisson problem to demonstrate everything in the following. The fact that we can easily get a ground truth value by directly applying the Dirichlet BCs makes the results fairly easy to analyze after the fact. Then we'll use the intuition we've built up on this easy problem to attack harder ones.

The penalty method

The Poisson equation with Dirichlet boundary conditions can be viewed as a constrained optimization problem. A really blunt way of enforcing these constraints is to modify the variational principle so that departures from the Dirichlet boundary conditions are "expensive" and thus a minimizer will be pretty close to $g$ on the boundary. The modified variational principle is:

$$J_\gamma(u) = \int_\Omega\left(\frac{1}{2}k|\nabla u|^2 - fu\right)dx + \int_{\partial\Omega}\frac{\gamma k}{2h}(u - g)^2ds.$$

The extra factor of $h$ is a length scale which we need there to make all the physical units work out right, while $\gamma$ is a dimensionless constant. The penalty method amounts to replacing the Dirichlet boundary condition with the Robin condition

$$-k\frac{\partial u}{\partial n} = \gamma\frac{k(u - g)}{h}.$$

In the limit as $\gamma \to \infty$, the minimizer of $J_\gamma$ will approach the solution of the problem we were originally solving.

For a numerical implementation we'll just take $\gamma$ to be very large and hope for the best. In our case we'll use

$$\gamma = |\Omega|^{1/d} / h$$

where $|\Omega|$ is the volume of the domain and $h$ is the diameter of a cell of the mesh.

v = g.copy(deepcopy=True)

h = firedrake.CellSize(mesh)
area = firedrake.assemble(firedrake.Constant(1) * dx(mesh))
γ = firedrake.Constant(np.sqrt(area)) / h
J = (0.5 * inner(grad(v), grad(v)) - f * v) * dx + 0.5 * γ / h * (v - g)**2 * ds
F = firedrake.derivative(J, v)

firedrake.solve(F == 0, v, **parameters)
fig = plt.figure()
axes = fig.add_subplot(projection='3d')
firedrake.trisurf(v, axes=axes);

The errors are largest around the boundary of the domain and the magnitudes are appreciably smaller than the spread of the solution itself.

δu = firedrake.interpolate(u - v, Q)

fig = plt.figure()
axes = fig.add_subplot(projection='3d')
firedrake.trisurf(δu, cmap='RdBu', axes=axes);

For comparison later, we'll calculate the relative difference between the exact solution and the solution obtained from the penalty method:

penalty_error = firedrake.norm(u - v) / firedrake.norm(u)
print(penalty_error)
0.004474209285244504

All told this involved very little extra work compared to adding the boundary conditions the traditional way. There are a number of disadvantages that ultimately make this method undesirable though, most of which can be gleaned from exercise 3.2.2 in Ciarlet's book. We used a penalty parameter $\gamma$ that scaled like $h^{-1}$ as the mesh is refined (this is in addition to the factor of $h^{-1}$ that was necessary to get the units right). If the finite element space is asymptotically $\mathscr{O}(h^p)$ accurate, this scaling gives solutions that are only $\mathscr{O}(h^{p/2})$ accurate. The penalty method effectively negates the greatest advantage of finite element analysis, namely the quasi-optimal accuracy of the solution.

With this scaling, the condition number growth of the linear systems as the mesh is refined is even worse than for the stiffness matrix of the original problem. In two dimensions, we can partially side-step this issue by solving the linear system with a direct factorization. Direct methods fare much worse in three dimensions, where iterative methods are a virtual necessity for even modest problem sizes. The poor conditioning of the penalized problem makes iterative methods take much longer to converge.

The Lagrange multiplier method

The penalty method is easy to implement, but it can wreck both the accuracy of the solution we obtain and the condition number of the linear system we have to solve. Instead, we might try to enforce the BC directly by introducing a Lagrange multiplier field $\lambda$ defined on the boundary of the domain. The Lagrangian is defined as

$$L(u, \lambda) = \int_\Omega\left(\frac{1}{2}k|\nabla u|^2 - fu\right)dx + \int_{\partial\Omega}\lambda(u - g)ds.$$

We could then solve for $u$ and $\lambda$ simultaneously using, say, the Uzawa algorithm.

Unfortunately this approach goes all to hell when we try to discretize it. The nice part about finite element analysis for the Poisson and related equations is that questions about accuracy of the solution all boil down to approximation theory. You can use just about any discretization so long as the finite element spaces can approximate $H^1$ in the limit. All this nice math hinges on the fact that the Poisson equation is positive-definite, but when we introduce a Lagrange multiplier we end up instead with a saddle-point problem. You can't use just any discretization for a saddle-point problem and there are criteria called the Ladyzhenskaya-Babuška-Brezzi condition that tell us when we do (or do not) have a stable discretization. (The LBB condition is why you need to use Taylor-Hood elements or bubble functions when solving the Stokes equations.)

Ivo Babuška himself proposed this Lagrange multiplier approach for boundary conditions in the 70s but left open the question of which finite element to use for $\lambda$. Juhani Pitkäranta, in a series of three papers in the 80s, went and solved this problem and found that the stability conditions on the elements for $\lambda$ are so strict as to be almost impossible to implement. So far as I know, no common FEA packages implement them.

There's still something valuable we can learn from the Lagrange multiplier approach, despite the fact that it isn't practical to use numerically. Let $u$ and $\lambda$ be the extremum of the Lagrangian $L$; the two fields solve the weak PDE

$$0 = \int_\Omega\left(k\nabla u\cdot\nabla v - fv\right)dx + \int_{\partial\Omega}\lambda v\; ds$$

for all $v$ in $H^1$. Now suppose that the input data are nice enough for elliptic regularity to apply. We can push the gradient of $v$ over on $u$:

$$\ldots = -\int_\Omega\left(\nabla\cdot k\nabla u + f\right)v\; dx + \int_{\partial\Omega}\left(\lambda + k\frac{\partial u}{\partial n}\right)v\; ds.$$

In order for this quantity to be 0 for any $v$, regardless of its boundary values, we need for

$$\lambda = -k\frac{\partial u}{\partial n}.$$

In other words, the Lagrange multiplier is equal to the boundary flux of the solution. This is going to be lead us, by of the augmented Lagrangian method, to Nitsche's method.

The augmented Lagrangian method

In the limit as $\gamma$ gets large, the solution of the penalty method approaches the true solution to the constrained problem. With a bit of extra analysis it's possible to establish a relation between the penalty and Lagrange multiplier methods. In the limit as $\gamma \to \infty$, we actually recover what the Lagrange multiplier is from the penalty method:

$$\lambda = -\lim_{\gamma\to\infty}\gamma k(u - g)/h.$$

(You probably have to be careful about whether $h \to 0$ or $\gamma \to \infty$ first but let's... not think about that too hard.) This suggests some kind of alternative where we use the departure from the constraints to guess the right value of the Lagrange multiplier in the hopes of achieving faster convergence. When you run with this idea you get the augmented Lagrangian method.

The dumbest description I can think of the augmented Lagrangian method is that you take the penalty method and the Lagrange multiplier method and smash them together:

$$L_\gamma(u, \lambda) = \int_\Omega\left(\frac{1}{2}k|\nabla u|^2 - fu\right)dx + \int_{\partial\Omega}\lambda(u - g)ds + \int_{\partial\Omega}\frac{\gamma k}{2h}(u - g)^2ds.$$

When $u$ satisfies the constraints, the penalty term is exactly 0. You can think of this modification as adding an extra term to the Lagrangian that doesn't modify the location of the extremum, but which does alter the curvature far from it in a way that's particularly advantageous.

The payoff for adopting this approach is that we can get a solution $u$ that exactly satisfies the constraints but without taking $\gamma \to \infty$. Consequently we don't blow up the condition number like we do for the penalty method. On top of that, there's a simple and convergent iterative procedure to update both $\gamma$ and $\lambda$ for which we only solve a positive-definite linear system at each step.

For the penalty method, you get the best accuracy by making $\gamma$ asymptotic to some power of $h^{-1}$. At this point it would be tempting to try and carry out the same analysis for the augmented Lagrangian method. Instead, we'll use the fact that $\lambda$ is equal to the boundary flux in equilibrium to eliminate this variable entirely, leading us to

Nitsche's method

The variational principle for Nitsche's method is obtained by substituting the analytical expression for the Lagrange multiplier $\lambda$ into the augmented Lagrangian:

$$J_\gamma(u) = \int_\Omega\left(\frac{1}{2}k|\nabla u|^2 - fu\right)dx - \int_{\partial\Omega}k\frac{\partial u}{\partial n}(u - g)ds + \int_{\partial\Omega}\frac{\gamma k}{2h}(u - g)^2ds.$$

The extra terms are both 0 at a solution of the original variational problem with the boundary conditions we specified. So a critical point of $J_\gamma$ is also a solution, but I say critical point because we haven't proved yet that this functional is convex. Whether or not it's convex will hinge on our choice of the penalty parameter $\gamma$, and with a little elbow grease we can figure out exactly how big the penalty needs to be.

We want to show that the Nitsche functional has the same positivity properties as the original functional. This means showing that its second derivative is a strictly positive-definite operator, in other words that

$$\langle d^2J_\gamma\cdot v, v\rangle = \int_\Omega k|\nabla v|^2dx - 2\int_{\partial\Omega}k\frac{\partial v}{\partial n}v\; ds + \int_{\partial\Omega}\frac{\gamma k}{h}v^2ds > 0$$

any $v$ in $H^1$. For our purposes, proving that the Hessian of $J_\gamma$ is positive-definite is easiest because it's twice-differentiable. There are several other definitions of convexity that can be easier to work from depending on the scenario, like if the functional isn't differentiable everywhere. The important point is that convexity guarantees the existence and uniqueness of a solution. From there we can prove the norm-equivalence properties that make finite element analysis possible.

A first (failing) attempt

The middle term of the last equation is clearly the troublesome part, while the remaining two terms are manifestly positive. As a first guess, we'll try using Young's inequality:

$$2ab \le a^2 + b^2.$$

By applying this to the middle term, we can break it up into a sum of two boundary integrals that hopefully can be controlled:

$$2\int_{\partial\Omega}k\frac{\partial v}{\partial n}v\; ds \le \int_{\partial\Omega}k\left(\frac{\partial v}{\partial n}\right)^2ds + \int_{\partial\Omega}kv^2\; ds.$$

The second term of the last equation looks like something that we can control with the boundary penalty, but the first term is a little harder to crack. If we know that $v$ is in $H^1(\Omega)$, then the Sobolev trace inequality tells us that $v|_{\partial\Omega}$ is in the Slobodeckij space $H^{1/2}(\partial\Omega)$. We can then show that $\partial v/\partial n$ is in the dual space $H^{-1/2}(\partial\Omega)$, but to control the boundary flux norm in the last equation we would at the very least need that it's in $L^2(\partial\Omega)$. That might be true but only if we assume a greater degree of regularity from the get-go. You could also guess that the last expression is a load of bunk because the physical units don't come out right!

Inverse inequalities

Using Young's inequality isn't totally far off, we just need to apply a slightly different form of it. Before doing that, however, I need to introduce a finite element inverse inequality. Inverse inequalities apply only to polynomials and not to all of $H^1$. These results are still useful to us because the basis functions in finite element analysis are polynomials. Moreover, we'll assume that the domain $\Omega$ has been subdivided into a regular mesh of elements $\{E_i\}$ where each $E_i$ is a triangle or tetrahedron.

What's interesting about inverse inequalities is that they control things in the opposite direction from what you expect. For example, the Poincaré inequality states that, if $v$ is in $H^1$ and $v|_{\partial\Omega} = 0$, then

$$\int_\Omega v^2dx \le C\int_\Omega|\nabla v|^2dx.$$

The corresponding inverse inequality states that, if $v$ is a polynomial of degree $p$ on a simplex $E$, then

$$\int_E|\nabla v|^2dx \le h^{-2}C(d, p, E)\int_E v^2dx$$

where $h$ is the radius of the simplex $E$ and $C$ is a constant that depends on the dimension $d$, the polynomial degree $p$, and the shape regularity of $E$, but not on $v$. This inequality does not imply the ridiculous conclusion that the $H^1$ norm can be bounded by the $L^2$ norm as we refine a finite element mesh. The factors $h^{-2}$ would blow up as the cells get smaller and smaller.

The particular inverse inequality that we'll need states that, if $v$ is a polynomial of degree $p$, then its square norm on the boundary can be controlled by the norm over the interior:

$$\int_{\partial E}v^2ds \le C(d, p)\cdot\frac{|\partial E|}{|E|}\cdot\int_E v^2dx$$

where $|E|$, $|\partial E|$ are respectively the volume of the simplex and the area of its boundary. Using a bit of trigonometry we can show that, for a triangle with shortest side length $h$ and smallest angle $\theta$,

$$\frac{|E|}{|\partial E|} \ge \sin\theta\cdot\tan\theta/2\cdot h/2.$$

We can calculate $\theta$ explicitly by looping over all the triangles of the mesh, or we can rely on the fact that nowadays mesh generators guarantee that the minimum angle is greater than, say, $\pi / 12$.

The usual approach to proving these inverse inequalities (for example, in Ciarlet's book) is to note that the space of polynomials up to a given degree is finite-dimensional and that all norms on finite-dimensional vector spaces are equivalent. This proof strategy does not, however, give any clue as to what the constant $C$ might be. You can figure out exactly what the constant is for piecewise linear finite elements on triangles, but it would be a lot nicer if we could compute a sharp bound that would work for any degree. In a wonderfully readable paper from 2003, Warburton and Hesthaven showed that, for a polynomial $u$ of degree $p$ on a simplex $E$ in $d$ dimensions,

$$\int_{\partial E}u^2ds \le \frac{(p + 1)(p + d)}{d}\cdot\frac{|\partial E|}{|E|}\cdot\int_Eu^2dx.$$

The proof relies on the fact that orthogonal polynomials on a simplex can be expressed in terms of Jacobi polynomials.

We're more interested in controlling the gradient of $v$ on the boundary rather than the values of $v$ itself. We can still use the boundary inverse inequality by noting that $\nabla v$ is a polynomial of one lower degree and with $d$ components:

$$\int_{\partial E}\left(\frac{\partial v}{\partial n}\right)^2ds \le d\cdot C(d, p - 1)\cdot\frac{|\partial E|}{|E|}\cdot\int_E|\nabla v|^2dx \ldots$$

and by applying both the trigonometric bound for the surface-to-volume ratio and the explicit formula for the constant $C$ we get that

$$\ldots \le 2p\cdot(p + d - 1)\cdot\csc\theta\cot\theta/2\cdot h^{-1}\cdot \int_E|\nabla v|^2dx.$$

The extra factor of $h$ is what we were missing before when we tried to apply Young's inequality. From now on we'll write the constant in the last inequality as $C(d, p, \theta)$. The important point to note is that $C(d, p, \theta)$ increses to infinity as $\theta$ approaches 0, so the more regular the mesh the better.

The Peter-Paul inequality

The inverse inequality suggests that we can indeed control the norm of the boundary flux, but we need to sneak in an extra factor of $h$ in order to be able to use it. That's where the famous "Young's inequality with $\epsilon$" comes in. This inequality is also called the Peter-Paul inequality after the English expression "robbing Peter to pay Paul"; the expression dates from the middle ages when the assets and properties of St. Peter's Cathedral in London were siphoned off to pay for the repairs and upkeep of St. Paul's Cathedral.

Historical anecdotes aside, the Peter-Paul inequality is:

$$2ab \le \underbrace{\epsilon^{-1}a^2}_{\text{Peter}} + \underbrace{\epsilon b^2}_{\text{Paul}}.$$

where $\epsilon$ is an arbitrary positive number. If we make $\epsilon$ very small then we can control one term at the expense of making the other very large. We'll take $\epsilon$ to also include a factor of $h$, and use it to break up the troublesome term in the Nitsche functional:

$$2\int_{\partial\Omega}k\frac{\partial v}{\partial n}v\; ds \le \epsilon\int_{\partial\Omega}hk\left(\frac{\partial v}{\partial n}\right)^2ds + \epsilon^{-1}\int_{\partial\Omega}\frac{k}{h}v^2\; ds.$$

We haven't decided what $\epsilon$ needs to be yet; it relates to the constant $C$ in the inverse inequality. I should also be more specific about $h$. Assuming that $\Omega$ has been divided into triangles $\{E_i\}$, we'll define $h$ to be equal to

$$h = \sum_i \text{diameter}(E_i)\cdot \mathbb{1}_{E_i}$$

where $\mathbb{1}_A$ is the indicator function of the set $A$. In other words the function $h$ gives the local element diameter. The important point here is that $h$ depends on position and consequently needs to live inside the integrals and not out.

Now that we have the extra factor of $h$ we can apply the inverse inequality. There's a little extra work to account for the presence of the nonhomogeneous conductivity coefficient $k$ in our variational problem. For the problem to be well-posed we need that $k$ is bounded above and below by positive constants. From the original form of the inverse inequality, we can then take a few extra steps to arrive at the following:

$$\int_{\partial\Omega}kh\left(\frac{\partial v}{\partial n}\right)^2ds \le C(d, p, \min\theta)\cdot\frac{\max_\Omega k}{\min_\Omega k}\cdot\int_\Omega k|\nabla v|^2dx.$$

We could pull the ratio of the maximum of $k$ to the minimum inside the integral and calculate it over each cell instead over the whole domain if we needed an even tighter inequality. For example if there were large contrasts in $k$ over the whole domain but not over a single element then that could be a big improvement. Likewise, we could also pull the constants $C(d, p, \theta)$ inside the integral, which we would then express as a sum over the intersections of $\partial\Omega$ with each cell. For highly anisotropic meshes, it might be worthwhile to keep the constants inside the sum, but in the interest of simplicity we'll leave this as-is.

The last inequality tells us exactly how small $\epsilon$ needs to be:

$$\epsilon = \alpha\cdot C(d, p, \min\theta)^{-1}\cdot\frac{\min_\Omega k}{\max_\Omega k}$$

for some constant $\alpha$ such that $0 < \alpha < 1$. When we go to compute things we'll take $\alpha = 1/2$, but for now we'll leave it arbitrary. Finally, we can put everything together:

$$\begin{align} \langle d^2J_\gamma\cdot v, v\rangle & = \int_{\Omega}k|\nabla v|^2dx - 2\int_{\partial\Omega}k\frac{\partial v}{\partial n}v\;ds + \int_{\partial\Omega}\frac{\gamma k}{h}v^2ds \\ & \underset{\text{Peter-Paul}}{\ge} \int_{\Omega}k|\nabla v|^2dx - \epsilon\int_{\partial\Omega}hk\left(\frac{\partial v}{\partial n}\right)^2ds + \int_{\partial\Omega}\frac{(\gamma - \epsilon^{-1})k}{h}v^2ds \\ & \underset{\text{inverse ineq.}}{\ge} (1 - \alpha)\int_{\Omega}k|\nabla v|^2dx + \int_{\partial\Omega}\frac{(\gamma - \epsilon^{-1})k}{h}v^2ds. \end{align}$$

The last inequality now tells us how big we need to take the penalty parameter $\gamma$:

$$\gamma > \epsilon^{-1} = \alpha^{-1}C(d, p, \min\theta)\frac{\max_\Omega k}{\min_\Omega k}.$$

Again for definiteness sake we can set $\gamma = \alpha^{-1}\epsilon^{-1}$ when we go to compute things.

Let's recap things a bit. First, we broke up the troublesome middle term using the Peter-Paul inequality. This middle term gets broken up into the square norm of a boundary flux and the square norm of a boundary value. We can control the boundary flux in terms of the square norm of the interior flux by using a finite element inverse inequality. In order to "pay Paul", we wedge in an extra factor of $h$ in Young's inequality. But we can't pay Paul without "robbing Peter" when we weight the boundary value integral by $h^{-1}$. After that a judicious choice of the penalty parameter $\gamma$ ensures that everything is positive, which implies convexity and therefore well-posedness.

Demonstration

Almost all the pieces that we need to implement this are part of UFL. The only extra factor is determining the shape regularity of each triangle.

from numpy.linalg import norm

coords = mesh.coordinates.dat.data_ro
cells = mesh.coordinates.cell_node_map().values

θ = np.inf
for cell in cells:
    for k in range(3):
        x, y, z = coords[np.roll(cell, k)]
        ζ, ξ = y - x, z - x
        angle = np.arccos(np.inner(ζ, ξ) / (norm(ζ) * norm(ξ)))
        θ = min(angle, θ)
        
print("Minimum angle: {} * π".format(θ / π))
Minimum angle: 0.25000000000000006 * π

From here we can easily define the modified variational principle for Nitsche's method. We've chosen $\alpha = 1/2$ in this case, in which case the boundary penalty gets weighted by $\alpha^{-2}$.

v = g.copy(deepcopy=True)

h = firedrake.CellSize(mesh)
n = firedrake.FacetNormal(mesh)

J_interior = (0.5 * inner(grad(v), grad(v)) - f * v) * dx
J_flux = -inner(grad(v), n) * (v - g) * ds
J_penalty = 0.5 * (v - g)**2 / h * ds

α = 1/2
C = p * (p + 1)
γ = firedrake.Constant(C / α**2 / (np.sin(θ) * np.tan(θ/2)))
J = J_interior + J_flux + γ * J_penalty

F = firedrake.derivative(J, v)
firedrake.solve(F == 0, v, **parameters)
fig = plt.figure()
axes = fig.add_subplot(projection='3d')
firedrake.trisurf(v, axes=axes);

The error is smaller than that of the pure penalty method by a factor of 1000! On top of that, we were able to achieve this improvement with a penalty $\gamma$ that's a constant as the mesh is refined. For the pure penalty method, $\gamma$ scales like $h^{-1}$, which makes the condition number for the linear system much worse as the mesh is refined. Nitsche's method, by contrast, has the same asymptotic condition number growth as the original problem.

nitsche_error = firedrake.norm(u - v) / firedrake.norm(u)
print(nitsche_error / penalty_error)
0.0012195126881258055

Having a sharp estimate for the constant in the inverse inequality enabled us to set the penalty parameter without any guesswork or hand-tuning. Most of the papers I've found on Nitsche's method completely gloss over this point, but I think the ability to set parameters like this with no intervention on the part of an end user is essential if your goal is to deliver code to domain scientists. Say you're developing a package for modeling groundwater flow; you should not go expecting hydrologists to know or care about the details of how you implemented the boundary conditions, they should just work.

Weyl's law

In this post we'll look at eigenfunctions and eigenvalues of the Laplace operator $\Delta$ on a domain $\Omega$ in $\mathbb{R}^d$. A function $\phi$ on $\Omega$ and a number $\lambda$ are an eigenfunction/eigenvalue pair if

$$-\Delta\phi = \lambda^2\phi$$

along with the Dirichlet boundary condition $\phi|_{\partial\Omega} = 0$. The operator $-\Delta$ is symmetric and positive-definite, so the eigenvalues are real and positive. I've chosen a slightly different way of writing things in terms of $\lambda^2$ because this makes the units of the eigenvalues an inverse length.

The Weyl asymptotic law describes how the eigenvalues grow as a function of the domain size and shape. Weyl proved in 1911 that, if $N(\lambda)$ is the number of eigenvalues of the Dirichlet Laplacian less than $\lambda$, that

$$N(\lambda) = (2\pi)^{-d}\omega_d\cdot\text{vol}(\Omega)\cdot\lambda^{d} + \mathscr{O}(\lambda^{d})$$

as $\lambda \to \infty$, where $\omega_d$ is the volume of the unit ball in $\mathbb{R}^d$. As a sanity check, note that $\lambda$ has units of length${}^{-1}$, so the formula above is dimensionless. As another sanity check, you can look at the analytical expression for the eigenvalues on a box or a sphere. The proof given in volume 1 of Courant and Hilbert is pretty easy to follow. Weyl conjectured that the second term could be expressed in terms of the area of the boundary:

$$N(\lambda) = (2\pi)^{-d}\omega_d\cdot\text{vol}(\Omega)\cdot\lambda^d - \frac{1}{4}(2\pi)^{1 - d}\omega_{d - 1}\cdot\text{area}(\partial\Omega)\cdot\lambda^{d - 1} + \mathscr{o}\left(\lambda^{d - 1}\right)$$

but this wasn't proved in his lifetime. Here we'll come up with a simple domain and show how you might verify this law numerically.

Making a mesh

First, we'll use the package pygmsh to create the spatial domain. Pygmsh is a Python wrapper around the mesh generator gmsh; pygmsh adds the nice feature of keeping track of all the entity ID numbers for you. The domain we'll use will be an ellipse with three circles removed from it. To keep the repetition down we'll first introduce a helper function that adds an ellipse to an existing geometry.

import numpy as np
from numpy import pi as π
def add_ellipse(geometry, x, y, a, b, N, lcar):
    θs = np.array([2 * π * n / N for n in range(N)])
    xs, ys = x + a * np.cos(θs), y + b * np.sin(θs)
    points = [geometry.add_point([x, y, 0], lcar=lcar) for x, y in zip(xs, ys)]
    lines = [geometry.add_line(points[n], points[(n + 1) % N])
             for n in range(N)]

    geometry.add_physical(lines)
    line_loop = geometry.add_line_loop(lines)
    return line_loop

The following code actually creates the domain. The calls to add a plane surface and a physical plane surface are easy to forget but essential.

import pygmsh
geometry = pygmsh.built_in.Geometry()

outer_line_loop = add_ellipse(geometry, x=0, y=0, a=2, b=1, N=256, lcar=1/4)
inner_loops = [
    add_ellipse(geometry, x=0, y=1/2, a=1/8, b=1/8, N=128, lcar=1/4),
    add_ellipse(geometry, x=1/2, y=1/4, a=3/16, b=3/16, N=128, lcar=1/4),
    add_ellipse(geometry, x=1, y=-1/4, a=1/4, b=1/4, N=192, lcar=1/4)
]

plane_surface = geometry.add_plane_surface(outer_line_loop, inner_loops)
geometry.add_physical(plane_surface)

with open('ellipse.geo', 'w') as geo_file:
    geo_file.write(geometry.get_code())
    
!gmsh -2 -format msh2 -v 0 -o ellipse.msh ellipse.geo

To make sure everything worked right, we'll visualize the mesh after loading it in.

import firedrake
import matplotlib.pyplot as plt
mesh = firedrake.Mesh('ellipse.msh')

fig, axes = plt.subplots()
firedrake.triplot(mesh, axes=axes)
axes.set_aspect('equal')
axes.legend();

Using SLEPc

To compute the eigenvalues and eigenfunctions of the Laplace operator, we'll use the Scalable Library for Eigenvalue Problem Computations (SLEPc). Both SLEPc and Firedrake are built on top of PETSc, so creating the eigenvalue problem is just a matter of assembling the Firedrake form objects representing our linear operators, extracting the underlying PETSc matrix objects, and passing them to SLEPc.

Q = firedrake.FunctionSpace(mesh, family='CG', degree=2)
ϕ = firedrake.TestFunction(Q)
ψ = firedrake.TrialFunction(Q)

from firedrake import inner, grad, dx
a = inner(grad(ϕ), grad(ψ)) * dx
m = ϕ * ψ * dx

bc = firedrake.DirichletBC(Q, 0, 'on_boundary')
A = firedrake.assemble(a, bcs=bc).M.handle
M = firedrake.assemble(m).M.handle

To solve the right problem and to help SLEPc get the right answer we'll pass it several options. First, we're solving a generalized Hermitian eigenproblem. Since the eigenproblem is Hermitian, all the eignevalues are real, which is a very convenient simplifying assumption.

For this problem we're going to use a spectral transformation. Rather than find the eigenvalues of a matrix $A$ directly, we'll instead find the eigenvalues of a matrix $f(A)$ where $f$ is invertible and holomorphic on a domain containing the spectrum of $A$. We can then compute the eigenvalues of $A$ as the function $f^{-1}$ aplied to the eigenvalues of $f(A)$. The advantage of spectral transformations is that, with a good choice of $f$, the eigenvalues of $f(A)$ can be easier to compute than those of $A$ itself. Since $A$ is positive-definite and we're looking for the smallest eigenvalues, a good choice is

$$f(z) = 1/(z - \sigma),$$

i.e. shifting and inverting. This spectral transformation is equivalent to finding the eigendecomposition of $(A - \sigma M)^{-1}$. Computing the inverse of a matrix is generally a bad idea, but under the hood it's enough to be able to solve linear systems.

Anything in SLEPc having to do with spectral transformations is prefixed with st. In our case, we're using the shift-and-invert transformation (sinvert). To solve these linear systems, we'll a Krylov subspace method (ksp_type) with some preconditioner (pc_type). Since $A$ is symmetric and positive-definite, we can use the conjugate gradient method (cg).

from petsc4py import PETSc
opts = PETSc.Options()
opts.setValue('eps_gen_hermitian', None)
opts.setValue('eps_target_real', None)
opts.setValue('eps_smallest_real', None)
opts.setValue('st_type', 'sinvert')
opts.setValue('st_ksp_type', 'cg')
opts.setValue('st_pc-type', 'jacobi')
opts.setValue('eps_tol', 1e-8)
from slepc4py import SLEPc
num_values = 250
eigensolver = SLEPc.EPS().create(comm=firedrake.COMM_WORLD)
eigensolver.setDimensions(num_values)
eigensolver.setOperators(A, M)
eigensolver.setFromOptions()
eigensolver.solve()

To check that everything worked right, we can see how many eigenvalues converged:

num_converged = eigensolver.getConverged()
print(num_converged)
288

Just for fun, we can plot one of the eigenfunctions. The zero contours of eigenfunctions are a fascinating subject -- the Courant nodal domain theorem tells us that the $n$-th eigenfunction can have no more than $n$ nodal domains.

Vr, Vi = A.getVecs()
λ = eigensolver.getEigenpair(24, Vr, Vi)
ϕ = firedrake.Function(Q)
ϕ.vector()[:] = Vr
fig, axes = plt.subplots()
levels = np.linspace(-1.25, +1.25, 51)
kwargs = {'levels': levels, 'cmap': 'twilight', 'extend': 'both'}
contours = firedrake.tricontourf(ϕ, axes=axes, **kwargs)
fig.colorbar(contours)
axes.set_aspect('equal');

The following plot shows exact eigenvalue counting function and the order-1 and order-2 approximations from Weyl's law.

Es = np.array([eigensolver.getEigenvalue(k) for k in range(num_values)]).real
λs = np.sqrt(Es)
import matplotlib.pyplot as plt
fig, axes = plt.subplots()
Ns = np.array(list(range(len(λs)))) + 1
axes.plot(λs, Ns, color='k', label='Exact $N(\lambda)$')

from firedrake import assemble, Constant, ds
vol = assemble(Constant(1, domain=mesh) * dx)
area = assemble(Constant(1, domain=mesh) * ds)

ω_2 = π
ω_1 = 2
order_1 = 1/(2*π)**2 * ω_2 * vol * λs**2
order_2 = order_1 - 1/(2*π) * ω_1 * area * λs / 4

axes.plot(λs, order_1, color='tab:blue', label='order 1')
axes.plot(λs, order_2, color='tab:orange', label='order 2')
axes.legend()

axes.set_xlabel('Eigenvalue $\lambda$')
axes.set_ylabel('Counting function $N(\lambda)$');

The accuracy difference is even more stark if we look at the relative error in the eigenvalue counting function.

fig, axes = plt.subplots()
error_1 = 1 - Ns / order_1
error_2 = 1 - Ns / order_2
axes.plot(λs[50:], error_1[50:], color='tab:blue', label='order 1')
axes.plot(λs[50:], error_2[50:], color='tab:orange', label='order 2')
axes.legend();

The order-1 approximation is pretty good, but the order-2 approximation is startlingly accurate. Of course we've only looked at the first few hundred eigenvalues on a mesh with several thousand vertices. Once the corresponding wavelengths get close to the diameter of a triangle of our mesh, I'd expect the approximation to break down. The mesh is too coarse at that point to resolve the highly oscillatory eigenfunctions.

Conclusions

The Weyl asymptotic law has some interesting physical implications. The first-order version of the law tells us that you can hear the area of a drumhead by fitting the sequence of harmonic frequencies to the right power. The second-order version of the law tells us that you can, in the same way, hear the perimeter of the drumhead by fitting the remainder of the first-order approximation.

Victor Ivrii gave a proof in 1980 of the Weyl law up to second order, under some special conditions that are thought to hold for a wide class of domains. While proving the law up to first order is relatively elementary, Ivrii's proof used microlocal analysis, which is well and truly above my pay grade.