It is Christmas night, and it is the first time this month that I haven’t had to plan my schedule for the evening around a programming puzzle contest. For the last 25 days this month, I participated in Advent of Code 2020, and I managed to collect all 50 stars!
A tutorial on how to create and deploy your Julia Package documentation using Documenter.jl and GitHub Actions.
If you are developing a new package for Julia, you might’ve followed the steps in this article, and is now wondering how to create the documentation for your package. Well, this is what this article is for. Here, our new package is also called VegaGraphs.jl, which is a package that I’m developing at the moment.
In this tutorial I’ll be using the package Documenter.jl together with the GitHub Actions plugin. The Documenter.jl package will help us create the documentation, and the GitHub Actions plugin will create a bot for us that will publish our documentation on our GitHub page.
1. Creating Docstring
First of all, when you write the functions in your package, above each function you should write a Docstring explaining the arguments used in the function, what the function does, etc.
# Example of function inside ./src/VegaGraphs.jl """ MyFunction(x,y)
This is an example of Docstring. This function receives two numbers x and y and returns the sum of the squares.
```math x^2 + y^2 ``` """ function MyFunction(x,y) return x^2+y^2 end
Note that you should use triple quotes, and place the text right above the function you are documenting. Also, you may use LaTeX to write math equations, as shown in the lines:
```math x^2 + y^2 ```
When you generate your documentation, this equation will be properly rendered, and you will have a beautiful mathematical equation.
2. Setting up Documenter.jl
Next we must set up the Documenter.jl. To do this, first create a folder named docs and inside of it create a file named make.jland another folder named ./src . Your package folder should look something like this:
Inside the make.jl file we will write the code that Documenter.jl will use to create a nice webpage for our documentation. Inside make.jl write the following (changing the name of the package from VegaGraph to yours):
# Inside make.jl push!(LOAD_PATH,"../src/") using VegaGraphs
Most of the code here is self-explanatory. You are defining the name the website for the documentation, the module which you will be documenting, and the pages your website will have. For now, our documentation will only have “Home”, and the information that will be on this page will be inside the index.m file.
Inside the ./docs/src you need to create the file named index.md. This is a markdown file where you will write how the “Home” page should look like. Here is an example:
# VegaGraphs.jl
*The best summation package.*
## Package Features - Sum the squares of two numbers
Everything here should be familiar to you if you know markdown. The only thing that looks different are the last 4 lines. Here is where our Docstring comes in. The Documenter.jl package will take the Docstring from the function MyFunction and place where we wrote:
As your create new functions, just add more of this to your index.md , and you will rapidly create your package’s documentation.
The final step in regards to Documenter.jl is to build the whole thing:
# from your terminal,inside the ./docs/src # Remember to install Documenter.jl before running this
julia make.jl
After running this command, a new folder called build will be created inside the docs , and this folder will contain all the html files for your documentation. You may now open this folder
3. Deploying your Documentation with GitHub Actions
Your website containing the documentation for the package is already created, and you may host the webpages using any method you want. In this section, I’ll then explain how to use GitHub Actions to automatically publish the documentation using GitHub pages.
Assuming you followed this article here on how to develop your package, you already have GitHub Actions working on the background. What you must do now is create a file named Documentation.yml inside the .github/workflows folder. Inside this file, you should have something like this:
name: Documentation
on: push: branches: - master tags: '*' pull_request:
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: julia-actions/setup-julia@latest with: version: '1.5' - name: Install dependencies run: julia --project=docs/ -e 'using Pkg; Pkg.develop(PackageSpec(path=pwd())); Pkg.instantiate()' - name: Build and deploy env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # For authentication with GitHub Actions token run: julia --project=docs/ docs/make.jl
You can pretty much copy and past the code above, and the next time you push new commits to your repository, the bot will run and generate the documentation. Also, note that it will create a branch named “gh-pages”. This is the branch containing the webpages for the documentation.
To use GitHub pages for hosting our documentation, we must enable GitHub pages on the repository containing the package. To do this, just go to the repository GitHub’s page, click on setttings , scroll down to the “GitHub Pages” section and enable it.
Example showing how to enable the hosting of your documentation
This is a static version of a Nextjournal notebook which shows how to develop a two-dimensional finite-difference solver for the Navier-Stokes equations from scratch in Julia. A convenient interface to the code is provided in a high-level function with keyword arguments for the dimensions, spatial resolution, Reynolds number, and a few other parameters. Head over to the Nextjournal version to interact with the code and even remix it to start your own coding project!
Introduction
This is a solver for the two-dimensional unsteady viscous incompressible Navier-Stokes equations in \(\omega-\psi\) formulation on a rectangular Cartesian grid. We discretize the domain using second-order centered finite differences, and march the governing equations forward in time implicitly.
All linear systems are solved by using either a naive Gauss-Siedel relaxation scheme or the native Julia matrix-inversion operator \ on a SparseArray. It should be possible, in the future, to assemble the full matrix using the Julia package DiffEqOperators.jl
We test the solver on the lid-driven cavity problem and compare results with Ghia & Ghia’s solution.
The first equation is a parabolic-hyperbolic equation for \(\omega\) where the advection velocity is given through the streamfunction, which is defined as follows:
The second equation is an elliptic equation for \(\psi\), specifically Poisson’s equation with the vorticity as the source term. This equation proceeds directly from the definition of \(\omega\) and \(\psi\):
In a rectangular domain \(\Omega\), we have eight boundary conditions on \(\psi\):
Four Dirichlet boundary conditions: \(\psi = 0\) on the entire \(\partial \Omega\) which ensures that the walls are a single streamline, i.e. that the wall-normal velocities are zero.
Four Neumann boundary coniditions: \(\frac{\partial \psi}{\partial n} = 0\) on the south, east, and west boundaries, and \(\frac{\partial \psi}{\psi n} = 1\) on the north boundary. This specifies the wall-tangential velocity \(u_t\) on each boundary.
For \(\omega\), no explicit boundary conditions are given. Indeed, the vorticity at the wall is actually a crucial unknown in the Navier-Stokes equations with boundaries, since all vorticity in a fluid must have been first generated at boundaries.
Thom’s Formula
To derive implicit boundary conditions on the vorticity, let us write a Taylor expansion for the streamfunction at a point adjacent to a wall, with the subscript ‘a’ representing the wall-adjacent point and the subscript b representing the point at the wall. ‘n’ is a coordinate representing the wall-normal direction, and \(\Delta n\) is the spatial discretization in the direction normal to the wall.
The normal derivative of \(\psi\) at a wall is simply the wall-tangential velocity. The second spatial derivative of \(\psi\) at a wall is (what is left of) the Laplacian of \(\psi\) at the wall, which by definition equals negative of the vorticity. Hence, we now have a relation between the wall vorticity \(\omega_b\), the wall-tangential velocity \(u_t\), and the value of the streamfunction:
In practice, we will use Dirichlet boundary conditions on \(\psi\) and Thom’s boundary conditions on \(\omega\).
function VorticityBoundaryConditions!(ω,ψ,Δx,Δy,un,us,ve,vw)ω[:,end].=2*((ψ[:,end]-ψ[:,end-1])/(Δx^2).-ve/Δx)ω[:,1].=2*((ψ[:,1]-ψ[:,2])/(Δx^2).-vw/Δx)ω[end,:].=2*((ψ[end,:]-ψ[end-1,:])/(Δy^2).+us/Δy)ω[1,:].=2*((ψ[1,:]-ψ[2,:])/(Δy^2).+un/Δy)end
Linear solvers
In theory, of course, any matrix-inverting technique can be used with any equation of the form \(Ax=b\). Here, we will use the native Julia matrix-inversion operator \ (or the conjugate gradient algorithm cg! from IterativeSovlers.jl ) for the Poisson equation for \(\psi\) because the boundary conditions for that equation are easy to implement, and they don’t change at each time step. For the advection-diffusion equation for \(\omega\), however, we will use the Gauss-Siedel technique. This equation is by far the easier one to solve, so the computational penalty of a naive solver like Gauss-Siedel is not very high.
Solving sparse \(A x = b\) with Gauss-Siedel
Consider a system of linear equations of the form \(Ax=b\). The vector x represents an unknown quantity on the entire grid, and is arranged in the following form:
A is a sparse, pentadiagonal matrix with (at most) five non-zero terms. These terms are the coefficients of the \(x_{ij}\)‘th point as well as its neighbors to the north, south, east and west. Thus, using ‘N,S,E,W’ to represent the neighboring points and ‘p’ to represent current point, the general form of any row of this system of equations is as follows:
In the Gauss-Siedel method, we make a new guess for \(x^{n+1}\) based on the current guess, \(x^n\) using the following procedure:
\[res = b_p – (a_p x_p + \sum_{NSEW}a_i x_i)\]
\[\Delta x = \frac{res}{a_p}\]
\[x_p^{n+1} = x^n_p + \Delta x\]
this is repeated until the residual falls below a small \(\epsilon\).
Over-relaxation
The Gauss-Siedel algorithm can be significantly accelerated by adding an over-relaxation parameter \(\lambda\). It can be added on to the end of each Gauss-Siedel iteration in the following way:
this essentially ‘weights’ the new value between the predicted value and the previous value. When \(\lambda = 1\), this reverts back to the usual Gauss-Siedel algorithm. As \(\lambda \rightarrow 2\), this weighs the new value more heavily toward the predicted value. One rule of thumb for the over-relaxation parameter is \(\lambda = 2 – \frac{1}{N-1}\).
In practice, we have found that over-relaxation only makes sense for solving the elliptic Poisson equation for \(\psi\).
function GaussSiedel!(ϕ,Ap,An,As,Ae,Aw,Rp,res;λ=1,maxiter=1000)normRes=1k=0Ny,Nx=size(ϕ)whilenormRes>=1e-8&&k<maxiterk+=1foriin2:Ny-1forjin2:Nx-1ϕP=ϕ[i,j]ϕE=ϕ[i+0,j+1]ϕW=ϕ[i+0,j-1]ϕN=ϕ[i-1,j+0]ϕS=ϕ[i+1,j+0]res[i,j]=Rp[i,j]-(Ap*ϕP+An*ϕN+As*ϕS+Ae*ϕE+Aw*ϕW)Δϕ=res[i,j]/Apϕ[i,j]=λ*(ϕ[i,j]+Δϕ)+(1-λ)*ϕ[i,j]endendnormRes=norm(res)endreturnkend
Solving sparse \(Ax=b\) with \ or cg!
In principle, Julia provides very simple syntax for matrix-inversion: A\b should be all we need. However, because we will be storing all variables as 2-D arrays, we need to first unwrap x and the right-hand side into a 1-D array, apply the matrix-inversion, and then wrap the updated x back into a 2-D array.
function LinearSolve!(A,x,b)# Solves the equation Ax = b assuming zero Dirichlet BCs everywhereNy,Nx=size(b)Ny,Nx=Ny-2,Nx-2x_int=x[2:end-1,2:end-1]b_int=b[2:end-1,2:end-1]b_vec=reshape(b_int,Ny*Nx)# x_int = A\b_vecx_vec=reshape(x_int,Ny*Nx)cg!(x_vec,A,b_vec,log=true)x[2:end-1,2:end-1].=reshape(x_int,(Ny,Nx))end
Discrete system of equations for \(\omega\) and \(\psi\)
Poisson equation for \(\psi\)
The equation for the streamfunction is already a Poisson equation, which is linear. It is straightforward to cast it in the form Ax = b using finite differences:
This only needs to be done once. We write a function which returns the 2-dimensional Laplacian using Julia’s SparseArray type:
function BuildPoissonMatrix(Ny,Nx,Δx,Δy)# This function returns a (Ny*Nx) × (Ny*Nx) matrix in the form of# a sparse array, corresponding to the discrete 2D Laplacian operator.Ny=Ny-2Nx=Nx-2Isx=[1:Ny;1:Ny-1;2:Ny]Jsx=[1:Ny;2:Ny;1:Ny-1]Isy=[1:Nx;1:Nx-1;2:Nx]Jsy=[1:Nx;2:Nx;1:Nx-1]Vsx=[fill(-2,Ny);fill(1,2Ny-2)]Vsy=[fill(-2,Nx);fill(1,2Nx-2)]D²x=sparse(Isx,Jsx,Vsx)D²y=sparse(Isy,Jsy,Vsy)D_yy=1/(Δy^2).*kron(sparse(I,Nx,Nx),D²x)D_xx=1/(Δx^2).*kron(D²y,sparse(I,Ny,Ny))Lap=D_xx+D_yyend
We treat the parabolic part (i.e. the diffusion term) of this equation implicitly, but the hyperbolic part (i.e. the advection term) explicitly. This is because if we were to treat the term term \(\boldsymbol{u} \cdot \nabla \omega\) implicitly with a central-difference scheme, we would get a non-diagonally-dominant matrix, which is not guaranteed to converge using the iterative matrix-solving techniques. If an upwind scheme is used, we can then treat the advection term implicitly as well.
We can write a discrete version of the evolution equation for \(\omega\) as follows:
where the superscript n denotes the value at the current (known) timestep, and the superscript n+1 denotes the value at the future (unknown) timestep. The diffusion term is treated implicitly, hence the n+1 there, while the advection term is treated explicitly, hence the n there. The time-derivative term has been treated fully implicitly with a first-order backwards Euler scheme, i.e. \(\dot{\omega}^{n+1} \approx \frac{\omega^{n+1}-\omega^{n}}{\Delta t}\). Collecting the unknown terms on the left-hand side and the known terms on the right-hand side, we get:
The above is also, of course, a system of linear equations of the form \(Ax=b\) and its diagonal dominance is guaranteed. Hence, it too can be solved using iterative methods. We build the matrix (in practice, only a set of coefficients, since we will solve this particular equation using the Gauss-Siedel technique) once, at the beginning:
function BuildAdvectionDiffusionCoefficients(Re,Δt,Δx,Δy)# Time-derivativeap=1/Δt# Diffusionap+=2/(Re*Δx^2)+2/(Re*Δy^2)an=-1/(Re*Δy^2)aw=-1/(Re*Δx^2)as=-1/(Re*Δy^2)ae=-1/(Re*Δx^2)returnap,an,as,ae,awend
On the other hand, the right-hand side of this equation will evidently be different at each time step, since the explicit term \(\boldsymbol{u} \cdot \nabla \omega\) changes at every step. The following function, therefore, will be called at each time step:
function BuildAdvectionDiffusionRHS!(Rp,ϕ,ψ,Δt,Δx,Δy,Ny,Nx,Re)# Time derivativeRp.=ϕ/Δt# Diffusion term (fully implicit)# Convection termforiin2:Ny-1forjin2:Nx-1ϕE=ϕ[i+0,j+1];ϕW=ϕ[i+0,j-1];ϕN=ϕ[i-1,j+0];ϕS=ϕ[i+1,j+0]ψE=ψ[i+0,j+1];ψW=ψ[i+0,j-1];ψN=ψ[i-1,j+0];ψS=ψ[i+1,j+0]u=(ψN-ψS)/(2Δy);v=-(ψE-ψW)/(2Δx)∂ϕ∂y=(ϕN-ϕS)/(2Δy);∂ϕ∂x=(ϕE-ϕW)/(2Δx)Rp[i,j]+=-(u*∂ϕ∂x+v*∂ϕ∂y)endendend
It is straightforward to replace the first-order backwards Euler time-stepping scheme with a second-order backwards Euler scheme. The only difference is that an additional set of \(\omega\)’s needs to be stored, and the time-derivative terms in the matrix as well as the RHS need to be slightly modified. The second-order backward scheme looks like this:
thus, we would simply need to replace Rp .= ϕ/Δt with Rp .= 2ϕ/Δt - ϕold/(2Δt) inside the function BuildAdvectionDiffusionRHS!, and replace ap = 1/Δt with 3/(2Δt) inside the function BuildAdvectionDiffusionCoefficients.
Code utilities
Record changes
In Julia, functions can be broadcast to multiple arguments. Hence, we only need a generic recording function:
function RecordHistory!(ϕ,ϕ_old,ϕ_hist)Δϕ=norm(ϕ-ϕ_old)ϕ_old.=ϕpush!(ϕ_hist,Δϕ)return(Δϕ)end
Solution struct and associated functions
We create a struct (essentially, a new type) representing a solution. The solver’s output will be assigned to a new instance of this struct. We also create some methods associated with this object type:
The code depends on some Julia packages. Here, we will install the ones which are not already in the environment and then pin all of them. The following is therefore executed in a different runtime, whose environment will be exported.
The above functions will be assembled into a function called LidDrivenCavity(), which accepts a number of keyword arguments. These are all optional, since there are default values associated with them.
tfinal = Inf, the final time of the simulation. If not set, it will run till steady-state.
Lx=1, length of \(\Omega\) in the horizontal direction
Ly=1, length of \(\Omega\) in the vertical direction
CFL=0.5, the Courant-Fredericks-Levy number
Nx = 65, the number of discretization points in the horizontal direction
Ny = 65, the number of discretization points in the horizontal direction
u_n,u_s,v_w,v_e = (1,0,0,0), the tangential velocities at each wall (north, south, east, west)
printfreq, prints output every this number of steps
Re=100, the Reynolds number
Complete function
function LidDrivenCavity(;tfinal=Inf,Lx=1,Ly=1,CFL=0.5,Re=100,Nx=65,Ny=65,u_n=1,u_s=0,v_w=0,v_e=0,printfreq=10)t0=time()# begin timingprintln("------------------Ny = $(Ny), Nx = $(Nx) ---------------")Δy=Ly/(Ny-1)Δx=Lx/(Nx-1)x=0:Δx:Lxy=0:Δy:LyΔt=CFL*Δx# Construct matrix for Poisson equationA_poisson=BuildPoissonMatrix(Ny,Nx,Δx,Δy)# for coNxgrad# Construct matrix for advection-diffusion equationap,an,as,ae,aw=BuildAdvectionDiffusionCoefficients(Re,Δt,Δx,Δy)# allocate empty matrices for Gauss-Siedel solverRp=zeros(Ny,Nx);res=zeros(Ny,Nx)# initialize ω and ψω=zeros(Ny,Nx)ψ=zeros(Ny,Nx)# keep track of changes ω_old=zeros(Ny,Nx)ψ_old=zeros(Ny,Nx)ω_hist=[]ψ_hist=[]residual=1######### Begin time-stepping #########k0,t=0,0whilet<tfinal&&maximum(residual)>1e-8t+=Δtk0+=1# Solve Poisson equation for ψ:LinearSolve!(A_poisson,ψ,-ω)# Determine boundary conditions on ω using Thom's formulaVorticityBoundaryConditions!(ω,ψ,Δx,Δy,u_n,u_s,v_e,v_w)# Modify the explicit part of advection-diffusion equationBuildAdvectionDiffusionRHS!(Rp,ω,ψ,Δt,Δx,Δy,Ny,Nx,Re)# Solve advection-diffusion equation for ω:GaussSiedel!(ω,ap,an,as,ae,aw,Rp,res)# Record changesresidual=RecordHistory!.([ω,ψ],[ω_old,ψ_old],[ω_hist,ψ_hist])# Print to terminalif(k0%printfreq==0)println("Step: $k0 \t Time: $(round(t,digits=3))\t","|Δω|: $(round((residual[1]),digits=8)) \t","|Δψ|: $(round((residual[2]),digits=8)) \t")endendtt=round(time()-t0,digits=3)# end timingprintln("This took $tt seconds.")println("--------------------------------------------------------")# Create a struct containing the resultsResults(ψ,ω,hcat(ω_hist,ψ_hist),x,y,t,k0,Re)end
beginusingDelimitedFiles,Plots,LaTeXStringsNx,Ny,Lx,Ly=65,65,1,1ψ1=sol1.ψuref_along_y=readdlm(reference_u.txt,skipstart=1)[:,2:3]vref_along_x=readdlm(reference_v.txt,skipstart=1)[:,2:3]Ny,Nx=size(ψ1)Δy=Ly/(Ny-1)Δx=Lx/(Nx-1)u1=diff(ψ1[:,Int((end-1)/2)])./Δyy1=reverse(range(Δy/2,1-Δy/2,step=Δy))v1=-diff(ψ1[Int((end-1)/2),:])./Δxx1=reverse(range(Δx/2,1-Δx/2,step=Δx))plot(y1,u1,markershape=:circle,color=:blue,label=L"u(x=0.5,y)",legendfont=font(14),framestyle=:box)plot!(uref_along_y[:,1],uref_along_y[:,2],markershape=:square,color=:blue,label="Ghia and Ghia")plot!(v1.+0.5,x1,markershape=:circle,color=:red,label=L"v(x,y=0.5)")plot!(vref_along_x[:,2].+0.5,vref_along_x[:,1],label="Ghia and Ghia",markershape=:square,color=:red,yticks=:none,xticks=:none,legend=:left)end