14) FEM Assembly#
Last time:
Introduction to the Finite Element Method
Variational formulations
Galerkin Spectral Element Method (SEM): \(L^2\) projection
Today:
Linear Finite Elements
Galerkin method for 1D Poisson equation
Abstractions
1. Linear Finite Elements#
The domain \(\Omega = [0,1]\) is discretized with elements (subintervals \(\Omega_h\))
such that \(x_i = i h \), \(i=0,1,\ldots, n\), where
is the length of the subinterval \((x_{i-1}, x_i)\), \(i=1, 2, \ldots, n\).
Linear (Hat) Basis Functions#
Nodal Property#
where \(\delta_{ij}\) denotes the Kronecker delta. This just says that \(\phi_i(x)\) has zeros at all the \(x_j\)’s (with \(j \neq i\)) except \(x_i\), where it is identically equal to \(1\).
Compact support#
Derivatives#
Uniform Mesh Case#
When a mesh is partitioned in uniform (all identically equal) elements, we have that \(h_i = h\) and therefore, we can rewrite
using Plots
using LaTeXStrings
default(linewidth=4, legendfontsize=12)
# Piecewise linear hat function centered at node xc,
# with left neighbor xl and right neighbor xr
function hat(x, xl, xc, xr)
if x < xl || x > xr
0.0
elseif x <= xc
(x - xl) / (xc - xl)
else
(xr - x) / (xr - xc)
end
end
xim1 = -1.0
xi = 0.0
xip1 = 1.0
x = range(xim1 - 0.5, xip1 + 0.5, length=500)
phi_i = [hat(xx, xim1, xi, xip1) for xx in x]
plot(x, phi_i,
lw=3,
label=L"\phi_i(x)",
xticks=([xim1, xi, xip1], [L"x_{i-1}", L"x_i", L"x_{i+1}"]),
yticks=([0, 1], ["0", "1"]),
ylims=(-0.05, 1.1),
framestyle=:origin
)
scatter!([xim1, xi, xip1], [0, 1, 0], label="")
# Example node locations
xim2 = -2.0
xim1 = -1.0
xi = 0.0
xip1 = 1.0
xip2 = 2.0
# Plot grid
x = range(xim2, xip2, length=1000)
# Three hat functions
phi_im1 = [hat(xx, xim2, xim1, xi) for xx in x]
phi_i = [hat(xx, xim1, xi, xip1) for xx in x]
phi_ip1 = [hat(xx, xi, xip1, xip2) for xx in x]
# Base plot
plot(x, phi_im1,
lw=3,
label=L"\phi_{i-1}(x)",
xlabel=L"x",
ylabel="",
legend=:top,
ylims=(-0.05, 1.2),
xlims=(xim2 - 0.4, xip2 + 0.5),
xticks=([xim2, xim1, xi, xip1, xip2],
[L"x_{i-2}", L"x_{i-1}", L"x_i", L"x_{i+1}", L"x_{i+2}"]),
yticks=[],
framestyle=:origin
)
plot!(x, phi_i, lw=3, label=L"\phi_i(x)")
plot!(x, phi_ip1, lw=3, label=L"\phi_{i+1}(x)")
# Mark the nodes
scatter!([xim2, xim1, xi, xip1, xip2], zeros(5),
label="",
markersize=5
)
# Optional vertical guide lines at the peaks
plot!([xim1, xim1], [0, 1], lw=1, ls=:dash, color=:black, label="")
plot!([xi, xi], [0, 1], lw=1, ls=:dash, color=:black, label="")
plot!([xip1, xip1], [0, 1], lw=1, ls=:dash, color=:black, label="", legend =:topright)
2. Galerkin method for 1D Poisson equation#
For the 1D two-point BVP for the Poisson equation:
We have seen that we start by constructing a weak form or variational formulation of the problem
Step 1: Multiply both sides of the PDE by a test function \(v(x)\) with compact support (i.e., \( v(0)=0, \; v(1) = 0 \)).
Step 2: Integrate both sides of the equation across the domain \(\Omega = [0,1]\) and apply integrations by parts. On the left-hand-side we have:
We note that the boundary term vanishes because \(v\) has compact support.
Hence the weak form of the PDE is:
Step 3: Construct the set of linear (hat) basis function as above
Step 4: Expand all functions in terms of basis functions, such as
where the coefficients \(\{u_j\}\) are the unknown nodal values to be determined.
On assuming the hat basis functions, obviously \(u_h(x)\) is also a piecewise linear function, although this is not usually the case for the true solution \(u(x)\).
We then derive a linear system of equations for the coefficients by substituting the approximate solution \(u_h(x)\) for the exact solution \(u(x)\) in the weak form
Hence,
Hence, we get a system of equations
Or in matrix form:
Where we have used the bilinear form
and the inner product
The matrix problem above can be written more compactly as
where the matrix \(K\) is called the Stiffness Matrix.
Therefore, for this case we get the matrix
The right-hand side can be explicitly written as
where we have used the simple trapezoidal rule for the \(L^2\) inner product integral.
Hence, we can rewrite our matrix problem as
Step 5: Solve the linear system of equations for the coefficients \( \{u_j\}\) and hence obtain the approximate solution
\[ u_h(\mathbf x) = \sum_{j=1}^n u_j \, \phi_j(\mathbf x) \]
Observations#
Does the matrix in (1) look familiar??
We note that for solving \(-u'' = f(x)\), \(u(0) = u(1) = 0\), the matrix form of the \(P^1\) linear Finite Element Method with trapezoidal quadrature is precisely the second order centered Finite Difference Scheme.
3. Abstractions#
In general, In the FEM/SEM fomrulations, we always try to keep the order of derivatives of \(u\) and \(v\) as small as possible. We reach a general form, where we can express as \(f_0\) all terms in the weak form that multiply the test function \(v\) and \(f_1\) all terms that multiply its gradient \(\nabla v\):
General form:
Many FEM/SEM libraries (like libCEED) discretize this as
libCEED’s operator decomposition is summarized in the following graphic.
Isoparametric mapping#
Given the reference coordinates \(X \in \hat K \subset R^n\) and physical coordinates \(x(X)\), an integral on the physical element can be written
Notation |
Meaning |
|---|---|
\(x\) |
physical coordinates |
\(X\) |
reference coordinates |
\(\mathcal E_e\) |
restriction from global vector to element \(e\) |
\(B\) |
values of nodal basis functions at quadrature ponits on reference element |
\(D\) |
gradients of nodal basis functions at quadrature points on reference element |
\(W\) |
diagonal matrix of quadrature weights on reference element |
\(\frac{\partial x}{\partial X} = D \mathcal E_e x \) |
gradient of physical coordinates with respect to reference coordinates |
\(\left\lvert \frac{\partial x}{\partial X}\right\rvert\) |
determinant of coordinate transformation at each quadrature point |
\(\frac{\partial X}{\partial x} = \left(\frac{\partial x}{\partial X}\right)^{-1}\) |
derivative of reference coordinates with respect to physical coordinates |
Finite element mesh and restriction#
using LinearAlgebra
using SparseArrays
using FastGaussQuadrature
function my_spy(A)
cmax = norm(vec(A), Inf)
s = max(1, ceil(120 / size(A, 1)))
spy(A, marker=(:square, s), c=:diverging_rainbow_bgymr_45_85_c67_n256, clims=(-cmax, cmax))
end
function vander_legendre_deriv(x, k=nothing)
if isnothing(k)
k = length(x) # Square by default
end
m = length(x)
Q = ones(m, k)
dQ = zeros(m, k)
Q[:, 2] = x
dQ[:, 2] .= 1
for n in 1:k-2
Q[:, n+2] = ((2*n + 1) * x .* Q[:, n+1] - n * Q[:, n]) / (n + 1)
dQ[:, n+2] = (2*n + 1) * Q[:,n+1] + dQ[:,n]
end
Q, dQ
end
function febasis(P, Q)
x, _ = gausslobatto(P)
q, w = gausslegendre(Q)
Pk, _ = vander_legendre_deriv(x)
Bp, Dp = vander_legendre_deriv(q, P)
B = Bp / Pk
D = Dp / Pk
x, q, w, B, D
end
function L2_galerkin(P, Q, f)
x, q, w, B, _ = febasis(P, Q)
A = B' * diagm(w) * B
rhs = B' * diagm(w) * f.(q)
u = A \ rhs
x, u
end
L2_galerkin (generic function with 1 method)
function fe1_mesh(P, nelem)
x = LinRange(-1, 1, nelem+1)
rows = Int[]
cols = Int[]
for i in 1:nelem
append!(rows, (i-1)*P+1:i*P)
append!(cols, (i-1)*(P-1)+1:i*(P-1)+1)
end
x, sparse(cols, rows, ones(nelem*P))'
end
P, nelem = 4, 3
x, E = fe1_mesh(P, nelem)
E
12×10 Adjoint{Float64, SparseMatrixCSC{Float64, Int64}} with 12 stored entries:
1.0 ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅
⋅ 1.0 ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅
⋅ ⋅ 1.0 ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅
⋅ ⋅ ⋅ 1.0 ⋅ ⋅ ⋅ ⋅ ⋅ ⋅
⋅ ⋅ ⋅ 1.0 ⋅ ⋅ ⋅ ⋅ ⋅ ⋅
⋅ ⋅ ⋅ ⋅ 1.0 ⋅ ⋅ ⋅ ⋅ ⋅
⋅ ⋅ ⋅ ⋅ ⋅ 1.0 ⋅ ⋅ ⋅ ⋅
⋅ ⋅ ⋅ ⋅ ⋅ ⋅ 1.0 ⋅ ⋅ ⋅
⋅ ⋅ ⋅ ⋅ ⋅ ⋅ 1.0 ⋅ ⋅ ⋅
⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ 1.0 ⋅ ⋅
⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ 1.0 ⋅
⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ 1.0
function xnodal(x, P)
xn = Float64[]
xref, _ = gausslobatto(P)
for i in 1:length(x)-1
xL, xR = x[i:i+1]
append!(xn, (xL+xR)/2 .+ (xR-xL)/2 * xref[1+(i>1):end])
end
xn
end
xn = xnodal(x, 10)
@show xn
scatter(xn, zero, marker=:square)
xn = [-1.0, -0.9731779693888196, -0.912924621701835, -0.8259749832701482, -0.7217596525554624, -0.6115736807778711, -0.5073583500631853, -0.42040871163149846, -0.36015536394451386, -0.3333333333333335, -0.3065113027221531, -0.24625795503516845, -0.1593083166034816, -0.05509298588879577, 0.055092985888795604, 0.15930831660348144, 0.24625795503516829, 0.3065113027221529, 0.33333333333333326, 0.36015536394451364, 0.4204087116314983, 0.5073583500631851, 0.6115736807778709, 0.7217596525554624, 0.8259749832701482, 0.912924621701835, 0.9731779693888196, 1.0]
Finite Element Method (FEM) building blocks in Julia#
struct FESpace
P::Int
Q::Int
nelem::Int
x::Vector
xn::Vector
Et::SparseMatrixCSC{Float64, Int64}
q::Vector
w::Vector
B::Matrix
D::Matrix
function FESpace(P, Q, nelem)
x, E = fe1_mesh(P, nelem)
xn = xnodal(x, P)
_, q, w, B, D = febasis(P, Q)
new(P, Q, nelem, x, xn, E', q, w, B, D)
end
end
# Extract out what we need for element e
function fe_element(fe, e)
xL, xR = fe.x[e:e+1]
q = (xL+xR)/2 .+ (xR-xL)/2*fe.q
w = (xR - xL)/2 * fe.w
E = fe.Et[:, (e-1)*fe.P+1:e*fe.P]'
dXdx = ones(fe.Q) * 2 / (xR - xL)
q, w, E, dXdx
end
fe = FESpace(3, 3, 5)
q, w, E, dXdx = fe_element(fe, 1);
@show q
@show sum(w)
E
q = [-0.9549193338482966, -0.8, -0.6450806661517035]
sum(w) = 0.3999999999999999
3×11 Adjoint{Float64, SparseMatrixCSC{Float64, Int64}} with 3 stored entries:
1.0 ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅
⋅ 1.0 ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅
⋅ ⋅ 1.0 ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅ ⋅
Finite element residual assembly#
where \(\tilde u = B \mathcal E_e u\) and \(\nabla \tilde u = \frac{\partial X}{\partial x} D \mathcal E_e u\) are the values and gradients evaluated at quadrature points.
kappa(x) = 0.6 .+ 0.4*sin(pi*x/2)
# Define the action of the QFunction, fq, at quadrature points and its derivative, dfq
fq(q, u, Du) = 0*u .- 1, kappa.(q) .* Du # two components, the first is f_0, second is f_1
dfq(q, u, du, Du, Ddu) = 0*du, kappa.(q) .* Ddu # two components, the first is ∇f_0, second is ∇f_1
function fe_residual(u_in, fe, fq; bci=[1], bcv=[1.])
u = copy(u_in); v = zero(u)
u[bci] = bcv
for e in 1:fe.nelem
q, w, E, dXdx = fe_element(fe, e)
B, D = fe.B, fe.D
ue = E * u
uq = B * ue
Duq = dXdx .* (D * ue)
f0, f1 = fq(q, uq, Duq)
ve = B' * (w .* f0) + D' * (dXdx .* w .* f1)
v += E' * ve
end
v[bci] = u_in[bci] - u[bci]
#println("residual")
v
end
fe_residual (generic function with 1 method)
using Pkg
Pkg.add("NLsolve")
import NLsolve: nlsolve
fe = FESpace(3, 3, 20)
u0 = zero(fe.xn)
sol = nlsolve(u -> fe_residual(u, fe, fq), u0; method=:newton)
#plot(fe.xn, sol.zero, marker=:auto)
Resolving package versions...
No Changes to `~/.julia/environments/v1.10/Project.toml`
No Changes to `~/.julia/environments/v1.10/Manifest.toml`
┌ Warning: Circular dependency detected.
│ Precompilation will be skipped for dependencies in this cycle:
│ ┌ Symbolics → SymbolicsForwardDiffExt
│ └─ Symbolics → SymbolicsPreallocationToolsExt
└ @ Pkg.API.Precompilation ~/julia-1.10.10/share/julia/stdlib/v1.10/Pkg/src/precompilation.jl:583
Results of Nonlinear Solver Algorithm
* Algorithm: Newton with line-search
* Starting Point: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
* Zero: [1.0000000000039475, 1.4927406210509597, 1.9672049360599393, 2.4184464161953563, 2.842731803751349, 3.237606533113795, 3.601932148325619, 3.935620553694385, 4.239530313788561, 4.515069053115947, 4.764128759073442, 4.988727193305379, 5.1910305785624775, 5.373093946001602, 5.536940081173518, 5.684391333646472, 5.81716450203112, 5.936767982438539, 6.044588540803668, 6.141830397098076, 6.229584974973035, 6.308795718312478, 6.3803102227209045, 6.444860602250416, 6.503100675080583, 6.555595710653866, 6.602848027373359, 6.645292412165557, 6.683313141956646, 6.71724283409507, 6.747373276818846, 6.773956336883946, 6.797210378615518, 6.817322369848602, 6.834451177061841, 6.848730316022175, 6.860269040775656, 6.869155363658036, 6.875455590114675, 6.879217330676398, 6.880467948789745]
* Inf-norm of residuals: 0.000000
* Iterations: 1
* Convergence: true
* |x - x'| < 0.0e+00: false
* |f(x)| < 1.0e-08: true
* Function Calls (f): 2
* Jacobian Calls (df/dx): 2
Finite element Jacobian assembly#
function fe_jacobian(u_in, fe, dfq; bci=[1], bcv=[1.])
u = copy(u_in); u[bci] = bcv
rows, cols, vals = Int[], Int[], Float64[]
for e in 1:fe.nelem
q, w, E, dXdx = fe_element(fe, e)
B, D, P = fe.B, fe.D, fe.P
ue = E * u
uq = B * ue; Duq = dXdx .* (D * ue)
K = zeros(P, P)
for j in 1:fe.P
du = B[:,j]
Ddu = dXdx .* D[:,j]
df0, df1 = dfq(q, uq, du, Duq, Ddu)
K[:,j] = B' * (w .* df0) + D' * (dXdx .* w .* df1)
end
inds = rowvals(E')
append!(rows, kron(ones(P), inds))
append!(cols, kron(inds, ones(P)))
append!(vals, vec(K))
end
A = sparse(rows, cols, vals)
A[bci, :] .= 0; A[:, bci] .= 0
A[bci,bci] = diagm(ones(length(bci)))
A
end
fe_jacobian (generic function with 1 method)
sol = nlsolve(u -> fe_residual(u, fe, fq),
u -> fe_jacobian(u, fe, dfq),
u0;
method=:newton)
Results of Nonlinear Solver Algorithm
* Algorithm: Newton with line-search
* Starting Point: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
* Zero: [1.0, 1.4927406210022975, 1.9672049360133526, 2.418446416148008, 2.8427318037033396, 3.237606533065686, 3.601932148276197, 3.9356205536428224, 4.2395303137362665, 4.51506905306329, 4.764128759020454, 4.988727193252867, 5.191030578510388, 5.373093945949693, 5.5369400811206395, 5.684391333592148, 5.817164501975459, 5.936767982381641, 6.04458854074561, 6.141830397039554, 6.2295849749140775, 6.3087957182525365, 6.380310222660033, 6.444860602188671, 6.503100675018002, 6.555595710590095, 6.602848027309177, 6.645292412100275, 6.683313141890995, 6.717242834029186, 6.7473732767527315, 6.773956336817821, 6.797210378549382, 6.81732236978235, 6.834451176994862, 6.848730315954377, 6.860269040707049, 6.869155363588632, 6.875455590044476, 6.879217330605506, 6.880467948718754]
* Inf-norm of residuals: 0.000000
* Iterations: 1
* Convergence: true
* |x - x'| < 0.0e+00: false
* |f(x)| < 1.0e-08: true
* Function Calls (f): 2
* Jacobian Calls (df/dx): 2
Spy on the Jacobian#
fe = FESpace(4, 6, 6)
u0 = zero(fe.xn)
J = fe_jacobian(u0, fe, dfq)
my_spy(J)
plot!(title="cond = $(cond(Matrix(J)))")
fe = FESpace(8, 10, 6)
u0 = zero(fe.xn)
J = fe_jacobian(u0, fe, dfq)
my_spy(J)
plot!(title="cond = $(cond(Matrix(J)))")
Questions:
What is interesting about this matrix structure?
What would the matrix structure look like for a finite difference method that is 6th order accurate?