This is the fourth and final lesson of Module 3, Riding the wave: convection problems, of the course "Practical Numerical Methods with Python" (a.k.a., #numericalmooc). We learned about conservation laws and the traffic-flow model in the first lesson, and then about better numerical schemes for convection in lesson 2.
By then, you should have started to recognize that both mathematical models and numerical schemes work together to give us a good solution to a problem. To drive the point home, lesson 3 deals only with an improved model—and showed you some impressive SymPy tricks!
In this lesson, we'll learn about a new class of discretization schemes, known as finite-volume methods. They are the most widely used methods in computational fluid dynamics, and for good reasons! Let's get started ...
Are you curious to find out why the finite-volume method (FVM) is the most popular method in computational fluid dynamics? In fact, almost all of the commercial CFD software packages are based on the finite-volume discretization. Here are some reasons:
FVM discretizations are very general and have no requirement that the grid be structured, like in the finite-difference method. This makes FVM very flexible.
FVM gives a conservative discretization automatically by using directly the conservation laws in integral form.
Let's go right back to the start of this module, where we explained conservation laws looking at a tiny control volume. To simplify the discussion, we just looked at flow in one dimension, with velocity u. Imagining a tiny cylindrical volume, like the one shown in Figure 1, there is flux on the left face and right face and we easily explained conservation of mass in that case.
The law of conservation of mass says that the rate of change of mass in the control volume, plus the net rate of flow of mass across the control surfaces must be zero. The same idea works for other conserved quantities.
Conservation means that any change in the quantity within a volume is due to the amount of that quantity that crosses the boundary. Sounds simple enough. (Remember that we are ignoring possible internal sources of the quantity.) The amount crossing the boundary is the flux. A general conservation law for a quantity e is thus:
∂∂t∫cvedV+∮cs→F⋅d→A=0where →F is the flux, and cv denotes the control volume with control surface cs.
Why not make the control volume itself our computational cell?
Imagine that the one-dimensional domain of interest is divided using grid points xi. But instead of trying to compute local values at the grid points, like we did before, we now want to follow the time evolution of average values within each one-dimensional cell of width Δx with center at xi (the idea is that as long as the cells are small enough, the average values will be a good representation of the quantities we are interested in).
Define ei as the integral average across the little control volume on the cell with center at xi (see Figure 2).
ei=1Δx∫xi+Δx/2xi−Δx/2e(x,t)dx.If we know the flux terms at the boundaries of the control volume, which are at xi−1/2 and xi+1/2, the general conservation law for this small control volume gives:
∂∂tei+1Δx[F(xi+1/2,t)−F(xi−1/2,t)]=0.This now just requires a time-stepping scheme, and is easy to solve if we can find F on the control surfaces.
We've seen with the traffic model that the flux can depend on the conserved quantity (in that case, the traffic density). That is generally the case, so we write that F=F(e). We will need to compute, or approximate, the flux terms at the cell edges (control surfaces) from the integral averages, ei.
If we had a simple convection equation with c>0, then the flux going into the cell centered at xi from the left would be F(ei−1) and the flux going out the cell on the right side would be F(ei) (see Figure 2). Applying these fluxes in Equation (3) results in a scheme that is equivalent to our tried-and-tested backward-space (upwind) scheme!
We know from previous lessons that the backward-space scheme is first order and the error introduces numerical diffusion. Also, remember what happened when we tried to use it with the non-linear traffic model in the green-light problem? It blew up! That was because the problem contains both right-moving and left-moving waves (if you don't remember that discussion, go back and review it; it's important!).
To skirt this difficulty in the green-light problem, we chose initial conditions that don't produce negative wave speeds. But that's cheating! A genuine solution would be to have a scheme that can deal with both positive and negative wave speeds. Here is where Godunov comes in.
Godunov proposed a first-order method in 1959 that uses the integral form of the conservation laws, Equation (1), and a piecewise constant representation of the solution, as shown in Figure (2). Notice that representing the solution in this way is like having a myriad of little shocks at the cell boundaries (control surfaces).
For each control surface, we have two values for the solution e at a given time: the constant value to the left, eL, and the constant value to the right, eR. A situation where you have a conservation law with a constant initial condition, except for a single jump discontinuity is called a Riemann problem.
The Riemann problem has an exact solution for the Euler equations (as well as for any scalar conservation law). The shock-tube problem, subject of your assignment for this course module, is a Riemann problem! And because it has an analytical solution, we can use it for testing the accuracy of numerical methods.
But Godunov had a better idea. With the solution represented as piecewise constant (Figure 2), why not use the analytical solution of the Riemann problem at each cell boundary? Solving a Riemann problem gives all the information about the characteristic structure of the solution, including the sign of the wave speed. The full solution can then be reconstructed from the union of all the Riemann solutions at cell boundaries. Neat idea, Godunov!
Figure 3 illustrates a Riemann problem for the Euler equations, associated to the shock tube. The space-time plot shows the characteristic lines for the left-traveling expansion wave, and the right-traveling contact discontinuity and shock.
We need to solve many Riemann problems from t to t+Δt, one on each cell boundary (illustrated in Figure 4). The numerical flux on xi+1/2 is
Fi+1/2=1Δt∫tn+1tnF(e(xi+1/2,t))dtTo be able to solve each Riemann problem independently, they should not interact, which imposes a limit on Δt. Looking at Figure 4, you might conclude that we must require a CFL number of 1/2 to avoid interactions between the Riemann solutions, but the numerical flux above only depends on the state at xi+1/2, so we're fine as long as the solution there is not affected by that at xi−1/2—i.e., the CFL limit is really 1.
The Riemann solution, even though known analytically, can get quite hairy for non-linear systems of conservation laws (like the Euler equations). And we need as many Riemann solutions as there are finite-volume cell boundaries, and again at each time step! This gets really cumbersome.
Godunov solved the Riemann problems exactly, but many after him proposed approximate Riemann solutions instead. We'll be calculating the full solution numerically, after all, so some controlled approximations can be made. You might imagine a simple approximation for the flux at a cell boundary that is just the average between the left and right values, for example: 12[F(eL)+F(eR)]. But that leads to a central scheme and, on its own, is unstable. Adding a term proportional to the difference between left and right states, eR−eL, supplies artificial dissipation and gives stability (see van Leer et al., 1987).
One formula for the numerical flux at xi+1/2 called the Rusanov flux, a.k.a. Lax-Friedrichs flux, is given by
Fi+1/2=12[F(eL)+F(eR)]−12max|F′(e)|(eR−eL)where F′(e) is the Jacobian of the flux function and max|F′(e)| is the local propagation speed of the fastest traveling wave. The Riemann solutions at each cell boundary do not interact if max|F′(e)|≤ΔxΔt, which leads to a flux formula we can now use:
Fi+1/2=12(F(ei)+F(ei+1)−ΔxΔt(ei+1−ei))import numpy
from matplotlib import pyplot, animation
from IPython.display import HTML
%matplotlib inline
# Set the font family and size to use for Matplotlib figures.
pyplot.rcParams['font.family'] = 'serif'
pyplot.rcParams['font.size'] = 16
Let's apply Godunov's method to the LWR traffic model. In lesson 2 we already wrote functions to set the initial conditions for a red-light problem and to compute the fluxes. To save us from writing this out again, we saved those functions into a Python file named traffic.py
(found in the same directory of the course repository). Now, we can use those functions by importing them in the same way that we import NumPy or any other library. Like this:
from traffic import rho_red_light, flux
You've probably noticed that we have the habit of writing detailed explanations of what a function does after defining it, in comments. These comments are called docstrings and it is good practice to include them in all your functions. It can be very useful when loading a function that you aren't familiar with (or don't remember!), because the help
command will print them out for you. Check it out:
help(rho_red_light)
Help on function rho_red_light in module traffic: rho_red_light(x, rho_max) Computes the "red light" initial condition with shock. Parameters ---------- x : numpy.ndarray Locations on the road as a 1D array of floats. rho_max : float The maximum traffic density allowed. Returns ------- rho : numpy.ndarray The initial car density along the road as a 1D array of floats.
Now, we can write some code to set up our notebook environment, and set the calculation parameters, with the functions imported above readily available.
# Set parameters.
nx = 100 # number of cells along the road
L = 4.0 # length of the road
dx = L / nx # cell width
nt = 30 # number of time steps to compute
rho_max = 10.0 # maximum traffic density allowed
u_max = 1.0 # speed limit
# Get the grid-cell centers.
# x_i is now the center of the i-th cell.
x = numpy.linspace(0.0 + 0.5 * dx, L - 0.5 * dx, num=nx)
# Compute the initial traffic density.
rho0 = rho_red_light(x, rho_max)
# Plot the initial car density on the road.
fig = pyplot.figure(figsize=(6.0, 4.0))
pyplot.xlabel(r'$x$')
pyplot.ylabel(r'$\rho$')
pyplot.grid()
line = pyplot.plot(x, rho0,
color='C0', linestyle='-', linewidth=2)[0]
pyplot.xlim(0.0, L)
pyplot.ylim(4.0, 11.0)
pyplot.tight_layout();
Below is a new function for applying Godunov's method with Lax-Friedrichs fluxes. Study it carefully.
def godunov(rho0, nt, dt, dx, bc_values, *args):
"""
Computes and returns the history of the traffic density
on the road using a Godunov scheme
with a Lax-Friedrichs flux.
Parameters
----------
rho0 : numpy.ndarray
The initial traffic density along the road
as a 1D array of floats.
nt : integer
The number of time steps to compute.
dt : float
The time-step size to integrate.
dx : float
The distance between two consecutive locations.
bc_values : tuple or list
The value of the density at the first and last locations
as a tuple or list of two floats.
args : list
Positional arguments to be passed to the flux function.
Returns
-------
rho_hist : list of numpy.ndarray objects
The history of the car density along the road
as a list of 1D arrays of floats.
"""
rho_hist = [rho0.copy()]
rho = rho0.copy()
for n in range(nt):
rhoL = rho[:-1] # i-th value at index i-1/2
rhoR = rho[1:] # i+1-th value at index i-1/2
# Compute the flux at cell boundaries.
F = 0.5 * (flux(rhoL, *args) + flux(rhoR, *args) -
dx / dt * (rhoR - rhoL))
# Advance in time.
rho[1:-1] = rho[1:-1] - dt / dx * (F[1:] - F[:-1])
# Apply boundary conditions.
rho[0], rho[-1] = bc_values
# Record the time-step solution.
rho_hist.append(rho.copy())
return rho_hist
We can run using a CFL of 1, to start with, but you should experiment with different values.
# Set time-step size based on CFL limit.
sigma = 1.0
dt = sigma * dx / u_max # time-step size
# Compute the traffic density at all time steps.
rho_hist = godunov(rho0, nt, dt, dx, (rho0[0], rho0[-1]),
u_max, rho_max)
def update_plot(n, rho_hist):
"""
Update the line y-data of the Matplotlib figure.
Parameters
----------
n : integer
The time-step index.
rho_hist : list of numpy.ndarray objects
The history of the numerical solution.
"""
fig.suptitle('Time step {:0>2}'.format(n))
line.set_ydata(rho_hist[n])
# Create an animation of the traffic density.
anim = animation.FuncAnimation(fig, update_plot,
frames=nt, fargs=(rho_hist,),
interval=100)
# Display the video.
HTML(anim.to_html5_video())
You'll see that the result is very similar to the original Lax-Friedrichs method, and with good reason: they're essentially the same! But this is only because we are using a uniform grid. In the finite-volume approach, using the integral form of the equations, we were free to use a spatially varying grid spacing, if we wanted to.
The original Godunov method is first-order accurate, due to representing the conserved quantity by a piecewise-constant approximation. That is why you see considerable numerical diffusion in the solution. But Godunov's method laid the foundation for all finite-volume methods to follow and it was a milestone in numerical solutions of hyperbolic conservation laws. A whole industry developed inventing "high-resolution" methods that offer second-order accuracy and higher.
Godunov's method works in problems having waves moving with positive or negative wave speeds. Try it on the green-light problem introduced in lesson 1 using the initial condition containing waves traveling in both directions.
Investigate two or three different numerical flux schemes (you can start with van Leer et al., 1987, or Google for other references. Implement the different flux schemes and compare!
Godunov's method is first-order accurate, which we already know is not appropriate for hyperbolic conservation laws, due to the high numerical diffusion. This poses particular difficulty near sharp gradients in the solution.
To do better, we can replace the piecewise constant representation of the solution with a piecewise linear version (still discontinuous at the edges). This leads to the MUSCL scheme (for Monotonic Upstream-Centered Scheme for Conservation Laws), invented by van Leer (1979).
The piecewise linear reconstruction consists of representing the solution inside each cell with a straight line (see Figure 5). Define the cell representation as follows:
e(x)=ei+σi(x−xi).where σi is the slope of the approximation within the cell (to be defined), and ei is the Godunov cell average. The choice σi=0 gives Godunov's method.
Standard central differencing would give
σi=ei+1−ei−12Δx.But we saw with the results in the second lesson that this can lead to oscillations near shocks. These Gibbs oscillations will always appear (according to Godunov's theorem) unless we use constant reconstruction. So we have to modify, or limit the slope, near shocks.
The easiest way to limit is to compute one-sided slopes
Δe−=ei−ei−1Δx,Δe+=ei+1−eiΔx,Now build the minmod slope
σi=minmod(Δe−,Δe+)={min(Δe−,Δe+) if Δe−,Δe+>0max(Δe−,Δe+) if Δe−,Δe+<00 if Δe−⋅Δe+≤0That is, use the smallest one-sided slope in magnitude, unless the slopes have different sign, in which cases it uses the constant reconstruction (i.e., Godunov's method).
Once the minmod slope is calculated, we can use it to obtain the values at the interfaces between cells.
eRi−1/2=ei−σiΔx2eLi+1/2=ei+σiΔx2where eR and eL are the local interpolated values of the conserved quantity immediately to the right and left of the cell boundary, respectively.
Notice that for the cell with index i, we calculate eRi−1/2 and eLi+1/2. Look at Figure 5: those are the two local values of the solution are at opposite cell boundaries.
However, when we calculate the local flux at the cell boundaries, we use the local solution values on either side of that cell boundary. That is:
Fi+1/2=f(eLi+1/2,eRi+1/2)You can calculate two flux vectors; one for the right-boundary values and one for the left-boundary values. Be careful that you know which boundary value a given index in these two vectors might refer to!
Here is a Python function implementing minmod.
def minmod(e, dx):
"""
Computes the minmod approximation of the slope.
Parameters
----------
e : list or numpy.ndarray
The input values as a 1D array of floats.
dx : float
The grid-cell width.
Returns
-------
sigma : numpy.ndarray
The minmod-approximated slope
as a 1D array of floats.
"""
sigma = numpy.zeros_like(e)
for i in range(1, len(e) - 1):
de_minus = (e[i] - e[i - 1]) / dx
de_plus = (e[i + 1] - e[i]) / dx
if de_minus > 0 and de_plus > 0:
sigma[i] = min(de_minus, de_plus)
elif de_minus < 0 and de_plus < 0:
sigma[i] = max(de_minus, de_plus)
else:
sigma[i] = 0.0
return sigma
Since we are aiming for second-order accuracy in space, we might as well try for second-order in time, as well. We need a method to evolve the ordinary differential equation forwards in time:
∂∂tei+1Δx[F(xi+1/2,t)−F(xi−1/2,t)]=0A second-order Runge-Kutta method with special characteristics (due to Shu & Osher, 1988) gives the following scheme:
e∗i=eni+ΔtΔx(Fni−1/2−Fni+1/2)en+1i=12eni+12(e∗i+ΔtΔx(F∗i−1/2−F∗i+1/2))Recall that the Rusanov flux is defined as
Fi+1/2=12[F(eL)+F(eR)]−12max|F′(e)|(eR−eL)Armed with the interpolated values of e at the cell boundaries we can generate a more accurate Rusanov flux. At cell boundary i+1/2, for example, this is:
Fi+1/2=12(F(eLi+1/2)+F(eRi+1/2)−ΔxΔt(eRi+1/2−eLi+1/2))Now we are ready to try some MUSCL!
def muscl(rho0, nt, dt, dx, bc_values, *args):
"""
Computes and returns the history of the traffic density
on the road using a MUSCL scheme with a minmod slope limiting
and a Lax-Friedrichs flux.
The function uses a second-order Runge-Kutta method
to integrate in time.
Parameters
----------
rho0 : numpy.ndarray
The initial traffic density along the road
as a 1D array of floats.
nt : integer
The number of time steps to compute.
dt : float
The time-step size to integrate.
dx : float
The distance between two consecutive locations.
bc_values : tuple or list
The value of the density at the first and last locations
as a tuple or list of two floats.
args : list
Positional arguments to be passed to the flux function.
Returns
-------
rho_hist : list of numpy.ndarray objects
The history of the car density along the road
as a list of 1D array of floats.
"""
def compute_flux(rho):
# Compute the minmod slope.
sigma = minmod(rho, dx)
# Reconstruct values at cell boundaries.
rhoL = (rho + sigma * dx / 2.0)[:-1]
rhoR = (rho - sigma * dx / 2.0)[1:]
# Compute the flux.
F = 0.5 * (flux(rhoL, *args) + flux(rhoR, *args) -
dx / dt * (rhoR - rhoL))
return F
rho_hist = [rho0.copy()]
rho = rho0.copy()
rho_star = rho.copy()
for n in range(nt):
# Compute the flux at cell boundaries.
F = compute_flux(rho)
# Perform 1st step of RK2.
rho_star[1:-1] = rho[1:-1] - dt / dx * (F[1:] - F[:-1])
# Apply boundary conditions.
rho_star[0], rho_star[-1] = bc_values
# Compute the flux at cell boundaries.
F = compute_flux(rho_star)
# Perform 2nd step of RK2.
rho[1:-1] = 0.5 * (rho[1:-1] + rho_star[1:-1] -
dt / dx * (F[1:] - F[:-1]))
# Apply boundary conditions.
rho[0], rho[-1] = bc_values
# Record the time-step solution.
rho_hist.append(rho.copy())
return rho_hist
# Set time-step size based on CFL limit.
sigma = 1.0
dt = sigma * dx / u_max # time-step size
# Compute the traffic density at all time steps.
rho_hist = muscl(rho0, nt, dt, dx, (rho0[0], rho0[-1]),
u_max, rho_max)
# Create an animation of the traffic density.
anim = animation.FuncAnimation(fig, update_plot,
frames=nt, fargs=(rho_hist,),
interval=100)
# Display the video.
HTML(anim.to_html5_video())
This MUSCL scheme does not show any of the oscillations you might see with MacCormack or Lax-Wendroff, but the features are not as sharp. Using the minmod slopes led to some smearing of the shock, which motivated many researchers to investigate other options. Bucketloads of so-called shock-capturing schemes exist and whole books are written on this topic. Some people dedicate their lives to developing numerical methods for hyperbolic equations!
Godunov, S.K. (1959), "A difference scheme for numerical computation of discontinuous solutions of equations of fluid dynamics," Math. Sbornik, Vol. 47, pp. 271–306.
van Leer, Bram (1979), "Towards the ultimate conservative difference scheme, V. A second-order sequel to Godunov's method," J. Comput. Phys., Vol. 32, pp. 101–136
van Leer, B., J.L. Thomas, P.L. Roe, R.W. Newsome (1987). "A comparison of numerical flux formulas for the Euler and Navier-Stokes equations," AIAA paper 87-1104 // PDF from umich.edu, checked 11/01/14.
Shu, Chi-Wang and Osher, Stanley (1988). "Efficient implementation of essentially non-oscillatory shock-capturing schemes," J. Comput. Phys., Vol. 77, pp. 439–471 // PDF from NASA Tech. Report server
from IPython.core.display import HTML
css_file = '../../styles/numericalmoocstyle.css'
HTML(open(css_file, 'r').read())