Sod, 1978, J.Comp.Phys. Vol 27: 1-31
C. Laney, 1998, Computational Gas Dynamics, Cambridge University Press
You can read more about the Sod Shock Tube on Wikipedia.
The shock tube is a common test for computational solvers and it has an analytical solution for comparison of results.
The shock tube problem set up is as follows. Imagine a tube divided by a massless, rigid membrane separating areas of different pressure and density. If the membrane is instantaneously removed, what is the reaction within the tube?
The conservative formulation of the Euler equations is required when solutions contain shocks. As you might expect, the Sod's Shock Tube solutions do contain shocks. The conserved variables $\underline{\mathbf{u}}$ are
where $e_T = e + \frac{u^2}{2}$ is the specific total energy.
The vector form of the Euler equations is:
$$\frac{\partial }{\partial t} \underline{\mathbf{u}} + \frac{\partial }{\partial t} \underline{\mathbf{f}} = 0$$where $\underline{\mathbf{f}}$ is the flux vector:
$$\underline{\mathbf{f}} = \left[ \begin{array}{c} \rho u \\ \rho u^2 + p \\ (\rho e_T + p) u \\ \end{array} \right] = \left[ \begin{array}{c} \rho u \\ \rho u^2 + p \\ \rho h_T u \\ \end{array} \right] $$and where $h_T$ is the total enthalpy:
$$h_T = e_T + \frac{p}{\rho} = h + \frac{u^2}{2}$$Using the Jacobian matrix, the Euler equations are written in quasi-linear form:
$$\frac{\partial}{\partial t} \underline{\mathbf{u}} + A \frac{\partial}{\partial x}\underline{\mathbf{u}} = 0$$where
The flux is given as:
$$\underline{\mathbf{F}} = \left[ \begin{array}{c} \rho u \\ \rho u^2 + p \\ (\rho e_T + p) u \\ \end{array} \right] = \left[ \begin{array}{c} f_1 \\ f_2 \\ f_3 \\ \end{array} \right]$$To calculate the Jacobian, $\underline{\mathbf{A}}$, we need $\underline{\mathbf{F}}$ in terms of $u_1$, $u_2$ and $u_3$.
Recalling that $\underline{\mathbf{u}} = \left[ \begin{array}{c} \rho \\ \rho u \\ \rho e_T \\ \end{array} \right] = \left[ \begin{array}{c} u_1 \\ u_2 \\ u_3 \\ \end{array} \right]$ we note that $f_1 = \rho u = u_2$
For $f_2$ and $f_3$, however, we must first represent the pressure $p$ in terms of conserved variables. We can relate the pressure to the conserved variables using an equation of state.
For an ideal gas, the equation of state is
$$e = e(\rho, p) = \frac{p}{(\gamma -1) \rho}, \gamma=1.4$$$$\therefore p = (\gamma -1)\rho e $$$$e_T = e+\frac{1}{2} u^2$$$$\therefore e = e_T - \frac{1}{2}u^2$$$$\therefore p = (\gamma -1)\left(\rho e_T - \frac{\rho u^2}{2}\right) = (\gamma -1)\left(u_3 - \frac{1}{2} \frac{u^2_2}{u_1} \right)$$Substituting for the pressure in terms of $\underline{\mathbf{u}}$ yields
Which can be simplied further to
Recalling that the Jacobian $\underline{\mathbf{A}}$ is of the form:
$$A(\underline{\mathbf{u}}) = \frac{\partial \underline{\mathbf{F}}}{\partial \underline{\mathbf{u}}} = \left[ \begin{array}{ccc} \frac{\partial f_1}{\partial u_1} & \frac{\partial f_1}{\partial u_2} & \frac{\partial f_1}{\partial u_3}\\ \frac{\partial f_2}{\partial u_1} & \frac{\partial f_2}{\partial u_2} & \frac{\partial f_2}{\partial u_3}\\ \frac{\partial f_3}{\partial u_1} & \frac{\partial f_3}{\partial u_2} & \frac{\partial f_3}{\partial u_3}\\ \end{array} \right]$$Calculate all partial derivatives to determine that the Jacobian matrix is
$$A(\underline{\mathbf{u}}) = \left[ \begin{array}{ccc} 0 & 1 & 0 \\ \frac{1}{2} (\gamma - 3) \left(\frac{u_2}{u_1}\right)^2 & (3- \gamma)\frac{u_2}{u_1} & \gamma - 1 \\ -\gamma \frac{u_2 u_3}{u_1^2} + (\gamma -1)\left(\frac{u_2}{u_1}\right)^3 & \gamma \frac{u_3}{u_1} - \frac{3}{2}(\gamma -1)\left(\frac{u_2}{u_1}\right)^2 & \gamma \frac{u_2}{u_1} \\ \end{array} \right]$$Expressing the Jacobian in terms of the velocity, $u$ and the speed of sound (for an ideal gas) $$a = \sqrt{\gamma \frac{p}{\rho}}$$ yields
$$A(\underline{\mathbf{u}}) = \left[ \begin{array}{ccc} 0 & 1 & 0 \\ \frac{1}{2} (\gamma - 3) u^2 & (3 - \gamma)u & \gamma - 1 \\ \frac{1}{2}(\gamma -2)u^3 - \frac{a^2 u}{\gamma -1} & \frac{3-2 \gamma}{2}u^2+\frac{a^2}{\gamma -1} & \gamma u\\ \end{array} \right]$$There's a lot to keep track of in the Sod tests, so it is a good idea to build several functions which can take care several common operations.
The initial conditions for both Sod tests are below.
Instead of writing all of this out for every test, we can create functions to "reset" all of our initial conditions.
We're also using a standard discretization throughout, so we can roll that into our reset functions.
Initial maximum wave speed = 374.17 m/s
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
def sodIC1():
nx = 50
dx = 20./50
CFL = 0.4
c_initial = 374.17
dt = CFL*(dx/c_initial)
x = np.linspace(-10,10,50)
nt = np.ceil(.01/dt)
#initial conditions
wl = np.array([1., 0, 100000.])
wr = np.array([0.125, 0, 10000.])
U = np.ones((3,nx))
U[:,:25] = build_U(wl[0],wl[1],wl[2])
U[:,25:] = build_U(wr[0],wr[1],wr[2])
return U, dx, dt, nx, x, int(nt)
def sodIC2():
nx = 50
dx = 25./50
CFL = 0.3
c_initial = 374.17
dt = CFL*(dx/c_initial)
x = np.linspace(-10,15,50)
nt = np.ceil(.01/dt)
#initial conditions
wl = np.array([1., 0, 100000.])
wr = np.array([0.01, 0, 1000.])
U = np.ones((3,nx))
U[:,:25] = build_U(wl[0],wl[1],wl[2])
U[:,25:] = build_U(wr[0],wr[1],wr[2])
return U, dx, dt, nx, x, int(nt)
Recall that
$$\underline{\mathbf{u}} = \left[ \begin{array}{c} \rho \\ \rho u \\ \rho e_T \\ \end{array} \right]$$where $e_T = e + \frac{u^2}{2}$ is the specific total energy and
$$e = e(\rho, p) = \frac{p}{(\gamma -1) \rho}, \gamma=1.4$$def build_U(rho, u, p):
gamma = 1.4
e = p / ((gamma-1)*rho)
e_T = e + u**2/2
U = np.array([[rho],[rho*u],[rho*e_T]])
return U
def build_flux(U_in):
'''Takes a 3x1 vector U and generates the flux vector for Conserved Euler Eqs'''
gamma = 1.4
u1, u2, u3 = U_in[0], U_in[1], U_in[2]
F = np.array([u2,\
u2**2/u1+(gamma-1)*(u3-u2**2/(2*u1)),\
u2/u1*(u3+(gamma-1)*(u3-u2**2/(2*u1)))])
return F
def build_jacobian(U):
rho = U[0,:]
u = U[1,:]/U[0,:]
gamma = 1.4
p = (gamma-1)*(U[2,:]-.5*U[1,:]**2/U[0,:])
c = np.sqrt(gamma*p/rho)
[row, col] = U.shape
A = np.zeros((row,row,col))
A[0,:,:] = np.zeros(col), np.ones(col), np.zeros(col)
A[1,:,:] = .5*(gamma-3)*u**2, (3-gamma)*u, gamma*np.ones(col)-1
A[2,:,:] = .5*(gamma-2)*u**3-c**2*u/(gamma-1), (3-2*gamma)/2*u**2 + c**2/(gamma-1)
return A
def laxfriedrichs(U, dx, dt, nx, nt):
UN = np.ones((3,nx))
for i in range(nt):
UN[:,1:-1] = .5*(U[:,2:]+U[:,:-2])-\
dt/(2*dx)*(build_flux(U[:,2:]) - build_flux(U[:,:-2]))
UN[:,0]=UN[:,1]
UN[:,-1]=UN[:,-2]
U[:]=UN[:]
return U
U, dx, dt, nx, x, nt = sodIC1()
U = laxfriedrichs(U, dx, dt, nx, nt)
We've solved for the vector $\underline{\mathbf{u}}$ using the conditions specified, but the goal is solve for pressure, velocity, sound speed, density, entropy and Mach number.
$\underline{\mathbf{u}}$ needs to be decomposed into those elements in order to compare results with the analytical solution.
def decompose_U(U_in):
'''Extract pressure, velocity, sound speed, density, entropy and Mach number from U'''
gamma = 1.4
vel = U_in[1,:]/U_in[0,:]
pres = (gamma - 1)*(U_in[2,:] - .5 * U_in[1,:]**2 / U_in[0,:])
rho = U_in[0,:]
c = np.sqrt(gamma * pres / rho)
S = pres/rho**gamma
return vel, pres, rho, c, S
vel, pres, rho, c, S = decompose_U(U)
def plot_shock_tube(vel, pres, rho, c, S, x):
plt.figure(figsize=(14,7))
plt.subplot(2,3,1)
plt.plot(x,vel)
plt.title('Velocity')
plt.subplot(2,3,2)
plt.plot(x,pres)
plt.title('Pressure')
plt.subplot(2,3,3)
plt.plot(x,rho)
plt.title('Density')
plt.subplot(2,3,4)
plt.plot(x,vel/c)
plt.title('Mach Number')
plt.subplot(2,3,5)
plt.plot(x,c)
plt.title('Speed of sound')
plt.subplot(2,3,6)
plt.plot(x,S)
plt.title('Entropy')
plot_shock_tube(vel, pres, rho, c, S, x)
U, dx, dt, nx, x, nt = sodIC2()
U = laxfriedrichs(U, dx, dt, nx, nt)
vel, pres, rho, c, S = decompose_U(U)
plot_shock_tube(vel, pres, rho, c, S, x)
2-step method
$$\underline{\mathbf{u}}^{n+\frac{1}{2}}_{i+\frac{1}{2}} = \frac{1}{2} \left( \underline{\mathbf{u}}^n_{i+1} + \underline{\mathbf{u}}^n_i \right) - \frac{\Delta t}{2 \Delta x} \left( \underline{\mathbf{f}}^n_{i+1} - \underline{\mathbf{f}}^n_i\right)$$$$\underline{\mathbf{u}}^{n+1}_i = \underline{\mathbf{u}}^n_i - \frac{\Delta t}{\Delta x} \left(\underline{\mathbf{f}}^{n+\frac{1}{2}}_{i+\frac{1}{2}} - \underline{\mathbf{f}}^{n+\frac{1}{2}}_{i-\frac{1}{2}} \right)$$def richtmyer(U, dx, dt, nx, nt, damp=0):
UN = np.ones((3,nx))
UN_plus = np.ones((3,nx))
UN_minus = np.ones((3,nx))
for i in range(nt):
UN_plus[:,:-1] = .5*(U[:,1:]+U[:,:-1]) -\
dt/(2*dx)*(build_flux(U[:,1:]) - build_flux(U[:,:-1]))
UN_minus[:,1:] = UN_plus[:,:-1]
UN[:,1:-1] = U[:,1:-1] - dt/dx *\
(build_flux(UN_plus[:,1:-1]) - build_flux(UN_minus[:,1:-1])) +\
damp * (U[:,2:] - 2*U[:,1:-1] + U[:,:-2])
UN[:,0] = UN[:,1]
UN[:,-1] = UN[:,-2]
U[:,:] = UN[:,:]
return U
Using the values for test 1, let's see how the Richtmyer scheme handles the shock tube.
U, dx, dt, nx, x, nt = sodIC1()
U = richtmyer(U, dx, dt, nx, nt)
vel, pres, rho, c, S = decompose_U(U)
plot_shock_tube(vel, pres, rho, c, S, x)
Some schemes are prone to overshoots, which produce the sharp spikes in the plots above. One way to smooth out these overshoots is to provide a damping term.
For the Richtmyer scheme, the damping term is added when the $\underline{\mathbf{u}}^{n+1}_i$ term is calculated.
The damping term is simply added on to the existing equation, with the level of damping controlled by a damping coefficient $\epsilon$.
The full damping term is defined as:
$$\epsilon (\underline{\mathbf{u}}^{n}_{i+1} - 2\underline{\mathbf{u}}^{n}_{i} + \underline{\mathbf{u}}^{n}_{i-1})$$You can see that this is implemented in the Richtmyer function above with the line
damp * (U[:,2:] - 2*U[:,1:-1] + U[:,:-2])
where damp
is another parameter passed to the function. damp
is given a default value of 0, so to add damping, simply pass a non-zero value of damp
to the same function.
Try experimenting with different values of $\epsilon$ and see what happens. Below, a value of $\epsilon = 0.1$ is used.
U, dx, dt, nx, x, nt = sodIC1()
U = richtmyer(U, dx, dt, nx, nt, damp=0.1)
vel, pres, rho, c, S = decompose_U(U)
plot_shock_tube(vel, pres, rho, c, S, x)
Look at the difference! Just a small damping value and all of the spikes smooth out nicely.
U, dx, dt, nx, x, nt = sodIC2()
U = richtmyer(U, dx, dt, nx, nt)
vel, pres, rho, c, S = decompose_U(U)
plot_shock_tube(vel, pres, rho, c, S, x)
As with the first set of conditions, there are some spikes formed by overshoots. Applying damping with $\epsilon = 0.125$, yields a similar smoothing effect.
U, dx, dt, nx, x, nt = sodIC2()
U = richtmyer(U, dx, dt, nx, nt, damp=0.125)
vel, pres, rho, c, S = decompose_U(U)
plot_shock_tube(vel, pres, rho, c, S, x)
This method is also called the 'predictor-corrector' method, where we predict the next value of $\underline{\mathbf{u}}$, $\underline{\mathbf{u}}^*_i$, and then update it.
$$\underline{\mathbf{u}}^*_i = \underline{\mathbf{u}}^n_i - \frac{\Delta t}{\Delta x} \left( \underline{\mathbf{f}}^n_{i+1} - \underline{\mathbf{f}}^n_i \right)$$$$\underline{\mathbf{u}}^{n+1}_i = \frac{1}{2}\left(\underline{\mathbf{u}}^n_i + \underline{\mathbf{u}}^*_i \right) - \frac{\Delta t}{2 \Delta x} \left(\underline{\mathbf{f}}^*_i - \underline{\mathbf{f}}^*_{i-1} \right) $$Damping can also be used to control overshoots in the MacCormack method. Unlike the Richtmyer damping, however, the damping used with the MacCormack method is applied to the 'predictor' step of the scheme. Again, the default value of damp
is 0, so the undamped behavior can also be observed.
def maccormack(U, dx, dt, nx, nt, damp=0):
UN = np.ones((3,nx))
UN_star = np.ones((3,nx))
BC = np.array([U[:,0], U[:,-1]])
for i in range(nt):
UN_star[:,1:-1] = U[:,1:-1] - dt/dx *\
(build_flux(U[:,2:]) - build_flux(U[:,1:-1])) +\
damp * (U[:,2:] - 2* U[:,1:-1] + U[:,:-2])
UN_star[:,0] = UN_star[:,1]
UN_star[:,-1] = UN_star[:,-2]
UN[:,1:-1] = .5 * (U[:,1:-1] + UN_star[:,1:-1]) - dt / (2*dx) *\
(build_flux(UN_star[:,1:-1]) - build_flux(UN_star[:,:-2]))
UN[:,0] = UN[:,1]
UN[:,-1] = UN[:,-2]
U[:,:] = UN[:,:]
return U
U, dx, dt, nx, x, nt = sodIC1()
U = maccormack(U, dx, dt, nx, nt, damp=0)
vel, pres, rho, c, S = decompose_U(U)
plot_shock_tube(vel, pres, rho, c, S, x)
-c:8: RuntimeWarning: invalid value encountered in sqrt -c:9: RuntimeWarning: invalid value encountered in power
What happened? If the overshoots are very large, the value of $\underline{\mathbf{u}}$ can become negative which will introduce some large errors into the calculation. It will also create some complex numbers when we try to calculate anything involving square roots.
Remember that the Sod tests are very restrictive, in the sense that the initial conditions we are using are very 'coarse.' There are a few ways to mitigate negative overshoots available to us.
Check and 'correct' negative velocity, density and pressure values at each time iteration.
Introduce damping and see if it can overcome the negative overshoots.
Cheat. Or rather, try out a third set of test values with a much finer mesh and see if the problem persists.
Cheat a different way. Instead of using a fixed timestep, introduce an adaptive timestep. Remember that we define out timestep nt
in terms of the initial sound speed. More on this later.
For now, let's try introducing some damping, with $\epsilon = .125$, and see what happens.
U, dx, dt, nx, x, nt = sodIC1()
U = maccormack(U, dx, dt, nx, nt, damp=0.125)
vel, pres, rho, c, S = decompose_U(U)
plot_shock_tube(vel, pres, rho, c, S, x)
That worked much better. How does Test 2 fare with damping added?
U, dx, dt, nx, x, nt = sodIC2()
U = maccormack(U, dx, dt, nx, nt, damp=0.125)
vel, pres, rho, c, S = decompose_U(U)
plot_shock_tube(vel, pres, rho, c, S, x)
What about the first 'cheat'? If we change the problem and give it a much finer mesh to work with, will the overshoots still cause trouble? Below is a third set of initial conditions, with the same physical parameters as test 2, but with nx = 400
.
def sodIC3():
nx = 400
dx = 25./nx
CFL = 0.3
c_initial = 374.17
dt = CFL*(dx/c_initial)
x = np.linspace(-10,15,nx)
nt = np.ceil(.01/dt)
#initial conditions
wl = np.array([1., 0, 100000.])
wr = np.array([0.01, 0, 1000.])
U = np.ones((3,nx))
U[:,:nx/2] = build_U(wl[0],wl[1],wl[2])
U[:,nx/2:] = build_U(wr[0],wr[1],wr[2])
return U, dx, dt, nx, x, int(nt)
U, dx, dt, nx, x, nt = sodIC3()
U = maccormack(U, dx, dt, nx, nt, damp=0)
vel, pres, rho, c, S = decompose_U(U)
plot_shock_tube(vel, pres, rho, c, S, x)
-c:5: RuntimeWarning: overflow encountered in square -c:5: RuntimeWarning: invalid value encountered in add -c:7: RuntimeWarning: invalid value encountered in subtract -c:12: RuntimeWarning: invalid value encountered in subtract -c:7: RuntimeWarning: invalid value encountered in multiply
Even with a much finer mesh, the overshoots have apparently caused some trouble. But maybe we can get away with a much smaller damping coefficient?
U, dx, dt, nx, x, nt = sodIC3()
U = maccormack(U, dx, dt, nx, nt, damp=0.05)
vel, pres, rho, c, S = decompose_U(U)
plot_shock_tube(vel, pres, rho, c, S, x)
The cell below executes the style of this notebook.
from IPython.core.display import HTML
def css_styling():
styles = open("../styles/custom.css", "r").read()
return HTML(styles)
css_styling()