Deep dive: evaluate state using sparse matrices¶
In this notebook we solve the model via the finite element method using FEniCSx.
import warnings
import numpy as np
import plotly.graph_objects as go
import plotly.io as pio
import ufl
from basix.ufl import element
from dolfinx import default_scalar_type, fem, mesh
from mpi4py import MPI
from plotly.subplots import make_subplots
from scipy.sparse import SparseEfficiencyWarning, bmat
from scipy.sparse.linalg import spsolve
warnings.simplefilter("ignore", SparseEfficiencyWarning)
The following line allows the plot at the end
pio.renderers.default = "notebook_connected"
Introduction¶
As in the previous notebook, we fix some of the physical parameters arbitrarily.
T = 2.0 # final time
x_0 = 0.0 # left point of the 1D interval
x_L = 1.0 # right point of the 1D interval
def kappa_1(x):
"""First diffusion coefficient."""
return np.ones_like(x[0])
def kappa_2(x):
"""Second diffusion coefficient."""
return np.ones_like(x[0])
def y0(x):
"""Initial value."""
return 5 * np.ones_like(x[0])
mu = (3, 3, 3, 3) # control vector
def u_func(t):
"""Input function."""
return -3 * (t <= 4 / 3) + 3 * (t > 4 / 3) * (t <= 2.0)
def f(y, q):
"""Nonlinear coupling function."""
return np.sqrt(y) * np.sinh(q)
Let us write again a weak formulation of the model:
Discretization¶
First, we discretize the space \(\Omega\) on 201 nodes between x_0 and x_L:
N_nodes_x = 201
Omega = mesh.create_interval(comm=MPI.COMM_WORLD, nx=N_nodes_x - 1, points=[x_0, x_L])
Given approximated spaces \(V^h = \mathrm{span} \, \{ \varphi_1, \dots, \varphi_{N_\mathsf{y}} \} \subset V\) and \(V_\circ^h = \mathrm{span} \, \{ \psi_1, \dots, \psi_{N_\mathsf{y}} \} \subset V_\circ\), the finite element (FE) method is based on the Galerkin projection, i.e. finding approximated solutions \(y^h\) of \(y\) and \(q^h\) of \(q\) of the form
solving the system (1) on the discretized spaces \(V^h\) and \(V_\circ^h\). This is:
We use in this notebook linear Lagrangian elements (P1).
FE_degree = 1
N_dofs = (N_nodes_x - 1) * FE_degree + 1
P1 = element("Lagrange", Omega.basix_cell(), FE_degree)
V_h = fem.functionspace(Omega, P1)
Then, we define the boundary conditions (that only apply to the state \(q\)).
left_boundary_dofs = fem.locate_dofs_geometrical(V_h, lambda x: np.isclose(x[0], x_0))
bc_left = fem.dirichletbc(default_scalar_type(x_0), left_boundary_dofs, V_h)
bcs = [bc_left]
In this formulation, the space V_h will be the same for \(y\) and \(q\). What differentiate them is only the boundary conditions, which we will apply for all the arrays appearing in the equation for \(q\).
We prepare the finite element (bilinear and linear) forms:
phi = ufl.TestFunction(V_h)
v = ufl.TrialFunction(V_h)
k_1 = fem.Function(V_h)
k_1.interpolate(kappa_1)
k_2 = fem.Function(V_h)
k_2.interpolate(kappa_2)
# stiffness matrices
a_1 = fem.form(k_1 * v.dx(0) * phi.dx(0) * ufl.dx)
a_2 = fem.form(k_2 * v.dx(0) * phi.dx(0) * ufl.dx)
# mass matrix
m = fem.form(v * phi * ufl.dx)
# initial value
y_circ_f = fem.Function(V_h)
y_circ_f.interpolate(y0)
y_circ_h_form = fem.form(y_circ_f * phi * ufl.dx)
# Neumann boundary
B = fem.form(phi * ufl.ds)
b_h = fem.assemble_vector(B).array
bc_left.set(b_h) # we need to apply the Dirichlet BC to the RHS of q
We can define the coordinate vectors
At this point, we can approximate (2) in matrix form as:
where:
and the function \(f\) is evaluated component by component:
We assemble these FE matrices (only once!)
M_y = fem.assemble_matrix(m).to_scipy()
M_q = fem.assemble_matrix(m, bcs=bcs).to_scipy()
A_y = fem.assemble_matrix(a_1).to_scipy()
A_q = fem.assemble_matrix(a_2, bcs=bcs).to_scipy()
y_circ_h = fem.assemble_vector(y_circ_h_form).array
y_0_h = spsolve(M_y, y_circ_h)
To solve (3) we will next discretize in time and then solve the nonlinear problem using Newton’s method. We operate on a uniform time grid \(t_k = k \delta\) for \(k=0, \dots, K\), and \(\delta = T/K\), and we interpret \(\mathbf{y}^{h,k}\) the solution at time \(t_k\) of the fully approximated finite-dimensional system.
K = 200
delta = T / K
t_array = np.linspace(0, T, K + 1)
u = u_func(t_array)
In particular, implicit Euler (IE) discretization in time gives:
Let us define the coupled variable
and the functional \(\mathrm{F}^k : \mathbb{R}^{N_\mathsf{y} + N_\mathsf{q}} \to \mathbb{R}^{N_\mathsf{y} + N_\mathsf{q}}\), defined as
with
We use Newton’s method for solving \(\mathrm{F}^k (\mathbf{z}^{h,k}) = 0\) at each time step \(k\). It works as follow:
Start from \(n=0\) and some initial value \(\mathbf{z}_n\).
Evaluate \(\mathbf{d}\) solution of \(\mathrm{J}_{\mathrm{F}^k} (\mathbf{z}_n) \mathbf{d} = - \mathrm{F}^k(\mathbf{z_n})\).
Generate new point \(\mathbf{z}_{n+1} = \mathbf{z}_n + \mathbf{d}\) and \(n=n+1\).
If \(\mathrm{F}^k(\mathbf{z}_{n})\) is smaller than some tolerance \(\tau\), then we set \(\mathbf{z}^{h,k} := \mathbf{z}_{n}\).
Additionally, at iteration \(k\) we set the previous iterate \(\mathbf{z}^{h,k-1}\) as the initial point \(\mathbf{z}_0\) and we use the variation “damped Newton method” to guarantee monotonicity in the residual decrease.
First, let us define some useful variables and functions:
mat = M_y + mu[0] * delta * A_y
i_bc = bc_left.dof_indices()[0] + N_dofs
Theta = []
for n in range(201):
v2 = fem.Function(V_h)
v2.x.array[n] = 1
v2.x.scatter_forward()
Tyy = fem.assemble_matrix(fem.form(v * v2 * phi * ufl.dx)).to_scipy().copy()
Tyy.eliminate_zeros()
Theta.append(Tyy)
lower_bound = np.concatenate((1e-18 * np.ones_like(y_0_h), -np.inf * np.ones_like(b_h)))
def Newton_F(z, rhs):
"""Function F to minimize."""
y, q = np.split(z, 2)
F_y = mat.dot(y) - mu[1] * delta * M_y.dot(f(y, q))
F_q = mu[2] * A_q.dot(q) + mu[3] * M_q.dot(f(y, q))
F = np.concatenate((F_y, F_q)) - rhs
return F
def Newton_J_F(z, *argv, **kwargs):
"""Jacobian of function F."""
y, q = np.split(z, 2)
dyf = 0.5 * np.sinh(q) / np.sqrt(y)
dqf = np.sqrt(y) * np.cosh(q)
dy = np.tensordot(dyf, Theta, 1).item()
dq = np.tensordot(dqf, Theta, 1).item()
J_F = bmat(
[
[mat - mu[1] * delta * dy, -mu[1] * delta * dq],
[mu[3] * dy, mu[2] * A_q + mu[3] * dq],
],
format="csr",
)
J_F[i_bc, :] = 0
J_F[:, i_bc] = 0
J_F[i_bc, i_bc] = 1
J_F.eliminate_zeros()
return J_F
Next, we define the function to solve \(F^k=0\) using Newton’s method.
def solve_Newton(z_0, rhs_F, tol=1e-8):
"""Solve Newton's method for the root finding."""
z = z_0.copy()
F = Newton_F(z, rhs_F)
J_F = Newton_J_F(z)
err = F.dot(F)
it = 0
tau = 0.5
while err > tol:
it += 1
p = spsolve(J_F, -F)
derr = J_F.T.dot(F).dot(p)
it_damped = 0
alpha = 1
for it_damped in range(20):
z_new = z + alpha * p
if np.all(z_new >= lower_bound):
F_new = Newton_F(z_new, rhs_F)
err_new = F_new.dot(F_new)
if err_new - err <= +alpha * 1e-4 * derr:
err = err_new
F = F_new
z = z_new
# print(f"{it=}, {it_damped=}, {err=}")
break
alpha *= tau
it_damped += 1
else:
# print(f"{it=}, {it_damped=}, {err=}, {err_new=}")
raise RuntimeError
return z
The overall algorithm to find \(\left \{ \mathbf{y}^{h,k}, \mathbf{q}^{h,k} \right \}_{k=1, \dots, K}\) is:
Evaluate \(\mathbf{y}^{h,0}\) by projecting the intial value \(y_\circ\) onto the solution space.
Evaluate a consistent initial condition for \(\mathbf{q}^{h,0}\) (this step is optional).
Use Newton’s method to evaluate \(\mathbf{z}^{h,k}\) for \(k=1,\dots, K\).
We collect data into the matrices Y^h and Q^h:
These matrices will contain the columns of the coordinate arrays (in the FE basis).
%%time
Y_h = np.zeros((N_dofs, K))
Q_h = np.zeros((N_dofs, K))
y = y_0_h.copy()
q = spsolve(A_q, u[1] * b_h)
z = np.concatenate((y, q))
for k in range(1, K):
rhs_F = np.concatenate((M_y.dot(y), u[k] * b_h))
z_new = solve_Newton(z, rhs_F)
y, q = np.split(z_new, 2)
Y_h[:, k - 1] = y
Q_h[:, k - 1] = q
z = z_new
CPU times: user 5.61 s, sys: 5.2 ms, total: 5.62 s
Wall time: 5.66 s
Note
Note that our Newton algorithm is significantly faster than SciPy’s root optimizer (scipy.optimizer.root) while reaching almost identical accuracy. While the cell above takes 5.5 seconds, the following code executes in about 23 seconds.
Y_h = np.zeros((N_dofs, K))
Q_h = np.zeros((N_dofs, K))
y = y_0_h.copy()
q = spsolve(A_q, u[1] * b_h)
z = np.concatenate((y, q))
for k in range(1, K):
rhs_F = np.concatenate((M_y.dot(y), u[k] * b_h))
z_new = optimize.root(Newton_F, z, args=(rhs_F), jac=lambda z, _: Newton_J_F(z).toarray()).x
y, q = np.split(z_new, 2)
Y_h[:,k-1] = y
Q_h[:,k-1] = q
z = z_new
def residual(Y, Q):
"""Evaluate the residual of the solution."""
res = 0
w = delta * np.ones(K)
w[0] = delta / 2
w[-1] = delta / 2
for k in range(1, K):
y = Y[:, k - 1]
q = Q[:, k - 1]
z = np.concatenate((y, q))
rhs_F = np.concatenate((M_y.dot(y), u[k] * b_h))
res_k = Newton_F(z, rhs_F)
res_k_y, res_k_q = np.split(res_k, 2)
res += w[k - 1] * (
res_k_y.dot(M_y.dot(res_k_y)) + res_k_q.dot(M_q.dot(res_k_q))
)
return res
residual(Y_h, Q_h)
np.float64(1.2955134623224107e-08)
fig = go.Figure()
# Array of x values
x = Omega.geometry.x[:, 0]
# Create subplots
fig = make_subplots(
rows=1,
cols=2,
column_widths=[0.5, 0.5],
)
# Add all $y^k$ and $q^k$ functions to trace
for k, t in enumerate(t_array[1:]):
fig.add_traces(
[
go.Scatter(
x=x,
y=Y_h[:, k],
visible=False,
name=f"y(t={t:0.3f})",
),
go.Scatter(
x=x,
y=Q_h[:, k],
visible=False,
name=f"q(t={t:0.3f})",
),
],
rows=[1, 1],
cols=[1, 2],
)
# Make t=0 visible
fig.data[0].visible = True
fig.data[1].visible = True
# Create and add slider
steps = []
for i in range(K - 1):
step = dict(
method="update",
args=[
{"visible": [False] * len(fig.data)},
{"title": "Slider switched to step: " + str(i)},
], # layout attribute
)
step["args"][0]["visible"][2 * i] = True # Toggle i'th trace to "visible"
step["args"][0]["visible"][2 * i + 1] = True # Toggle i'th trace to "visible"
steps.append(step)
sliders = [
dict(active=0, currentvalue={"prefix": "Time: "}, pad={"t": 50}, steps=steps)
]
# Add slider to figure
fig.update_layout(
sliders=sliders,
yaxis={"range": [0, 7]},
legend={"x": 0.5, "y": 1.3, "xanchor": "center", "orientation": "h"},
)
# Show final interactive plot
fig.show()