# Cahn-Hilliard equation

This example demonstrates the solution of the Cahn-Hilliard equation, a nonlinear, time-dependent fourth-order PDE.

A mixed finite element method

The \(\theta\)-method for time-dependent equations

Automatic linearisation

Use of the class

`NonlinearProblem`

The built-in Newton solver (

`NewtonSolver`

)Form compiler options

Interpolation of functions

Visualisation of a running simulation with PyVista

This demo is implemented in `demo_cahn-hilliard.py`

.

## Equation and problem definition

The Cahn-Hilliard equation is a parabolic equation and is typically used to model phase separation in binary mixtures. It involves first-order time derivatives, and second- and fourth-order spatial derivatives. The equation reads:

where \(c\) is the unknown field, the function \(f\) is usually non-convex in \(c\) (a fourth-order polynomial is commonly used), \(n\) is the outward directed boundary normal, and \(M\) is a scalar parameter.

### Operator split form

The Cahn-Hilliard equation is a fourth-order equation, so casting it in a weak form would result in the presence of second-order spatial derivatives, and the problem could not be solved using a standard Lagrange finite element basis. A solution is to rephrase the problem as two coupled second-order equations:

The unknown fields are now \(c\) and \(\mu\). The weak (variational) form of the problem reads: find \((c, \mu) \in V \times V\) such that

### Time discretisation

Before being able to solve this problem, the time derivative must be dealt with. Apply the \(\theta\)-method to the mixed weak form of the equation:

where \(dt = t_{n+1} - t_{n}\) and \(\mu_{n+\theta} = (1-\theta) \mu_{n} + \theta \mu_{n+1}\). The task is: given \(c_{n}\) and \(\mu_{n}\), solve the above equation to find \(c_{n+1}\) and \(\mu_{n+1}\).

### Demo parameters

The following domains, functions and time stepping parameters are used in this demo:

\(\Omega = (0, 1) \times (0, 1)\) (unit square)

\(f = 100 c^{2} (1-c)^{2}\)

\(\lambda = 1 \times 10^{-2}\)

\(M = 1\)

\(dt = 5 \times 10^{-6}\)

\(\theta = 0.5\)

## Implementation

This demo is implemented in the `demo_cahn-hilliard.py`

file.

```
import os
from mpi4py import MPI
from petsc4py import PETSc
import numpy as np
import ufl
from basix.ufl import element, mixed_element
from dolfinx import default_real_type, log, plot
from dolfinx.fem import Function, functionspace
from dolfinx.fem.petsc import NonlinearProblem
from dolfinx.io import XDMFFile
from dolfinx.mesh import CellType, create_unit_square
from dolfinx.nls.petsc import NewtonSolver
from ufl import dx, grad, inner
try:
import pyvista as pv
import pyvistaqt as pvqt
have_pyvista = True
if pv.OFF_SCREEN:
pv.start_xvfb(wait=0.5)
except ModuleNotFoundError:
print("pyvista and pyvistaqt are required to visualise the solution")
have_pyvista = False
# Save all logging to file
log.set_output_file("log.txt")
```

Next, various model parameters are defined:

```
lmbda = 1.0e-02 # surface parameter
dt = 5.0e-06 # time step
theta = 0.5 # time stepping family, e.g. theta=1 -> backward Euler, theta=0.5 -> Crank-Nicholson
```

A unit square mesh with 96 cells edges in each direction is created,
and on this mesh a
`FunctionSpaceBase`

`ME`

is built
using a pair of linear Lagrange elements.

```
msh = create_unit_square(MPI.COMM_WORLD, 96, 96, CellType.triangle)
P1 = element("Lagrange", msh.basix_cell(), 1)
ME = functionspace(msh, mixed_element([P1, P1]))
```

Trial and test functions of the space `ME`

are now defined:

```
q, v = ufl.TestFunctions(ME)
```

For the test functions, `TestFunctions`

(note the ‘s’ at the end) is used to
define the scalar test functions `q`

and `v`

. Some mixed objects of
the `Function`

class on `ME`

are defined to represent \(u = (c_{n+1}, \mu_{n+1})\) and \(u0 = (c_{n},
\mu_{n})\), and these are then split into sub-functions:

```
u = Function(ME) # current solution
u0 = Function(ME) # solution from previous converged step
# Split mixed functions
c, mu = ufl.split(u)
c0, mu0 = ufl.split(u0)
```

The line `c, mu = ufl.split(u)`

permits direct access to the
components of a mixed function. Note that `c`

and `mu`

are references
for components of `u`

, and not copies.

The initial conditions are interpolated into a finite element space:

