Solving the Heat Equation with Bounds ConstraintsΒΆ
In this demo we solve the simple heat equation with bounds constraints applied uniformly in time and space. Consider the simple heat equation on \(\Omega = [0,1]\times [0,1]\), with boundary \(\Gamma\):
for some known functions \(f\) and \(g\). The solution will be some function \(u\in V\), for a suitable function space \(V\).
The weak form is found by multiplying by an arbitrary test function \(v\in V\) and integrating over \(\Omega\). We then have the variational problem of finding \(u:[0,T]\rightarrow V\) such that .. math:
(u_t, v) + (\nabla u, \nabla v) = (f, v)\quad \forall v \in V \textrm{ and } t\in [0, T],
subject to the boundary condition \(u = g\) on \(\Gamma\). This demo uses particular choices of the functions \(f\) and \(g\) to be defined below.
The approach to bounds constraints below relies on the geometric properties of the Bernstein basis. In one dimension (on \([0,1]\)), the graph of the polynomial .. math:
p(x) = \sum_{i = 0}^n p_i b_i^n(x),
where \(b_i^n(x)\) are Bernstein basis polynomials, lies in the convex-hull of the points .. math:
\left\{\left(\frac{i}{n}, p_i\right)\right\}_{i = 0}^n.
In particular, if the coefficients \(p_i\) lie in the interval \([m,M]\), then the output of \(p(x)\) will also fall within this range. Similar results hold in higher dimensions. This property provides a straightforward approach to uniformly enforced bounds constraints in both space and time.
First, we must import firedrake and certain items from Irksome:
from firedrake import *
from irksome import (Dt, MeshConstant, RadauIIA, TimeStepper)
We also need some UFL tools in order to manufacture a solution:
from ufl.algorithms import expand_derivatives
Finally, numpy provides us with the upper bound of infinity:
import numpy as np
We first define the mesh and the necessary function space. We choose quadratic Bernstein polynomials to support bounds-constraints in space:
N = 32
msh = UnitSquareMesh(N, N)
V = FunctionSpace(msh, "Bernstein", 2)
In order to enforce bounds constraints in time, we must utilize a collocation method.
In this demo, we will time-step using the L-stable, fully implicit, 2-stage RadauIIA
Runge-Kutta method. The bounds will be passed as an argument to the
advance
method. We now define the Butcher Tableau and variables to store the
time step, current time, and final time:
butcher_tableau = RadauIIA(2)
MC = MeshConstant(msh)
dt = MC.Constant(2 / N)
t = MC.Constant(0.0)
Tf = MC.Constant(1.0)
We will find an approximate solution at time \(t=1.0\) with and without enforcing a constraint on the lower bound. We will need the following pair of solver parameters:
lu_params = {
"snes_type": "ksponly",
"ksp_type": "preonly",
"mat_type": "aij",
"pc_type": "lu"
}
vi_params = {
"snes_type": "vinewtonrsls",
"snes_max_it": 300,
"snes_atol": 1.e-8,
"ksp_type": "preonly",
"mat_type": "aij",
"pc_type": "lu",
}
We now define the right-hand side using the method of manufactured solutions:
x, y = SpatialCoordinate(msh)
uexact = 0.5 * exp(-t) * (1 + (tanh((0.1 - sqrt((x - 0.5) ** 2 + (y - 0.5) ** 2)) / 0.015)))
rhs = expand_derivatives(diff(uexact, t)) - div(grad(uexact))
Note that the exact solution is uniformly positive in space and time. Using a manufactured solution, one usually interpolates or projects the exact solution at time \(t = 0\) onto the approximation space to obtain the initial condition. Interpolation does not work with the Bernstein basis, and there is no guarantee that an interpolant or projection would satisfy the bounds constraints. To guarantee that the initial condition satisfies the bounds constraints, we solve a variational inequality:
v = TestFunction(V)
u_init = Function(V)
G = inner(u_init - uexact, v) * dx
nlvp = NonlinearVariationalProblem(G, u_init)
nlvs = NonlinearVariationalSolver(nlvp, solver_parameters=vi_params)
lb = Function(V)
ub = Function(V)
ub.assign(np.inf)
lb.assign(0.0)
nlvs.solve(bounds=(lb, ub))
u = Function(V)
u.assign(u_init)
u_c = Function(V)
u_c.assign(u_init)
u
and u_c
now hold a bounds-constrained approximation to the exact solution
at \(t = 0\). Note that ub = None is also supported and gets internally converted
to what we have here.
We now construct semidiscrete variational problems for both the constrained and unconstrained
approximations using UFL notation and the Dt
operator from Irksome:
v = TestFunction(V)
F = (inner(Dt(u), v) * dx + inner(grad(u), grad(v)) * dx - inner(rhs, v) * dx)
v_c = TestFunction(V)
F_c = (inner(Dt(u_c), v_c) * dx + inner(grad(u_c), grad(v_c)) * dx - inner(rhs, v_c) * dx)
We use exact boundary conditions in both cases. When \(g\) is the trace of a function
defined over the whole domain, Firedrake creates its own version of the boundary condition by either interpolating
or projecting that function onto the finite element space and computing the trace of the result.
To ensure the internal boundary condition satisfies the bounds constraints, we will pass the bounds to
the TimeStepper
below.
bc = DirichletBC(V, uexact, "on_boundary")
For the unconstrained approximation, we configure the TimeStepper
in a
familiar way:
stepper = TimeStepper(F, butcher_tableau, t, dt, u, bcs=bc, solver_parameters=lu_params)
We will enforce nonnegativity when finding the constrained approximation. We now set up the keyword database to
configure an instance of TimeStepper
for this task. We first specify, using the
keyword stage_type
, that we wish to use a stage-value formulation of the underlying collocation
method. The keyword basis_type
then allows us to change the basis of the collocation
polynomial to the Bernstein basis. Having done this, we must specify a solver which is able to handle bounds
constraints. In this example we solve a variational inequality using vinewtonrsls
by passing vi_params
as solver_parameters
to the TimeStepper
.
We also ensure that projecting the boundary condition data satisfies bounds constraints through the
bc_constraints keyword:
kwargs_c = {"stage_type": "value",
"basis_type": 'Bernstein',
"bc_constraints": {bc: (vi_params, lb, ub)},
"solver_parameters": vi_params
}
stepper_c = TimeStepper(F_c, butcher_tableau, t, dt, u_c, bcs=bc, **kwargs_c)
Note that if one does not set the basis_type
to Bernstein, the standard basis will be used. Solving for the
Bernstein coefficients of the collocation polynomial we obtain uniform-in-time bounds constraints. If the standard
basis is used, the bounds constraints are guaranteed at the Runge-Kutta stages and the discrete times, but not necessarily
between them.
We set the bounds as follows (reusing those defined in the initial condition):
bounds = ('stage', lb, ub)
When using a stage-value formulation, passing bounds
to the TimeStepper
through the advance
method
will enforce the bounds constraints at the discrete stages and time levels (this results in uniformly enforced constraints when using
the Bernstein basis).
We now advance both semidiscrete systems in the usual way. We add the bounds as an argument
to the advance
method for the constrained approximation.
In order to monitor our approximate solutions, we check the minimum value of each after every step in time. If an approximate solution violates the lower bound, we append a tuple to indicate the time and minimum value.
violations_for_unconstrained_method = []
violations_for_constrained_method = []
timestep = 0
while (float(t) < float(Tf)):
if (float(t) + float(dt) > float(Tf)):
dt.assign(float(Tf) - float(t))
stepper.advance()
stepper_c.advance(bounds=bounds)
t.assign(float(t) + float(dt))
timestep = timestep + 1
min_value = min(u.dat.data)
if min_value < 0:
violations_for_unconstrained_method.append((float(t), timestep, round(min_value, 3)))
min_value_c = min(u_c.dat.data)
if min_value_c < 0:
violations_for_constrained_method.append((float(t), timestep, round(min_value_c, 3)))
print(float(t))
Finally, we print the relative \(L^2\) error and the time and severity (if any) of constraint violations:
np.set_printoptions(legacy='1.25')
print()
print(f"Relative L^2 norm of the unconstrained solution: {norm(u - uexact) / norm(uexact)}")
print(f"Relative L^2 norm of the constrained solution: {norm(u_c - uexact) / norm(uexact)}")
print()
print("List of constraint violations in the form (time, time step, minimum value) for each approximation:")
print()
print(f"Unconstrained solution: {violations_for_unconstrained_method}")
print()
print(f"Constrained solution: {violations_for_constrained_method}")