Solving Poisson's Equation with Julia via the finite element method
Background
The finite element method (FEM) is a group of techniques for approximating the solution of boundary value problems (BVPs). This writeup will assume that you have some basic understanding of how FEM works, though I will give a quick (and extremely informal) refresher on the basics in the next section.
We will try to solve Poisson’s Equation with a Dirichlet boundary condition. First, let \( \Omega \subset \mathbb{R}^{n} \). Then, Poisson’s equation is given by:
\[\nabla^{2}u = f \text{ in } \Omega \tag{1}\]with some boundary condition:
\[u = g \text{ on }\partial\Omega\]where \(g \text{ and } f\) are a continuous functions.
Basic Principles
We begin with the weak formulation of the stated problem: Determine \(u \in \mathbb{R}^{n}\) such that:
\[\int\nabla u \nabla v d\Omega = \int v f d\Omega \tag{2}\]for all \( v \in \mathbb{R}^{n} \). Now, let \(V^{n} \subset \mathbb{R}^{n} \) be the set of continuous functions over some partition of \(\Omega\). The finite element approximation of the original problem is then given by the following.
Determine \( u_h \in V^{n}\) such that:
\[\int\nabla u_h \nabla v d\Omega = \int v f d\Omega \tag{3}\]is satisfied for all \( u_{h} \in V^{n} \). Since \( u_h \in V^{n}\) we can write:
\[u_h = \sum_{i=0}^{N} u_i \phi_i \tag{4}\]where {\( \phi_j \)} is the set of basis functions spanning \(V^{n}\). Splitting the terms up into non-boundary and boundary terms respectively we see:
\[\sum_{i\notin\partial\Omega}u_i\phi_i+\sum_{i\in\partial\Omega}g\_i\phi_i \tag{5}\]Subsituting \((5)\) into \((3)\) and replacing \( v \text{ with } \phi_j \) we get:
\[\sum_{i \notin \partial\Omega} u_i \int \nabla \phi_i \phi_j d\Omega = \int\phi_j f d\Omega-\sum_{i\in\partial\Omega}(\int\nabla\phi_i\phi_j d\Omega)g_i \tag{5}\]Which finally leads us to the matrix formulation of the problem:
\[\hat{A} \vec{x} = \vec{b}\]where:
\[A_{ij} = \int \nabla \phi_i \phi_j d\Omega\] \[x_i = u_i\] \[b_j = \int f \phi_j d\Omega -\sum_{i\in\partial\Omega}(\int\nabla\phi_i\phi_j d\Omega)g_i\]Finding the approximate solution then is accomplished by solving for \(\vec{x}\).
Implementation
The solution to the stated problem is relatively easy once all of the parts are assembled, although actually implementing the solution may more of a challenge. Several of the issues that are not commonly discussed are detailed in the next three subsections.
Mesh Representation
Many introductory FEM examples provide in-code mesh definitions, making them difficult to adapt to different geometries. Given that the ability to handle complex geometries gracefully is one of the greatest strengths of the FEM, I wanted to create a code that allowed for more advanced meshing capabilities.
In order to avoid going down the rabbit hole of building my own meshing package (maybe an excursion for a later time, however) I relied on GMSH to construct my example meshes and built an interface between the mesh file and the FEM solver module. The exact details of how the mesh is read into Julia is fairly mundane, so I will skip over it. If you wish to see how this works the code can be seen here.
Once the GMSH file is read in the relevant information is encapsulated in a
Mesh
datatype:
With the data structure laid out, we now face the issue of how to accurately reflect the geometry of the problem. This implementation will use linear triangle shaped elements. This representation makes the shape functions of the reference element extremely simple:
The combination of the shape function definitions and the mesh datatype allow us to represent the solution. To actually find the solution we must assemble our matrix equation.
Assembling \(\hat{A}\) and \(\vec{b}\)
To build the set of equations in \( (6) \) we will have to calculate some quantities of interest on the mesh elements first. For each element in the solution we will calculate the gradient on the element, which is used to build local equivalents to \( \hat{A} \) and \( \vec{b} \). Then, using the element-wise versions we can iteratively construct the global versions. The process looks like the following.
Before we get into assembling \(\hat{A}\) and \(\vec{b}\), some setup:
Then, the following sections are to be in a loop over each element in the mesh.
Gradient calculation:
\(\hat{A}\) calculation:
\(\vec{b}\) calculation:
The Solving the Matrix Equation
Now that we’ve got the load vector and stiffness matrix we can use Julia’s built in capabilities: