MATMEK-4270
We have considered the approximation of functions
We have found
Impossible to use for unstructured meshes, like
5 global basis functions
2 local piecewise linear basis functions
The Galerkin formulation is the same whether you use a global approach with Legendre polynomials or a local FEM with piecewise linear polynomials. The difference lies all in the function spaces and the choice of basis.
Find
But in this course we will learn FEM using simple structured meshes.
The domain
The figure shows a mesh with 5 non-uniform nodes and 2 non-uniform elements
Using more nodes inside each element is how the FEM can achieve higher order accuracy
An element with no internal nodes can at best use piecewise linear basis functions
Use a continuous piecewise linear function space
To approximate a function
We can still use
The mass matrix
However, since each basis function is only non-zero on at most two elements, we usually assemble elementwise and add up (this works very well on unstructured meshes!)
We define the element mass matrix
The finite element method is much more difficult to implement than global methods, because of the local basis functions and unstructured mesh. Yet, the unstructured mesh and local basis functions make the method much more flexible.
For piecewise linear basis functions there are only 2 non-zero basis functions per element. See element
The matrix
Mapping local index
For unstructured meshed
With
Note
The matrix
The 4 smaller matrices represent
Finite element assembly: add up for
In assembling the matrix
differ only in the domain, whereas the shape of the basis functions is the same regardless of domain. The piecewise linear basis functions are always straight lines.
Let us map all elements to a reference domain
Mapping back and forth is as usual
The basis functions
where
and for piecewise linear basis functions (
For quadratic elements the Lagrange basis functions on the reference domain are
Use a change of variables (
where
Note that the integral does not depend on element number
then, instead of computing for each (linear) element:
we can simply use:
with merely a different
Similarly for higher
Linear (
h = sp.Symbol('h')
l = Lagrangebasis([-1, 1])
ae = lambda r, s: sp.integrate(l[r]*l[s], (x, -1, 1))
A1e = h/2*sp.Matrix([[ae(0, 0), ae(0, 1)],[ae(1, 0), ae(1, 1)]])
A1e
Quadratic (
Ae = [A1e, A2e] # previously computed
def get_element_boundaries(xj, e, d=1):
return xj[d*e], xj[d*(e+1)]
def get_element_length(xj, e, d=1):
xL, xR = get_element_boundaries(xj, e, d=d)
return xR-xL
def local_to_global_map(e, r=None, d=1): # q(e, r)
if r is None:
return slice(d*e, d*(e+1)+1)
return d*e+r
def assemble_mass(xj, d=1):
N = len(xj)-1
Ne = N//d
A = np.zeros((N+1, N+1))
for elem in range(Ne):
hj = get_element_length(xj, elem, d=d)
s0 = local_to_global_map(elem, d=d)
A[s0, s0] += np.array(Ae[d-1].subs(h, hj), dtype=float)
return A
[[ 0.0333 0.0167 -0.0083 0. 0. 0. 0. 0. 0. ]
[ 0.0167 0.1333 0.0167 0. 0. 0. 0. 0. 0. ]
[-0.0083 0.0167 0.0667 0.0167 -0.0083 0. 0. 0. 0. ]
[ 0. 0. 0.0167 0.1333 0.0167 0. 0. 0. 0. ]
[ 0. 0. -0.0083 0.0167 0.0667 0.0167 -0.0083 0. 0. ]
[ 0. 0. 0. 0. 0.0167 0.1333 0.0167 0. 0. ]
[ 0. 0. 0. 0. -0.0083 0.0167 0.0667 0.0167 -0.0083]
[ 0. 0. 0. 0. 0. 0. 0.0167 0.1333 0.0167]
[ 0. 0. 0. 0. 0. 0. -0.0083 0.0167 0.0333]]
Sparsity pattern:
Note
In solving for
we also need the right hand side
This inner product can also be evaluated elementwise, and mapped just like the mass matrix. We define the element vector similarly as the element matrix
Using as before
Note
The vector
Assemble by adding up for all elements
def map_true_domain(xj, e, d=1, x=x): # return x(X) xL, xR = get_element_boundaries(xj, e, d=d) hj = get_element_length(xj, e, d=d) return (xL+xR)/2+hj*x/2 def map_reference_domain(xj, e, d=1, x=x): # return X(x) xL, xR = get_element_boundaries(xj, e, d=d) hj = get_element_length(xj, e, d=d) return (2*x-(xL+xR))/hj def map_u_true_domain(u, xj, e, d=1, x=x): # return u(x(X)) return u.subs(x, map_true_domain(xj, e, d=d, x=x)) def assemble_b(u, xj, d=1): l = Lagrangebasis(np.linspace(-1, 1, d+1), sympy=False) N = len(xj)-1 Ne = N//d b = np.zeros(N+1) for elem in range(Ne): hj = get_element_length(xj, elem, d=d) us = sp.lambdify(x, map_u_true_domain(u, xj, elem, d=d)) integ = lambda xj, r: us(xj)*l[r](xj) for r in range(d+1): b[local_to_global_map(elem, r, d)] += hj/2*quad(integ, -1, 1, args=(r,))[0] return b
def map_true_domain(xj, e, d=1, x=x): # return x(X) xL, xR = get_element_boundaries(xj, e, d=d) hj = get_element_length(xj, e, d=d) return (xL+xR)/2+hj*x/2 def map_reference_domain(xj, e, d=1, x=x): # return X(x) xL, xR = get_element_boundaries(xj, e, d=d) hj = get_element_length(xj, e, d=d) return (2*x-(xL+xR))/hj def map_u_true_domain(u, xj, e, d=1, x=x): # return u(x(X)) return u.subs(x, map_true_domain(xj, e, d=d, x=x)) def assemble_b(u, xj, d=1): l = Lagrangebasis(np.linspace(-1, 1, d+1), sympy=False) N = len(xj)-1 Ne = N//d b = np.zeros(N+1) for elem in range(Ne): hj = get_element_length(xj, elem, d=d) us = sp.lambdify(x, map_u_true_domain(u, xj, elem, d=d)) integ = lambda xj, r: us(xj)*l[r](xj) for r in range(d+1): b[local_to_global_map(elem, r, d)] += hj/2*quad(integ, -1, 1, args=(r,))[0] return b
Note
We need to perform an integral by calling quad
for each
Use the previously implemented assemble_mass
and assemble_b
to find the approximation of
def assemble(u, N, domain=(-1, 1), d=1, xj=None):
mesh = np.linspace(domain[0], domain[1], N+1) if xj is None else xj
A = assemble_mass(mesh, d=d)
b = assemble_b(u, mesh, d=d)
return A, b
N = 4
xj = np.linspace(1, 2, N+1)
A, b = assemble(10*(x-1)**2-1, N, d=1, xj=xj)
uh = np.linalg.inv(A) @ b
yj = np.linspace(1, 2, 1000)
plt.figure(figsize=(6, 3.5))
plt.plot(xj, uh, 'b-o', yj, 10*(yj-1)**2-1, 'r--')
plt.legend(['FEM', 'Exact']);
Note
uh
= matplotlib
will correctly fill in a linear profile between the points.Check that a non-uniform mesh works as well:
N = 6
xj = np.zeros(N+1)
xj[::2] = 1 + (np.cos(np.arange(N//2+1)*np.pi*2/N)[::-1] + 1)/2
xj[1::2] = 0.5*(xj[:-1:2]+xj[2::2])
A, b = assemble(10*(x-1)**2-1, N, d=2, xj=xj)
uh = np.linalg.inv(A) @ b
yj = np.linspace(1, 2, 1000)
plt.figure(figsize=(6, 4))
plt.plot(xj, uh, '-bo', yj, 10*(yj-1)**2-1, 'r--')
plt.plot(xj[1::2], uh[1::2], 'go')
plt.legend(['FEM 2nd order', 'Exact', 'Internal points']);
Why still linear interpolation? We need to use the higher order
The finite element solution differs from the finite difference solution in that the solution is automatically defined everywhere within the domain.
However, most basis functions will be zero at any location
def fe_evaluate(uh, p, xj, d=1):
l = Lagrangebasis(np.linspace(-1, 1, d+1), sympy=False)
elem = max(0, np.argmax(p <= xj[::d])-1) # find element containing p
Xx = map_reference_domain(xj, elem, d=d, x=p)
return Lagrangefunction(uh[d*elem:d*(elem+1)+1], l)(Xx)
fe_evaluate(uh, 1.2, xj, d=2), 10*(1.2-1)**2-1
(-0.600000000000000, -0.6000000000000002)
Just loop over scalar code for each point
Alternatively, use vectorization, but not really straightforward:
def fe_evaluate_v(uh, pv, xj, d=1):
l = Lagrangebasis(np.linspace(-1, 1, d+1), sympy=False)
# Find points inside elements
elem = (np.argmax((pv <= xj[::d, None]), axis=0)-1).clip(min=0)
xL = xj[:-1:d] # All left element boundaries
xR = xj[d::d] # All right element boundaries
xm = (xL+xR)/2 # middle of all elements
hj = (xR-xL) # length of all elements
Xx = 2*(pv-xm[elem])/hj[elem] # map pv to reference space all elements
dofs = np.array([uh[e*d+np.arange(d+1)] for e in elem], dtype=float)
V = np.array([lr(Xx) for lr in l], dtype=float) # All basis functions evaluated for all points
return np.sum(dofs * V.T, axis=1)
Compute
def L2_error(uh, ue, xj, d=1):
yj = np.linspace(-1, 1, 4*len(xj))
uhj = fe_evaluate_v(uh, yj, xj, d=d)
uej = ue(yj)
return np.sqrt(np.trapz((uhj-uej)**2, dx=yj[1]-yj[0]))
u = sp.exp(sp.cos(x))
ue = sp.lambdify(x, u)
err = []
err2 = []
for n in range(2, 30, 4):
N = 2*n
xj = np.linspace(-1, 1, N+1)
A, b = assemble(u, N, (-1, 1), 1)
uh = np.linalg.inv(A) @ b
A2, b2 = assemble(u, N, (-1, 1), 2)
uh2 = np.linalg.inv(A2) @ b2
err.append(L2_error(uh, ue, xj, 1))
err2.append(L2_error(uh2, ue, xj, 2))
Note
This illustrates nicely spectral versus finite order accuracy. With
from mpi4py import MPI
from dolfinx import mesh, fem, cpp
from dolfinx.fem.petsc import LinearProblem
import ufl
from ufl import dx, inner
msh = mesh.create_interval(MPI.COMM_SELF, 4, (-1, 1))
V = fem.functionspace(msh, ("Lagrange", 1))
u = ufl.TrialFunction(V)
v = ufl.TestFunction(V)
xp = ufl.SpatialCoordinate(msh)
ue = ufl.exp(ufl.cos(xp[0]))
a = inner(u, v) * dx
L = inner(ue, v) * dx
problem = LinearProblem(a, L)
uh = problem.solve()
FEniCS uses exactly the same method with piecewise linear basis functions as we have described using Sympy/Numpy and as such we get exactly the same matrix/vectors:
array([[0.1667, 0.0833, 0. , 0. , 0. ],
[0.0833, 0.3333, 0.0833, 0. , 0. ],
[0. , 0.0833, 0.3333, 0.0833, 0. ],
[0. , 0. , 0.0833, 0.3333, 0.0833],
[0. , 0. , 0. , 0.0833, 0.1667]])
array([0.4892, 1.1865, 1.3317, 1.1865, 0.4892])
array([1.7169, 2.4361, 2.7772, 2.4361, 1.7169])