```
# Zero u
u.x.array[:] = 0.0
# Interpolate initial condition
u.sub(0).interpolate(lambda x: 0.63 + 0.02 * (0.5 - np.random.rand(x.shape[1])))
u.x.scatter_forward()
```

The first line creates an object of type `InitialConditions`

. The
following two lines make `u`

and `u0`

interpolants of `u_init`

(since
`u`

and `u0`

are finite element functions, they may not be able to
represent a given function exactly, but the function can be
approximated by interpolating it in a finite element space).

The chemical potential \(df/dc\) is computed using UFL automatic differentiation:

```
# Compute the chemical potential df/dc
c = ufl.variable(c)
f = 100 * c**2 * (1 - c)**2
dfdc = ufl.diff(f, c)
```

The first line declares that `c`

is a variable that some function can
be differentiated with respect to. The next line is the function \(f\)
defined in the problem statement, and the third line performs the
differentiation of `f`

with respect to the variable `c`

.

It is convenient to introduce an expression for \(\mu_{n+\theta}\):

```
# mu_(n+theta)
mu_mid = (1.0 - theta) * mu0 + theta * mu
```

which is then used in the definition of the variational forms:

```
# Weak statement of the equations
F0 = inner(c, q) * dx - inner(c0, q) * dx + dt * inner(grad(mu_mid), grad(q)) * dx
F1 = inner(mu, v) * dx - inner(dfdc, v) * dx - lmbda * inner(grad(c), grad(v)) * dx
F = F0 + F1
```

This is a statement of the time-discrete equations presented as part of the problem statement, using UFL syntax.

The DOLFINx Newton solver requires a
`NonlinearProblem`

object to
solve a system of nonlinear equations

```
# Create nonlinear problem and Newton solver
problem = NonlinearProblem(F, u)
solver = NewtonSolver(MPI.COMM_WORLD, problem)
solver.convergence_criterion = "incremental"
solver.rtol = np.sqrt(np.finfo(default_real_type).eps) * 1e-2
# We can customize the linear solver used inside the NewtonSolver by
# modifying the PETSc options
ksp = solver.krylov_solver
opts = PETSc.Options() # type: ignore
option_prefix = ksp.getOptionsPrefix()
opts[f"{option_prefix}ksp_type"] = "preonly"
opts[f"{option_prefix}pc_type"] = "lu"
ksp.setFromOptions()
```

The setting of `convergence_criterion`

to `"incremental"`

specifies
that the Newton solver should compute a norm of the solution increment
to check for convergence (the other possibility is to use
`"residual"`

, or to provide a user-defined check). The tolerance for
convergence is specified by `rtol`

.

To run the solver and save the output to a VTK file for later visualization, the solver is advanced in time from \(t_{n}\) to \(t_{n+1}\) until a terminal time \(T\) is reached:

```
# Output file
file = XDMFFile(MPI.COMM_WORLD, "demo_ch/output.xdmf", "w")
file.write_mesh(msh)
# Step in time
t = 0.0
# Reduce run time if on test (CI) server
if "CI" in os.environ.keys() or "GITHUB_ACTIONS" in os.environ.keys():
T = 3 * dt
else:
T = 50 * dt
# Get the sub-space for c and the corresponding dofs in the mixed space
# vector
V0, dofs = ME.sub(0).collapse()
# Prepare viewer for plotting the solution during the computation
if have_pyvista:
# Create a VTK 'mesh' with 'nodes' at the function dofs
topology, cell_types, x = plot.vtk_mesh(V0)
grid = pv.UnstructuredGrid(topology, cell_types, x)
# Set output data
grid.point_data["c"] = u.x.array[dofs].real
grid.set_active_scalars("c")
p = pvqt.BackgroundPlotter(title="concentration", auto_update=True)
p.add_mesh(grid, clim=[0, 1])
p.view_xy(True)
p.add_text(f"time: {t}", font_size=12, name="timelabel")
c = u.sub(0)
u0.x.array[:] = u.x.array
while (t < T):
t += dt
r = solver.solve(u)
print(f"Step {int(t/dt)}: num iterations: {r[0]}")
u0.x.array[:] = u.x.array
file.write_function(c, t)
# Update the plot window
if have_pyvista:
p.add_text(f"time: {t:.2e}", font_size=12, name="timelabel")
grid.point_data["c"] = u.x.array[dofs].real
p.app.processEvents()
file.close()
# Update ghost entries and plot
if have_pyvista:
u.x.scatter_forward()
grid.point_data["c"] = u.x.array[dofs].real
screenshot = None
if pv.OFF_SCREEN:
screenshot = "c.png"
pv.plot(grid, show_edges=True, screenshot=screenshot)
```