Text provided under a Creative Commons Attribution license, CC-BY.  All code is made available under the FSF-approved BSD-3 license.  (c) Lorena A. Barba, Gilbert F. Forsyth 2017. Thanks to NSF for support via CAREER award #1149784.
[@LorenaABarba](https://twitter.com/LorenaABarba)

12 steps to Navier-Stokes
=====
***

We continue our journey to solve the Navier-Stokes equation with Step 4. But don't continue unless you have completed the previous steps! In fact, this next step will be a combination of the two previous ones. The wonders of *code reuse*!

Step 4: Burgers' Equation
----
***

You can read about Burgers' Equation on its [wikipedia page](http://en.wikipedia.org/wiki/Burgers'_equation).

Burgers' equation in one spatial dimension looks like this:

$$\frac{\partial u}{\partial t} + u \frac{\partial u}{\partial x} = \nu \frac{\partial ^2u}{\partial x^2}$$

As you can see, it is a combination of non-linear convection and diffusion. It is surprising how much you learn from this neat little equation!

We can discretize it using the methods we've already detailed in Steps [1](./01_Step_1.ipynb) to [3](./04_Step_3.ipynb).  Using forward difference for time, backward difference for space and our 2nd-order method for the second derivatives yields:

$$\frac{u_i^{n+1}-u_i^n}{\Delta t} + u_i^n \frac{u_i^n - u_{i-1}^n}{\Delta x} = \nu \frac{u_{i+1}^n - 2u_i^n + u_{i-1}^n}{\Delta x^2}$$

As before, once we have an initial condition, the only unknown is $u_i^{n+1}$. We will step in time as follows:

$$u_i^{n+1} = u_i^n - u_i^n \frac{\Delta t}{\Delta x} (u_i^n - u_{i-1}^n) + \nu \frac{\Delta t}{\Delta x^2}(u_{i+1}^n - 2u_i^n + u_{i-1}^n)$$

### Initial and Boundary Conditions

To examine some interesting properties of Burgers' equation, it is helpful to use different initial and boundary conditions than we've been using for previous steps.

Our initial condition for this problem is going to be:

\begin{eqnarray}
u &=& -\frac{2 \nu}{\phi} \frac{\partial \phi}{\partial x} + 4 \\\
\phi &=& \exp \bigg(\frac{-x^2}{4 \nu} \bigg) + \exp \bigg(\frac{-(x-2 \pi)^2}{4 \nu} \bigg)
\end{eqnarray}

This has an analytical solution, given by:

\begin{eqnarray}
u &=& -\frac{2 \nu}{\phi} \frac{\partial \phi}{\partial x} + 4 \\\
\phi &=& \exp \bigg(\frac{-(x-4t)^2}{4 \nu (t+1)} \bigg) + \exp \bigg(\frac{-(x-4t -2 \pi)^2}{4 \nu(t+1)} \bigg)
\end{eqnarray}

Our boundary condition will be:

$$u(0) = u(2\pi)$$

This is called a *periodic* boundary condition. Pay attention! This will cause you a bit of headache if you don't tread carefully.

### Saving Time with SymPy

The initial condition we're using for Burgers' Equation can be a bit of a pain to evaluate by hand.  The derivative $\frac{\partial \phi}{\partial x}$ isn't too terribly difficult, but it would be easy to drop a sign or forget a factor of $x$ somewhere, so we're going to use SymPy to help us out.

[SymPy](http://sympy.org/en/) is the symbolic math library for Python.  It has a lot of the same symbolic math functionality as Mathematica with the added benefit that we can easily translate its results back into our Python calculations (it is also free and open source).

Start by loading the SymPy library, together with our favorite library, NumPy.

```python
import numpy
import sympy
```

We're also going to tell SymPy that we want all of its output to be rendered using $\LaTeX$. This will make our Notebook beautiful!

```python
from sympy import init_printing
init_printing(use_latex=True)
```

Start by setting up symbolic variables for the three variables in our initial condition and then type out the full equation for $\phi$.  We should get a nicely rendered version of our $\phi$ equation.

```python
x, nu, t = sympy.symbols('x nu t')
phi = (sympy.exp(-(x - 4 * t)**2 / (4 * nu * (t + 1))) +
       sympy.exp(-(x - 4 * t - 2 * numpy.pi)**2 / (4 * nu * (t + 1))))
phi
```

It's maybe a little small, but that looks right.  Now to evaluate our partial derivative $\frac{\partial \phi}{\partial x}$ is a trivial task.

```python
phiprime = phi.diff(x)
phiprime
```

If you want to see the unrendered version, just use the Python print command.

```python
print(phiprime)
```

    -(-8*t + 2*x)*exp(-(-4*t + x)**2/(4*nu*(t + 1)))/(4*nu*(t + 1)) - (-8*t + 2*x - 12.5663706143592)*exp(-(-4*t + x - 6.28318530717959)**2/(4*nu*(t + 1)))/(4*nu*(t + 1))

### Now what?

Now that we have the Pythonic version of our derivative, we can finish writing out the full initial condition equation and then translate it into a usable Python expression.  For this, we'll use the *lambdify* function, which takes a SymPy symbolic equation and turns it into a callable function.

```python
from sympy.utilities.lambdify import lambdify

u = -2 * nu * (phiprime / phi) + 4
print(u)
```

    -2*nu*(-(-8*t + 2*x)*exp(-(-4*t + x)**2/(4*nu*(t + 1)))/(4*nu*(t + 1)) - (-8*t + 2*x - 12.5663706143592)*exp(-(-4*t + x - 6.28318530717959)**2/(4*nu*(t + 1)))/(4*nu*(t + 1)))/(exp(-(-4*t + x - 6.28318530717959)**2/(4*nu*(t + 1))) + exp(-(-4*t + x)**2/(4*nu*(t + 1)))) + 4

### Lambdify

To lambdify this expression into a useable function, we tell lambdify which variables to request and the function we want to plug them in to.

```python
ufunc = lambdify((t, x, nu), u)
print(ufunc(1, 4, 3))
```

    3.49170664206

### Back to Burgers' Equation

Now that we have the initial conditions set up, we can proceed and finish setting up the problem.  We can generate the plot of the initial condition using our lambdify-ed function.

```python
from matplotlib import pyplot
%matplotlib inline

###variable declarations
nx = 301
nt = 100
dx = 2.0 * numpy.pi / (nx - 1)
nu = .07
dt = dx * nu

x = numpy.linspace(0, 2 * numpy.pi, nx)
un = numpy.empty(nx)
t = 0

u = numpy.asarray([ufunc(t, x0, nu) for x0 in x])
u
```

    array([ 4.        ,  4.02094395,  4.0418879 ,  4.06283185,  4.0837758 ,
            4.10471976,  4.12566371,  4.14660766,  4.16755161,  4.18849556,
            4.20943951,  4.23038346,  4.25132741,  4.27227136,  4.29321531,
            4.31415927,  4.33510322,  4.35604717,  4.37699112,  4.39793507,
            4.41887902,  4.43982297,  4.46076692,  4.48171087,  4.50265482,
            4.52359878,  4.54454273,  4.56548668,  4.58643063,  4.60737458,
            4.62831853,  4.64926248,  4.67020643,  4.69115038,  4.71209433,
            4.73303829,  4.75398224,  4.77492619,  4.79587014,  4.81681409,
            4.83775804,  4.85870199,  4.87964594,  4.90058989,  4.92153385,
            4.9424778 ,  4.96342175,  4.9843657 ,  5.00530965,  5.0262536 ,
            5.04719755,  5.0681415 ,  5.08908545,  5.1100294 ,  5.13097336,
            5.15191731,  5.17286126,  5.19380521,  5.21474916,  5.23569311,
            5.25663706,  5.27758101,  5.29852496,  5.31946891,  5.34041287,
            5.36135682,  5.38230077,  5.40324472,  5.42418867,  5.44513262,
            5.46607657,  5.48702052,  5.50796447,  5.52890842,  5.54985238,
            5.57079633,  5.59174028,  5.61268423,  5.63362818,  5.65457213,
            5.67551608,  5.69646003,  5.71740398,  5.73834793,  5.75929189,
            5.78023584,  5.80117979,  5.82212374,  5.84306769,  5.86401164,
            5.88495559,  5.90589954,  5.92684349,  5.94778745,  5.9687314 ,
            5.98967535,  6.0106193 ,  6.03156325,  6.0525072 ,  6.07345115,
            6.0943951 ,  6.11533905,  6.136283  ,  6.15722696,  6.17817091,
            6.19911486,  6.22005881,  6.24100276,  6.26194671,  6.28289066,
            6.30383461,  6.32477856,  6.34572251,  6.36666647,  6.38761042,
            6.40855437,  6.42949832,  6.45044227,  6.47138622,  6.49233017,
            6.51327412,  6.53421807,  6.55516202,  6.57610598,  6.59704993,
            6.61799388,  6.63893783,  6.65988178,  6.68082572,  6.70176967,
            6.72271359,  6.74365747,  6.76460125,  6.78554476,  6.80648759,
            6.82742866,  6.84836523,  6.86929029,  6.89018589,  6.91100608,
            6.93163322,  6.95176632,  6.97063555,  6.98627467,  6.99367964,
            6.9802301 ,  6.91482855,  6.72527549,  6.26782654,  5.35565198,
            4.        ,  2.64434802,  1.73217346,  1.27472451,  1.08517145,
            1.0197699 ,  1.00632036,  1.01372533,  1.02936445,  1.04823368,
            1.06836678,  1.08899392,  1.10981411,  1.13070971,  1.15163477,
            1.17257134,  1.19351241,  1.21445524,  1.23539875,  1.25634253,
            1.27728641,  1.29823033,  1.31917428,  1.34011822,  1.36106217,
            1.38200612,  1.40295007,  1.42389402,  1.44483798,  1.46578193,
            1.48672588,  1.50766983,  1.52861378,  1.54955773,  1.57050168,
            1.59144563,  1.61238958,  1.63333353,  1.65427749,  1.67522144,
            1.69616539,  1.71710934,  1.73805329,  1.75899724,  1.77994119,
            1.80088514,  1.82182909,  1.84277304,  1.863717  ,  1.88466095,
            1.9056049 ,  1.92654885,  1.9474928 ,  1.96843675,  1.9893807 ,
            2.01032465,  2.0312686 ,  2.05221255,  2.07315651,  2.09410046,
            2.11504441,  2.13598836,  2.15693231,  2.17787626,  2.19882021,
            2.21976416,  2.24070811,  2.26165207,  2.28259602,  2.30353997,
            2.32448392,  2.34542787,  2.36637182,  2.38731577,  2.40825972,
            2.42920367,  2.45014762,  2.47109158,  2.49203553,  2.51297948,
            2.53392343,  2.55486738,  2.57581133,  2.59675528,  2.61769923,
            2.63864318,  2.65958713,  2.68053109,  2.70147504,  2.72241899,
            2.74336294,  2.76430689,  2.78525084,  2.80619479,  2.82713874,
            2.84808269,  2.86902664,  2.8899706 ,  2.91091455,  2.9318585 ,
            2.95280245,  2.9737464 ,  2.99469035,  3.0156343 ,  3.03657825,
            3.0575222 ,  3.07846615,  3.09941011,  3.12035406,  3.14129801,
            3.16224196,  3.18318591,  3.20412986,  3.22507381,  3.24601776,
            3.26696171,  3.28790567,  3.30884962,  3.32979357,  3.35073752,
            3.37168147,  3.39262542,  3.41356937,  3.43451332,  3.45545727,
            3.47640122,  3.49734518,  3.51828913,  3.53923308,  3.56017703,
            3.58112098,  3.60206493,  3.62300888,  3.64395283,  3.66489678,
            3.68584073,  3.70678469,  3.72772864,  3.74867259,  3.76961654,
            3.79056049,  3.81150444,  3.83244839,  3.85339234,  3.87433629,
            3.89528024,  3.9162242 ,  3.93716815,  3.9581121 ,  3.97905605,  4.        ])

```python
pyplot.figure(figsize=(11, 7), dpi=100)
pyplot.plot(x, u, marker='o', lw=2)
pyplot.xlim([0, 2 * numpy.pi])
pyplot.ylim([0, 10]);
```

This is definitely not the hat function we've been dealing with until now. We call it a "saw-tooth function".  Let's proceed forward and see what happens.

### Periodic Boundary Conditions

One of the big differences between Step 4 and the previous lessons is the use of *periodic* boundary conditions.  If you experiment with Steps 1 and 2 and make the simulation run longer (by increasing `nt`) you will notice that the wave will keep moving to the right until it no longer even shows up in the plot.

With periodic boundary conditions, when a point gets to the right-hand side of the frame, it *wraps around* back to the front of the frame.

Recall the discretization that we worked out at the beginning of this notebook:

$$u_i^{n+1} = u_i^n - u_i^n \frac{\Delta t}{\Delta x} (u_i^n - u_{i-1}^n) + \nu \frac{\Delta t}{\Delta x^2}(u_{i+1}^n - 2u_i^n + u_{i-1}^n)$$

What does $u_{i+1}^n$ *mean* when $i$ is already at the end of the frame?

Think about this for a minute before proceeding.

```python
for n in range(nt): # looping over time
    un = u.copy()   # creating a copy to use for the iteration
    for i in range(1, nx-1): # looping over space
        u[i] = un[i] - un[i] * dt / dx *(un[i] - un[i-1]) + nu * dt / dx**2 *\
                (un[i+1] - 2 * un[i] + un[i-1]) #looping over all of the internal nodes
    u[0] = un[0] - un[0] * dt / dx * (un[0] - un[-2]) + nu * dt / dx**2 *\
                (un[1] - 2 * un[0] + un[-2]) #setting the value for the first node
    u[-1] = u[0] #setting the value for the last not

u_analytical = numpy.asarray([ufunc(nt * dt, xi, nu) for xi in x]) #calculating the analytical solution
```

```python
pyplot.figure(figsize=(11, 7), dpi=100)
pyplot.plot(x,u, marker='o', lw=2, label='Computational')
pyplot.plot(x, u_analytical, label='Analytical')
pyplot.xlim([0, 2 * numpy.pi])
pyplot.ylim([0, 10])
pyplot.legend();
```

***

What next?
----

The subsequent steps, from 5 to 12, will be in two dimensions. But it is easy to extend the 1D finite-difference formulas to the partial derivatives in 2D or 3D. Just apply the definition — a partial derivative with respect to $x$ is the variation in the $x$ direction *while keeping $y$ constant*.

Before moving on to [Step 5](./07_Step_5.ipynb), make sure you have completed your own code for steps 1 through 4 and you have experimented with the parameters and thought about what is happening. Also, we recommend that you take a slight break to learn about [array operations with NumPy](./06_Array_Operations_with_NumPy.ipynb).

```python
from IPython.core.display import HTML
def css_styling():
    styles = open("../styles/custom.css", "r").read()
    return HTML(styles)
css_styling()
```

<link href='http://fonts.googleapis.com/css?family=Fenix' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Alegreya+Sans:100,300,400,500,700,800,900,100italic,300italic,400italic,500italic,700italic,800italic,900italic' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Source+Code+Pro:300,400' rel='stylesheet' type='text/css'>
<style>
    @font-face {
        font-family: "Computer Modern";
        src: url('http://mirrors.ctan.org/fonts/cm-unicode/fonts/otf/cmunss.otf');
    }
    div.cell{
        width:800px;
        margin-left:16% !important;
        margin-right:auto;
    }
    h1 {
        font-family: 'Alegreya Sans', sans-serif;
    }
    h2 {
        font-family: 'Fenix', serif;
    }
    h3{
		font-family: 'Fenix', serif;
        margin-top:12px;
        margin-bottom: 3px;
       }
	h4{
		font-family: 'Fenix', serif;
       }
    h5 {
        font-family: 'Alegreya Sans', sans-serif;
    }
    div.text_cell_render{
        font-family: 'Alegreya Sans',Computer Modern, "Helvetica Neue", Arial, Helvetica, Geneva, sans-serif;
        line-height: 135%;
        font-size: 120%;
        width:600px;
        margin-left:auto;
        margin-right:auto;
    }
    .CodeMirror{
            font-family: "Source Code Pro";
			font-size: 90%;
    }
/*    .prompt{
        display: None;
    }*/
    .text_cell_render h1 {
        font-weight: 200;
        font-size: 50pt;
		line-height: 100%;
        color:#CD2305;
        margin-bottom: 0.5em;
        margin-top: 0.5em;
        display: block;
    }
    .text_cell_render h5 {
        font-weight: 300;
        font-size: 16pt;
        color: #CD2305;
        font-style: italic;
        margin-bottom: .5em;
        margin-top: 0.5em;
        display: block;
    }

    .warning{
        color: rgb( 240, 20, 20 )
        }
</style>

```python

```


### Prolegomena

We need

- a short list of `sympy` functions,
- the ability to pretty-print `sympy`'s objects,
- some additional display functions from `IPython`.
- some names for some symbols to be used in symbolic expressions.

```
from sympy import symbols, Rational as rat, Eq, latex
%load_ext sympyprinting
from IPython.core.display import Image, display, Math, Latex
m, k, p, L, u, a, delta = symbols('m k p_0 L u_G a_G delta')
d = delta*u
```

# Rigid Bodies

```
Image(filename='figures/rigid.png')
```

### Centres of Instantaneous Rotation

The CIR of left body, $\Omega_1$, is at the intersection of an horizontal line in A and a vertical line
in C, hence the coordinates of $\Omega_1$ are $(L,L)$ if we put the origin in B.

The CIR of the right body, $\Omega_2$, is at the intersection of an horizontal line in G and a $135^o$ line
going from D, internal hinge, to $\Omega_1$, hence the coordinates of $\Omega_2$ are $(0,2L)$.

The rotation $\theta_2$ is given in terms of $u_G$ by $\theta_2\\,3L=u_G$, the rotation $\theta_1$ is
$\theta_1=2\theta_2$.

### Objects

In our problem, we have two type of objects, 1) the rotation of a rigid body characterized by the coordinates
of the centre of rotation and the angle of rotation and 2) the significant points on a rigid body,
characterized by the rotation, the attached mass, the attached spring etc.

We define two simple classes that model the behaviour of our objects.

```
class Centre:
    def __init__(self, x, y, rot):
        self.x = x ; self.y = y ; self.r = rot

class Point:
    def __init__(self, x, y, centre, mass=0, kx=0, ky=0, px=0, py=0, length=0):
        if kx*ky: raise NameError('Only one stiffness can be different from zero.')
        j_r = mass*length**2/12
        ux = -(y-centre.y)*centre.r
        uy = +(x-centre.x)*centre.r
        self.mvw = -a*d*(mass*(ux*ux+uy*uy)+j_r*centre.r**2)
        self.kvw = -u*d*(kx*ux**2+ky*uy**2)
        self.pvw = +d*(px*ux+py*uy)
```

#### Specifying the centres of rotation

The centres of rotation and the rotations, for the left and the right rigid body, are
defined as follows (the origin of coordinates is in B, note that the rotations are divided
by the unit displacement):

```
w1 = Centre( L, L,   rat(2,3)/L)
w2 = Centre( 0, 2*L, rat(1,3)/L)
```

#### Specifying the relevant points of the rigid bodies

The significant points are A (external force), B (point mass), D (spring),
F (both spring and center of mass of the vertical segment of DEFG bar) and
GDE (centre of mass of the horizontal segment).

A Point object is initialized for each significant point, with the relevant
quantities explicited.

```
A = Point( 0, L, w1, py=-p)
B = Point( 0, 0, w1, mass=3*m)
D = Point( 2*L, 0, w1, ky=k)
F = Point( 3*L, L, w2, kx=k, mass=2*m, length=2*L)
GDE = Point( rat(5,2)*L, 0, w2, mass=m, length=L)
```

### Computing the work of different forces for a virtual displacement

We need the sum of virtual works for the different types of forces in the equation of the virtual
displacements, here it is an handy function to do it...

```
def sum_vw(which):
    return sum([getattr(point,which) for point in (A,B,D,F,GDE)])
```

We use our handy function to compute the individual contributions to the virtual work.

```
vw_inertial_f = sum_vw('mvw')
display(Math(r'\delta W_I = '+latex(vw_inertial_f/d)+'\\,'+latex(d)+','))
vw_elastic_f  = sum_vw('kvw')
display(Math(r'\delta W_S = '+latex(vw_elastic_f/d)+'\\,'+latex(d)+','))
vw_external_f = sum_vw('pvw')
display(Math(r'\delta W_E = '+latex(vw_external_f/d)+'\\,'+latex(d)+'.'))
```

$$\delta W_I = - \frac{55}{9} a_{G} m\,\delta u_{G},$$

$$\delta W_S = - \frac{5}{9} k u_{G}\,\delta u_{G},$$

$$\delta W_E = \frac{2}{3} p_{0}\,\delta u_{G}.$$

### The equation of dynamic equilibrium

The sum of the individual contribution to the virtual work, i.e., the total virtual work, is zero for a rigid body in equilibrium or, equivalently,

<center>$-\delta W_I -\delta W_S = \delta W_E$</center>

Simplyfing $\delta u_g$ we have

```
display(Eq( -vw_inertial_f/d - vw_elastic_f/d, vw_external_f/d))
```

#### The generalized parameters

```
display(Latex(r'The generalized mass is $m^* = %s$, '%latex(-vw_inertial_f/d/a) +
              r'the generalized stiffness is $k^* = %s$, '%latex(-vw_elastic_f/d/u) +
              r'the generalized load is $p^* = %s\,\mathrm{f}(t)$.'%latex(vw_external_f/d)))
```

The generalized mass is $m^* = \frac{55}{9} m$, the generalized stiffness is $k^* = \frac{5}{9} k$, the generalized load is $p^* = \frac{2}{3} p_{0}\,\mathrm{f}(t)$.


# 1. Creating formulas
Write the following mathematical formula in Python:

\begin{align}
 result = 6a^3 - \frac{8b^2 }{4c} + 11
\end{align}

```python
a = 2
b = 3
c = 2
```

```python
# Your formula here:
result = (6*(a**3)) - ((8*(b**2))/(4*c)) + 11
```

```python

assert result == 50
```

# 2. Floating point pitfalls
Show that `0.1 + 0.2 == 0.3`

```python
# Your solution here
from decimal import Decimal
str1 = Decimal("0.1")
str2 = Decimal("0.2")
print(str1 + str2)
# This won't work:
#assert 0.1 + 0.2 == 0.3
```


# PHYS 5640 - Project 4
## Steven Stetzler

```python
# Handy packages
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import rcParams
```

```python
# Plotting parameters
rcParams['figure.figsize'] = [12, 12]
rcParams['font.size'] = 24
rcParams['axes.labelsize'] = 28
rcParams['axes.labelpad'] = 15
```

# 1. Finite-difference method for advection equation

We wish to numerically solve the advection equation given by
\begin{equation}
\tag{1}
\frac{\partial u}{\partial t} + v \frac{\partial u}{\partial x} = 0
\end{equation}

Using the Lax finite-difference scheme, this PDE can be solved numerically on a discretized grid in time and space, with grid spacings in time of $\Delta t$ and grid spacings in space of $\Delta x$. First we start with a Forward-Time Central-Space (FTCS) scheme:

$$
\frac{\partial u}{\partial t} = \frac{u_j^{n+1} - u_j^n}{\Delta t}, \qquad \frac{\partial u}{\partial x} = \frac{u_{j+1}^n - u_{j-1}^n}{2\Delta x}
$$

and we replace $u_j^{n}$ with the average of the neighbors of $u_j^{n}$:

$$
\frac{\partial u}{\partial t} = \frac{u_j^{n+1} - \frac{1}{2}\left( u_{j+1}^n + u_{j-1}^n \right)}{\Delta t}, \qquad \frac{\partial u}{\partial x} = \frac{u_{j+1}^n - u_{j-1}^n}{2\Delta x}
$$

which allows us to solve the advection equation by substituting these discrete approximations with the derivatives in Eq. 1:

$$
\frac{\partial u}{\partial t} + v \frac{\partial u}{\partial x} = 0\\
\frac{u_j^{n+1} - \frac{1}{2}\left( u_{j+1}^n + u_{j-1}^n \right)}{\Delta t} + v \frac{u_{j+1}^n - u_{j-1}^n}{2\Delta x} = 0
$$
\begin{equation}
\tag{2}
u_{j}^{n+1} = \frac{1}{2}\left( u_{j+1}^n + u_{j-1}^n \right) - \frac{v\Delta t}{2 \Delta x} \left(u_{j+1}^n - u_{j-1}^n \right)
\end{equation}

We can perform a Von Neumann stability analysis to obtain good parameters for the grid spacing $\Delta x$ and $\Delta t$ of our simulation. We start by consdering solutions of the form

$$
u_j^n \sim e^{ik x_j}
$$

where $x_j = x_0 + j \Delta x$ and $k$ is the wavenumber of this solution. In amplifying the solution, the Lax method will produce a new value at every time step that is some multiple of the old value:

$$
u_j^{n+1} = \xi_k u_j^n = \xi_k e^{ik x_j}
$$

We can simply plug in the above to Eq. 2 to obtain $\xi_k$:

$$
\xi_k e^{ik x_j} = \frac{1}{2}\left( e^{ik(x_j + \Delta x)} + e^{ik(x_j - \Delta x)} \right) - \frac{v\Delta t}{2 \Delta x} \left(e^{ik(x_j + \Delta x)} - e^{ik(x_j - \Delta x)} \right) \\
\xi_k = \frac{1}{2}\left( e^{ik\Delta x} + e^{-ik\Delta x} \right) - \frac{v\Delta t}{2 \Delta x} \left(e^{-ik\Delta x} - e^{ik\Delta x} \right)
$$
\begin{equation}
\label{eqn:discrete_amplification}
\xi_k = \cos(k \Delta x) - i \frac{v \Delta t}{\Delta x} \sin(k \Delta x)
\end{equation}

The solutions to the Lax method are stable if $\xi_k$ satisfies the condition $\lvert \xi_k \rvert < 1$:

$$
\lvert \xi_k \rvert = \sqrt{ \cos^2(k \Delta x) + \left(\frac{v \Delta t}{\Delta x}\right)^2 \sin^2(k \Delta x) }
$$

Since we know that $\cos^2(x) + \sin^2(x) = 1$ we can infer the following stability conditions:

$$
\lvert \xi_k \rvert = \sqrt{ \cos^2(k \Delta x) + \left(\frac{v \Delta t}{\Delta x}\right)^2 \sin^2(k \Delta x) } \\
\rightarrow \lvert \xi_k \rvert = 1, \text{   if } \frac{v \Delta t}{\Delta x} = 1 \\
\rightarrow \lvert \xi_k \rvert < 1, \text{   if } \frac{v \Delta t}{\Delta x} < 1 \\
\rightarrow \lvert \xi_k \rvert > 1, \text{   if } \frac{v \Delta t}{\Delta x} > 1 \\
$$

We can also compute the exact amplification factor produced by the advection equation. First, we assume plane wave solutions of the form

$$
u(x, t) = A(t) e^{ikx}
$$

we then plug this solution into Eq. 1 to obtain:

$$
\frac{\partial (A(t) e^{ikx})}{\partial t} + v \frac{\partial (A(t) e^{ikx})}{\partial x } = 0 \\
\dot{A}(t) e^{ikx} = -ikv A e^{ikx} \\
\dot{A}(t) = -ikv A
$$

Solving this differential equation we obtain

$$
A(t) = A(t=0) e^{-ikvt} \\
\rightarrow u(x, t) = A(t=0) e^{-ikvt} e^{ikx}
$$

The exact amplification factor is then computed as

$$
\xi_k = \frac{u(x, t + dt) }{u(x, t)} = \frac{A(t = 0) e^{-ikv (t + dt)} e^{ikx} }{A(t = 0) e^{-ikvt}e^{ikx}} \\
\rightarrow \xi_k = e^{-ikv dt}
$$

We can re-express $\xi_k$ using Euler's formula as

\begin{equation}
\tag{3}
\xi_k = \cos(kv dt) - i \sin(kv dt)
\end{equation}

$$
\lvert \xi_k \rvert = \sqrt{\cos^2(kv dt) + \sin^2(kv dt)} = 1
$$

We then see that the norm of the amplification factor of the Lax method (Eq. 3) is exactly equal to the norm of the exact amplification factor (Eq. 4) only when the term $v \Delta t / \Delta x = 1$. For any other value of this term, the discrete approximate of Eq. 1 will not provide correct results.

In the following code, we simulate a system evolved using the advection equation and the Lax method with the initial condition $u(x, t = 0) = \exp(-x^2/w_2)$ which is simply a Gaussian. The spatial gridding for each simulation is $\Delta x = 0.1$ and the parameter $v = 1$.

```python
# Apply the update rule over the whole grid in time
# Uses Eq. (2) to find the values of the next point in time of the grid
def update_advection(grid, delta_x, delta_t, v):
    xs, times = grid.shape

    for t in range(times - 1):
        right = np.roll(grid[:, t], -1, axis=0)
        left = np.roll(grid[:, t], 1, axis=0)

        # Eq. (2)
        grid[:, t + 1] = 0.5 * (right + left) - (0.5 * v * delta_t / delta_x) * (right - left)
        grid[0, :] = 0
        grid[xs - 1, :] = 0

# Initialize a grid with initial conditions of a gaussian and evolve the system
# with the advection equation with v = 1
def advection(min_x, max_x, delta_x, min_t, max_t, delta_t, v=1, w=5):
    range_x = max_x - min_x
    range_t = max_t - min_t

    num_x, num_t = int(range_x / delta_x), int(range_t / delta_t)
    grid = np.zeros((num_x, num_t))
    w = 5
    grid[:, 0] = np.exp(-np.linspace(min_x, max_x, int(range_x / delta_x))**2 / w**2)
    update_advection(grid, delta_x, delta_t, v)
    return grid
```

We simulate the system over a range of x values $x \in \left[-20, 50 \right]$ with a spatial gridding of $\Delta x = 0.1$ and over a range of time $t \in \left[ 0, 25 \right]$ with a temporal gridding of $\Delta t = 0.06$.

```python
min_x, max_x, delta_x, min_t, max_t, delta_t = -20, 50, 0.1, 0, 25, 0.06

locations = np.linspace(min_x, max_x, int((max_x - min_x) / delta_x))
times = np.linspace(min_t, max_t, int((max_t - min_t) / delta_t))

grid = advection(min_x, max_x, delta_x, min_t, max_t, delta_t)
```

```python
plt.plot(locations, grid[:, int(0 / delta_t)], label=r'$t$ = 0')
plt.plot(locations, grid[:, int(5 / delta_t)], label='  = 5')
plt.plot(locations, grid[:, int(10 / delta_t)], label='  = 10')
plt.plot(locations, grid[:, int(15 / delta_t)], label='  = 15')
plt.plot(locations, grid[:, int(20 / delta_t)], label='  = 20')

plt.xlabel(r"$x$")
plt.ylabel(r"$u$", rotation=0)

plt.legend()
plt.show()
```

We notice that, while the solution is stable over time, the amplitude of the wave decreases in time. This is because the amplification factor $\xi_k < 1$ since the quantity $v \Delta t / \Delta x = 0.6 < 1$. Even though these solutions are stable, they are incorrect since the amplification factor is not identically equal to 1.

Next, we run the simulation again with the same initial conditions, spatial gridding, and space and time ranges but with $\Delta t = 0.1$ instead.

```python
min_x, max_x, delta_x, min_t, max_t, delta_t = -20, 50, 0.1, 0, 25, 0.10

locations = np.linspace(min_x, max_x, int((max_x - min_x) / delta_x))
times = np.linspace(min_t, max_t, int((max_t - min_t) / delta_t))

grid = advection(min_x, max_x, delta_x, min_t, max_t, delta_t)
```

```python
plt.plot(locations, grid[:, int(0 / delta_t)], label=r'$t$ = 0')
plt.plot(locations, grid[:, int(5 / delta_t)], label='  = 5')
plt.plot(locations, grid[:, int(10 / delta_t)], label='  = 10')
plt.plot(locations, grid[:, int(15 / delta_t)], label='  = 15')
plt.plot(locations, grid[:, int(20 / delta_t)], label='  = 20')

plt.xlabel(r"$x$")
plt.ylabel(r"$u$", rotation=0)

plt.legend()
plt.show()
```

With $\Delta t = 0.1$ we find that the solution is as expected: a wave travelling to the right without decay. The decy previously observed has vanished because the amplification factor $\xi_k = 1$ since the quantity $v\Delta t / \Delta x = 1$.

Next, we will run the simulation again with the same spatial gridding and space and time ranges, but with $\Delta t = 0.14$.

```python
min_x, max_x, delta_x, min_t, max_t, delta_t = -20, 50, 0.1, 0, 25, 0.14

locations = np.linspace(min_x, max_x, int((max_x - min_x) / delta_x))
times = np.linspace(min_t, max_t, int((max_t - min_t) / delta_t))

grid = advection(min_x, max_x, delta_x, min_t, max_t, delta_t)
```

```python
plt.plot(locations, grid[:, int(0 / delta_t)], label=r'$t$ = 0')
plt.plot(locations, grid[:, int(5 / delta_t)], label='  = 5')
plt.plot(locations, grid[:, int(6 / delta_t)], label='  = 6')
plt.plot(locations, grid[:, int(7.2 / delta_t)], label='  = 7.2')

plt.ylim(-0.2, 1.2)

plt.xlabel(r"$x$")
plt.ylabel(r"$u$", rotation=0)

plt.legend()
plt.show()
```

We observe that the amplitude of the wave grows over time and at $t \approx 7.2$, the wave becomes unstable. The increasing amplitude and the instability of the solution stems from the fact that the amplification factor $\xi_k > 1$ since the quantituty $v \Delta t / \Delta x = 1.4 > 1$. If $\xi_k > 1$, our simulation with the Lax method will produce unstable solutions.

Next, we will explore energy conservation in this system. We will define the energy of the system as

\begin{equation}\tag{3}
E = \int_{-\infty}^{\infty}  u^2(x, t) dx
\end{equation}

We expect that this quantity is conserved because if we take the time derivative of Eq. 3 we obtain:

$$
\frac{\partial E}{\partial t} = \int_{-\infty}^{\infty} \frac{\partial u^2}{\partial t} dx \\
= \int_{-\infty}^{\infty} 2 u \frac{\partial u}{\partial t} dx
$$

using Eq. 1 to swap the time derivative of $u$ for a spatial derivative we obtain

$$
\frac{\partial E}{\partial t} = -v \int_{-\infty}^{\infty} 2 u \frac{\partial u}{\partial x} dx \\
= -v \int_{-\infty}^{\infty} \frac{\partial u^2}{\partial x} dx \\
= -v \big\rvert_{-\infty}^{\infty}
$$

Given the boundary conditions of this system: $u(-\infty) = u(\infty) = 0$ we obtain

$$
\frac{\partial E}{\partial t} = -v \big\rvert_{-\infty}^{\infty} = 0
$$

and thus we expect this energy defintion to be a conserved quantity.

We will simulate the evolution of the initial Gaussian wave over several values of the temporal grid spacing $\Delta t$ and observe how the energy of the system changes with respect to the initial energy $E_0 = E(t=0)$.

```python
# The "energy" of the system
# Eq. (3)
def advection_energy(grid):
    return np.sum(grid**2, axis=0)
```

```python
for delta_t in np.arange(0.02, 0.18, 0.02):
    min_x, max_x, delta_x, min_t, max_t = -20, 50, 0.1, 0, 25
    grid = advection(min_x, max_x, delta_x, min_t, max_t, delta_t)

    locations = np.linspace(min_x, max_x, int((max_x - min_x) / delta_x))
    times = np.linspace(min_t, max_t, int((max_t - min_t) / delta_t))

    if delta_t != 0.02:
        plt.plot(times, advection_energy(grid) / advection_energy(grid)[0], label=r"    = {:0.2f}".format(delta_t))
    else:
        plt.plot(times, advection_energy(grid) / advection_energy(grid)[0], label=r"$\Delta t = {:0.2f}$".format(delta_t))

plt.xlabel(r"$t$")
plt.ylabel(r"$E / E_0$")

plt.ylim(0.6, 2)
plt.legend()
plt.show()
```

We observe that for $v\Delta t / \Delta x < 1$ and $v\Delta t / \Delta x > 1$ this energy is not conserved. For $v\Delta t / \Delta x < 1$, the energy decreases with time. For $v\Delta t / \Delta x > 1$ the energy gradually increases before exploding once the solution becomes unstable. Only for $v\Delta t / \Delta x = 1$ does the energy remain a conserved quantity throughout the simulation.

# 2. Crank-Nicholson method for diffusion equation

Next, we will attempt to use the Crank-Nicholson finite difference scheme to solve the diffusion equation, which is a parabolic PDE:

\begin{equation}
\tag{1}
\frac{\partial u}{\partial t} = D \frac{\partial^2 u}{\partial x^2}
\end{equation}

The Crank-Nicholson method uses the following discretization approximations for the partial derivatives:

$$
\frac{\partial u}{\partial t} = \frac{u_j^{n+1} - u_j^{n}}{\Delta t}, \qquad \frac{\partial^2 u}{\partial x^2} = \frac{u_{j+1}^{n+1/2} - 2 u_{j}^{n+1/2} + u_{j-1}^{n+1/2}}{\Delta x^2}
$$

The half-step approximation for $\frac{\partial^2 u}{\partial x^2}$ is then replaced with the average of the approximate derivative at $n$ and $n + 1$:

$$
\frac{\partial u}{\partial t} = \frac{u_j^{n+1} - u_j^{n}}{\Delta t}, \qquad \frac{\partial^2 u}{\partial x^2} = \frac{\left(u_{j+1}^{n+1} - 2 u_{j}^{n+1} + u_{j-1}^{n+1}\right) + \left(u_{j+1}^{n} - 2 u_{j}^{n} + u_{j-1}^{n}\right)}{2\Delta x^2}
$$

Applying this discretization to Eq. 1 we obtain the following implicit equation

\begin{equation}
\tag{2}
u_j^{n+1} = u_j^n + \frac{D \Delta t}{2\Delta x^2} \left[ \left( u_{j+1}^{n+1} - 2 u_j^{n+1} + u_{j-1}^{n+1} \right) + \left( u_{j+1}^n - 2 u_j^n + u_{j-1}^n  \right)  \right]
\end{equation}

In order to make Eq. 2 useful we must re-arrange all terms evaluated at time $n + 1$ to be on one side of the equation and all terms evaluted at time $n$ to be on the other side. First, we will re-name $D \Delta t / 2 \Delta x^2 = r$.

$$
u_j^{n+1} = u_j^n + r \left[ \left( u_{j+1}^{n+1} - 2 u_j^{n+1} + u_{j-1}^{n+1} \right) + \left( u_{j+1}^n - 2 u_j^n + u_{j-1}^n  \right)  \right] \\
u_j^{n+1} - r \left( u_{j+1}^{n+1} - 2 u_j^{n+1} + u_{j-1}^{n+1} \right) = u_j^n + r \left( u_{j+1}^n - 2 u_j^n + u_{j-1}^n  \right) \\
-r u_{j+1}^{n+1} + \left(1 + 2r\right) u_j^{n+1} - r u_{j-1}^{n+1} = r u_{j+1}^n + \left( 1 - 2r \right) u_j^{n} + r u_{j-1}^n
$$

On both sides, we have some linear combination of components in $u$ along the spatial direction. Thus, if we convert $u_j$ to be the $j^{th}$ component of a vector:

$$
\textbf{u} = \begin{bmatrix}
    u_0 \\
    u_1 \\
    \vdots \\
    u_N
\end{bmatrix}
$$

we can convert the above equation into a matrix equation:

$$
A \textbf{u}^{n+1} = B \textbf{u}^{n}
$$

where $A$ and $B$ are both tri-diagonal matrices of the form
$$ \tag{3}
A = \begin{bmatrix}
1 + 2r  & -r    & 0     & ...    & -r \\
-r      & 1 + 2r & -r    &        & \vdots  \\
0       & -r    & 1 +2 r &        &  \vdots \\
\vdots  &       &       & \ddots & -r  \\
-r      &  ...     & ...    &  -r  & 1 + 2r  \\
\end{bmatrix}
\qquad
B = \begin{bmatrix}
1 - 2r  & r      & 0      & ...    & r \\
r       & 1 - 2r & r      &        & \vdots  \\
0       & r      & 1 - 2r &        &  \vdots \\
\vdots  &        &        & \ddots & r  \\
r       &  ...   & ...    &  r     & 1 - 2r  \\
\end{bmatrix}
$$

The entries in the top right and bottom left of $A$ and $B$ indicate that we are applying periodic boundary conditions to this system.

This allows us to solve for the value of $u$ at time $n+1$ using the following equation

\begin{equation}\tag{4}
\textbf{u}^{n+1} = A^{-1} B \textbf{u}^n
\end{equation}

The Diffusion equation can also be solved exactly with the use of the Fourier Transform. If we define the Fourier Transform as

$$
\mathcal{F}\left(f(x)\right) = \tilde{f}(k) = \frac{1}{\sqrt{2\pi}} \int_{-\infty}^{\infty} f(x) e^{- i x k} dx
$$
and the inverse Fourier Transform as
$$
\mathcal{F}^{-1}\left(\tilde{f}(k)\right) = f(x) = \frac{1}{\sqrt{2\pi}} \int_{-\infty}^{\infty} \tilde{f}(k)e^{ i x k} dk
$$

then we can solve Eq. 1 using the Fourier Transform:

$$
\mathcal{F}\left(\frac{\partial u(x, t)}{\partial t}\right) = \frac{\partial \tilde{u}(k, t)}{\partial t} \\
\mathcal{F}\left( D \frac{\partial^2 u(x, t)}{\partial x^2} \right) = -D k^2 \tilde{u}(k, t)
$$

allowing us to rewrite Eq. 1 as

$$
\frac{\partial \tilde{u}(k, t)}{\partial t} = -D k^2 \tilde{u}(k, t) \\
\rightarrow \tilde{u}(k, t) = \tilde{u}(k, t = 0) e^{-D k^2 t}
$$

where $\tilde{u}(k, t = 0) = \mathcal{F}(u(x, t=0))$. Finally, we can tranform back using the inverse Fourier Transform to obtain a solution for $u(x,t)$:

$$\tag{5}
\label{eqn:exact}
u(x,t) = \mathcal{F}^{-1}\left( \mathcal{F}(u(x, t=0)) e^{-D k^2 t} \right)
$$

In the coded solution below, we will solve the Diffusion equation numerically using the Crank-Nicholson method (Eq. 2 and Eq. 3) and compare it to the exact solution (Eq. 5). We will start with the initial condition

$$
u(x, t=0) = \exp(-x^2 / w^2)
$$

then we will evolve the system according to Eq. 2 along a grid of spatial resolution $\Delta x = 0.1$ and various temporal resolutions $\Delta t$. Throughout, we will use $D = 0.5$ and $w = 10$. The exact solution to the diffusion equation for this initial condition is

\begin{align*}
u(x,t) &= \mathcal{F}^{-1}\left( \mathcal{F}(u(x, t=0)) e^{-D (2\pi k)^2 t} \right) \\
\mathcal{F}(u(x, t=0)) &= \mathcal{F}(e^{- \left(\frac{x}{w}\right)^2} ) = \frac{1}{\sqrt{2\pi}} \int_{-\infty}^{\infty} e^{- \left(\frac{x}{w}\right)^2} e^{-i x k} dx \\
&= e^{-\left( wk / 2\right)^2} \frac{1}{\sqrt{2\pi}} \int_{-\infty}^{\infty} e^{- \left(\frac{x}{w} + iwk / 2\right)^2 } dx \\
&= \frac{w}{\sqrt{2}} e^{-(kw / 2)^2} \\
\rightarrow u(x,t) &= \mathcal{F}^{-1}\left( \frac{w}{\sqrt{2}} e^{-(kw / 2)^2} e^{-D (2\pi k)^2 t} \right) \\
&= \frac{w}{\sqrt{2}} \frac{1}{\sqrt{2\pi}} \int_{-\infty}^{\infty} e^{-(kw / 2)^2} e^{-D (2\pi k)^2 t} e^{ i x k} dk \\
&= \frac{2}{2\sqrt{\pi}} e^{ -\frac{x^2}{4\left(Dt + w^2 / 4\right)} } \int_{-\infty}^{\infty} e^{- \left( k\sqrt{Dt + w^2 / 4} - \frac{ix}{2\sqrt{Dt + w^2/4}} \right)^2} \\
&= \frac{w}{2} \frac{1}{\sqrt{Dt + w^2 / 4}} e^{ - \frac{x^2}{w^2 + 4Dt} }
\end{align*}

$$\tag{6}
\rightarrow u(x, t) = \frac{1}{ \sqrt{ 1 + 4 Dt/w^2 } } \exp\left( - \frac{x^2}{w^2 + 4Dt} \right)
$$

```python
# Update the grid according to the Crank-Nicholson scheme to solve
# the diffusion equation with the diffusion constant D
# Uses Eq. (3) and Eq. (4)
def update_crank_nicholson(grid, delta_x, delta_t, D):
    xs, times = grid.shape

    r = (D * delta_t) / (2 * delta_x**2)

    # Construct the matrix A (Eq. (3))
    diag = np.eye(xs, xs) * (1 + 2 * r)
    diag_down = np.roll(np.eye(xs, xs), 1, axis=0) * (-r)
    diag_up = np.roll(np.eye(xs, xs), -1, axis=0) * (-r)

    A = diag + diag_down + diag_up

    # Construct the matrix B (Eq. (4))
    diag = np.eye(xs, xs) * (1 - 2 * r)
    diag_down = np.roll(np.eye(xs, xs), 1, axis=0) * (r)
    diag_up = np.roll(np.eye(xs, xs), -1, axis=0) * (r)

    B = diag + diag_down + diag_up

    # Compute the matrix that gives us the value of the function at the next time step
    # Eq. (4) without application
    step = np.dot(np.linalg.inv(A), B)

    for t in range(times - 1):
        # Eq. (4)
        grid[:, t + 1] = np.dot(step, grid[:, t])

# Solve the Diffusion equation with the Crank-Nicholson scheme
# Initial wave is a Gaussian of the form exp( -x^2 / w^2)
# The exact form at all times t is computed and compared to the simulated result
def crank_nicholson(min_x, max_x, delta_x, min_t, max_t, delta_t, D, w):
    x_vals = np.linspace(min_x, max_x, int((max_x - min_x) / delta_x))
    t_vals = np.linspace(min_t, max_t, int((max_t - min_t) / delta_t))
    grid = np.zeros((len(x_vals), len(t_vals)))

    # Initial conditions
    grid[:, 0] = np.exp(- x_vals**2 / w**2)
    # Fill in the grid
    update_crank_nicholson(grid, delta_x, delta_t, D)

    # Compute the real value at all times
    # Eq. (6)
    real = 1. / (np.sqrt(1 + 4 * D * np.outer(np.ones(len(x_vals)), t_vals) / w**2)) * \
                 np.exp(-np.outer(x_vals, np.ones(len(t_vals)))**2 / (w**2 + 4 * D * \
                                                                   np.outer(np.ones(len(x_vals)), t_vals)))
    # Compute the error = difference between simulated and real value
    error = grid - real

    return grid, real, error
```

We will simulate the Diffusion equation starting with a Gaussian pulse of width $w= 10$ with $D = 0.5$ over the space $x \in \left[ -60, 60 \right]$ for times $t \in \left[ 0, 70 \right]$ with a spacial gridding of $\Delta x = 0.1$ and a temporal gridding of $\Delta t = 0.5$.

```python
min_x, max_x, delta_x, min_t, max_t, delta_t, D, w = -60, 60, 0.1, 0, 70, 0.1, 0.5, 10
x_vals = np.linspace(min_x, max_x, int((max_x - min_x) / delta_x))
t_vals = np.linspace(min_t, max_t, int((max_t - min_t) / delta_t))

grid, real, error = crank_nicholson(min_x, max_x, delta_x, min_t, max_t, delta_t, D, w)
```

```python
for t in [0, 10, 20, 30, 40, 50, 60]:
    if t == 0:
        plt.plot(x_vals, grid[:, int(t / delta_t)], label=r"$t$ = {:d}".format(t))
    else:
        plt.plot(x_vals, grid[:, int(t / delta_t)], label="  = {:d}".format(t))

plt.xlabel(r"$x$")
plt.ylabel(r"$u$", rotation=0)

plt.legend()
plt.show()
```

Here we plot $u(x,t)$ at $t = 0, 10, 20, 30, 40, 50, \text{ and } 60$. We notice that the initial Gaussian pulse diffuses as expected.

```python
plt.imshow(grid.T, cmap='jet', origin='lower')
plt.xlabel("x (grid)")
plt.ylabel("t (grid)")
plt.show()
```

Here we show the entire grid as an image, showing how the initial pulse (lower) diffuses over all time.

Next, we will examine the error scaling of the Crank-Nicholson method with the temporal gridding of $\Delta t = 0.125, 0.25, 0.5, 1, 2, 4, \text{ and } 8$. We will examine the error (difference between simulated and exact values for $u(x, t)$) at $x = 0$, which is where the peak of the pulse is.

```python
peak_errors = []
delta_ts = [0.125, 0.25, 0.5, 1, 2, 4, 8]

t_valss = []
peak_errors = []

for delta_t in delta_ts:
    min_x, max_x, delta_x, min_t, max_t, D, w = -60, 60, 0.1, 0, 400, 0.5, 10
    x_vals = np.linspace(min_x, max_x, int((max_x - min_x) / delta_x))
    t_vals = np.linspace(min_t, max_t, int((max_t - min_t) / delta_t))

    grid, real, error = crank_nicholson(min_x, max_x, delta_x, min_t, max_t, delta_t, D, w)

    peak = int((max_x - min_x) / delta_x / 2)
    peak_error = error[peak, :]
    t_valss.append(t_vals)
    peak_errors.append(np.abs(peak_error))
```

```python
for i, (t_vals, peak_error, delta_t) in enumerate(zip(t_valss, peak_errors, delta_ts)):
    if i == 0:
        plt.plot(t_vals, np.abs(peak_error), label=r'$\Delta t = {:1.3f}$'.format(delta_t))
    else:
        plt.plot(t_vals, np.abs(peak_error), label='    = {:1.3f}'.format(delta_t))

plt.legend(loc='right', bbox_to_anchor=(1.3, .5), ncol=1, fancybox=False, shadow=True)

plt.xlabel(r"$t$")
plt.ylabel(r"$error$")

plt.show()
```

Here, we plot the absolute discrepancy between the simulated and exact values of $u(x = 0, t)$ over the simulation time. We note that as $\Delta t$ decreases, the error decreases as well.

Next, we will examine how the error scales with $\Delta t$ by plotting the error in the peak at $t = 10$ for the same set of $\Delta t$ values.

```python
peak_errors = []
delta_ts = [0.125, 0.25, 0.5, 1, 2, 4, 8]

for delta_t in delta_ts:
    min_x, max_x, delta_x, min_t, max_t, D, w = -60, 60, 0.1, 0, 200, 0.5, 10
    x_vals = np.linspace(min_x, max_x, int((max_x - min_x) / delta_x))
    t_vals = np.linspace(min_t, max_t, int((max_t - min_t) / delta_t))

    grid, real, error = crank_nicholson(min_x, max_x, delta_x, min_t, max_t, delta_t, D, w)

    peak = int((max_x - min_x) / delta_x / 2)

    peak_errors.append(np.abs(error[peak, 10]))
```

```python
plt.loglog(delta_ts, peak_errors, markersize=10, linestyle='None', marker='o', color='k')
plt.ylim(1e-7, 1e-2)
y = [1.5e-4 * delta_t**2 for delta_t in delta_ts]
plt.loglog(delta_ts, y, linestyle='--', color='c', label=r'$C \times \Delta t^2$')

plt.xlabel(r"$\Delta t$")
plt.ylabel("Peak Error")

plt.ylim(1e-6, 1e-2)

plt.legend()
plt.show()
```

We find that the error scales as $error \sim \Delta t^2$.

# 3. Korteweg – de Vries equation

We seek to solve numerically the Kortewag - de Vries (KdV) equation, which is given by

\begin{equation}
\tag{1}
\frac{\partial u}{\partial t} + u \frac{\partial u}{\partial x} + \delta^2 \frac{\partial^3 u}{\partial x^3} = 0
\end{equation}

where $\delta$ is a parameter that governs dispersion. If $\delta = 0$, we obtain the advection equation with $v = u$. The KdV equation can be solved numerically using the explicit leapfrog method:

\begin{equation}
\tag{2}
u_j^{n+1} = u_j^{n-1} - \frac{\Delta t}{3\Delta x} (u_{j+1}^n + u_{j}^n + u_{j-1}^n) ( u_{j+1}^n - u_{j-1}^n ) - \frac{\delta^2 \Delta t}{\Delta x^3} ( u_{j+2}^n - 2 u_{j+1}^n + 2 u_{j-1}^n - u_{j-2}^n )
\end{equation}

In the coded solution below, we solve the KdV equation using the discretization provided by Eq. 2 with $\delta = 0.022$ over the spatial domain $0 < x < 2$ with periodic boundary conditions. The simulation is started with the initial condition $u(x, t = 0) = cos(\pi x)$.

```python
# Fill in the grid according to Eq. (2)
def update_korteweg(grid, delta_x, delta_t, delta):
    xs, times = grid.shape

    for t in range(1, times - 1):
        if t % 10 == 0:
            print("-- {:0.2f}% done".format(100. * t / times), end='\r')

        previous = grid[:, t - 1]
        now = grid[:, t]

        right = np.roll(now, -1, axis=0)
        right_2 = np.roll(now, -2, axis=0)

        left = np.roll(now, 1, axis=0)
        left_2 = np.roll(now, 2, axis=0)

        # Eq. (2)
        grid[:, t + 1] = previous - (delta_t / (3 * delta_x)) * (right + now + left) * (right - left) - ((delta**2 * delta_t) / (delta_x**3)) * (right_2 - 2 * right + 2 * left - left_2)

# Run the simulation with the initial condition of a cosine wave
def korteweg(min_x, max_x, delta_x, min_t, max_t, delta_t, delta=0.022):
    range_x = max_x - min_x
    range_t = max_t - min_t

    num_x, num_t = int(range_x / delta_x), int(range_t / delta_t)

    grid = np.zeros((num_x, num_t))
    # Initial conditions
    grid[:, 0] = np.cos( np.pi * np.linspace(min_x, max_x, int(range_x / delta_x) ) )
    grid[:, 1] = np.cos( np.pi * np.linspace(min_x, max_x, int(range_x / delta_x) ) )

    # Fill in the grid
    update_korteweg(grid, delta_x, delta_t, delta)

    return grid
```

```python
min_x, max_x, delta_x, min_t, max_t, delta_t = 0, 2, 0.01, 0, 11 / np.pi, 1e-5

locations = np.linspace(min_x, max_x, int((max_x - min_x) / delta_x))
times = np.linspace(min_t, max_t, int((max_t - min_t) / delta_t))

kdv_grid = korteweg(min_x, max_x, delta_x, min_t, max_t, delta_t)
```

    -- 100.00% done

```python
plt.plot(locations, kdv_grid[:, 0], label='t = 0', color='purple')
plt.plot(locations, kdv_grid[:, int((1 / np.pi) / delta_t)], label=r'  = $1 t_B$', color='c')
plt.plot(locations, kdv_grid[:, int((3 / np.pi) / delta_t)], label=r'  = $3 t_B$', color='forestgreen')

plt.xlabel(r'$x$')

plt.legend()
plt.show()
```

Here, we plot the waveform evolved according to the KdV equation at three times: $t = 0, 1t_B, \text{ and } 3t_B$ where $t_B = 1/\pi$ is the characteristic time to breakdown of the system. At this time, a singularity forms and $\frac{\partial u}{\partial x}$ diverges, causing soliton trains to form (the peaks), which move indpendently across the space. At $t = 3t_B$ there exist 9 identifiable solitons.

```python
plt.imshow(kdv_grid[:, (times / delta_t).astype(int)[::1000]].T, origin='lower', cmap='jet')
plt.xlabel("x (grid)")
plt.ylabel("t (grid)")
plt.show()
```

Here, we show a contour plot of the grid, showing the initial waves breaking down into solitons and displaying the solitons' particle-like trajectories (the curved and straight lines). Note how the wavefronts change once two solitons collide.

We can also plot the wave as a function at several points in time and create a movie of the wave evolving in time.

```python
for t in (times / delta_t).astype(int)[::2000]:
    plt.plot(locations, kdv_grid[:, t], color='k')
    plt.ylim(-1.2, 3)
    plt.xlabel(r"$x$")
    plt.savefig("images/image_%06d.jpg" % t)
    plt.close()
```

Here, we show a movie of the initial wave evolving in time.


# HW 04 Solutions

1.  (a) output is 0-5 V

(b) best DAS input range would be 0-10 V, there will be clipping otherwise.

(c) Pressure transducer has 3 sources of elemental uncertainties: linearity, repeatability, and hysterisis.  We will use the RSS of the 3 elemental uncertainties to find the uncertainty in the pressure transducer.

first we have to select a common unit to work with for the pressure transducer
and the DAS.  Because the uncertainty in the PT are given in full scale (FS), it is preferrable to work in absolut voltages.

```python
import numpy
# Pressure transducer
FS_PT = 5 # V

u_PT_lin = 0.0025 * FS_PT # V
u_PT_rep = 0.0006 * FS_PT # V
u_PT_hys = 0.001 * FS_PT # V
print("linearity %1.4f" % u_PT_lin, 'V')
print( "repeatability %1.4f" % u_PT_rep, 'V')
print("hysterisi %1.4f" % u_PT_hys, 'V')
u_PT = numpy.sqrt(u_PT_lin**2 + u_PT_rep**2 + u_PT_hys**2)
print("Uncertainty of pressure transducer, u_PT = %1.4f" % u_PT , ' V')

# DAS
N = 16 # bits
Range = 10 # V, guarantee no clipping
Q = Range/2**(N+1) # V
LSD = 2 * Q # V
u_DAS_lin = 2 * LSD # V
u_DAS_gain = 2 * LSD # V
u_DAS = numpy.sqrt(u_DAS_lin**2 + u_DAS_gain**2 + Q**2)
print("linearity %1.6f" % u_DAS_lin, ' V')
print("gain %1.6f" % u_DAS_gain, ' V')
print("quantization error %1.6f" % Q, ' V')

print("Uncertainty of DAS %1.6f" %u_DAS, ' V')
```

    linearity 0.0125 V
    repeatability 0.0030 V
    hysterisi 0.0050 V
    Uncertainty of pressure transducer, u_PT = 0.0138  V
    linearity 0.000305  V
    gain 0.000305  V
    quantization error 0.000076  V
    Uncertainty of DAS 0.000438  V

(d)  We use of the RSS of the PT and DAS to find the overall uncertainty of the system

```python
u_overall = numpy.sqrt(u_PT**2 + u_DAS**2)
print("overall uncertainty %1.6f" %u_overall, ' V')
```

    overall uncertainty 0.013800  V

(e) The overall uncertainty is dominated by the pressure transducer.  I would try and replace it.

2. The overall accuracy is the RSS of the thermocouple and its display.  I would compute and report the uncertainties in $^\circ C$.

```python
u_TC = 0.5 # C
u_display = 0.5 # C
u_overall_TC = numpy.sqrt(u_TC**2 + u_display**2)
print("overall uncertainty is %1.4f" % u_overall_TC, 'C')
```

    overall uncertainty is 0.7071 C

3. (a) Remember the ideal gas law:
\begin{align}
\rho = \frac{P}{RT} = P^1 R^{-1} T^{-1}
\end{align}
The ideal gas constant $R$ is very well known and we can assume it is know perfectly (ie its uncertainty is negligible).

Using uncertainty of a result, one has:
\begin{align}
\frac{u_\rho}{\rho} = \sqrt{\left( 1 \cdot \frac{u_P}{P}\right)^2 + \left( -1 \cdot \frac{u_T}{T} \right)^2}
\end{align}
I need to select a unit for this analysis.  I will work as percentage error, which is ideal for the relative uncertainties I am using here, and convert to the appropriate unit when necessary.

```python
u_PP = 0.005 # relative
u_T = 1 # K
T = 288 # temperature is the thermodynamic temperature in Kelvin
u_rhorho = numpy.sqrt(u_PP**2 + (u_T/T)**2)

print("relative temperature uncertainty %1.4f" % (u_T/T*100), '%')
print("relative uncertainty on density %1.4f" % (u_rhorho*100), ' %')
```

    relative temperature uncertainty 0.3472 %
    relative uncertainty on density 0.6087  %

(b)  If we increase the accuracy of the temperature measurement from $\pm 1^\circ C$ to $\pm 0.5^\circ C$, our relative error on temperature measurement is now halved, or $\pm 0.1736\%$, so we have hope to meet our resolution target on the density.  To solve this optimization problem, the trick is not to inverse the governing equation (the result) first, but to invert the uncertainty of a result equation...

Starting from the equation above and taking the square of it:
\begin{align}
\left( \frac{u_\rho}{\rho} \right)^2 & = \left( \frac{u_P}{P}\right)^2 + \left( \frac{u_T}{T} \right)^2  \\
\left( \frac{u_P}{P}\right)^2 & = \left( \frac{u_\rho}{\rho} \right)^2 - \left( \frac{u_T}{T} \right)^2
\end{align}

```python
u_TT = 0.5/288 # relative
u_rhrh = 0.0025 # relative

u_PP2 = numpy.sqrt(u_rhrh**2 - u_TT**2)
print("Relative pressure uncertainty must be no larger than %1.4f" % (u_PP2*100), '%')
```

    Relative pressure uncertainty must be no larger than 0.1799 %

```python

```


```
# default_exp system_response
```

```
#hide
%load_ext autoreload
%autoreload 2
```

```
#hide
# Makes it possible to do symbolic maths and use a control system lib
import sympy
sympy.init_printing()

# Let's also ignore some warnings here due to sympy using an old matplotlib function to render Latex equations.
import warnings
warnings.filterwarnings('ignore')
```

```
#export
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
```

```
#hide
%matplotlib inline
```

# System Response

This notebook analyses how different systems might respond to different classes of inputs.

- Dominal pole approximation
- First and second order systems
- System response and performance requirements

-----------------------

## First and second order systems, and the dominant pole approximation

Lower order (1st and 2nd) are well understood and easy to characterize (speed of system, oscillations, damping...), but his is much more difficult with higher order systems.

One way to make many such systems easier to think about is to approximate the system by a lower order system using a technique called the dominant pole approximation. This approximation assumes that the slowest part of the system dominates the response, and that the faster part(s) of the system can be ignored.

**Notes:**
- In a transfer function representation, the *order* is the highest exponent in the transfer function. In a proper system, the system order is defined as the degree of the denominator polynomial.
-  A *proper system* is a system where the degree of the denominator is larger than or equal to the degree of the numerator polynomial.
- A *strictly proper* system is a system where the degree of the denominator polynomial is larger than the degree of the numerator polynomial.

Let's take some examples and verify how a few systems behaves.

We will use Sympy to do it, so we need to define some variables first.

```
t, K, tau = sympy.symbols('t, K, tau',real=True, positive=True)
s = sympy.Symbol('s')
u = sympy.Heaviside(t) # step function

def L(f):
    return sympy.laplace_transform(f, t, s, noconds=True)
def invL(F):
    return sympy.inverse_laplace_transform(F, s, t)
```

```
def evaluate(f, times):
    res = []
    for time in times:
        res.append(f.evalf(subs={t:time}).n(chop=1e-5))
    return res
```

**Side note:**

`chop` makes it possible to round float numbers off to a desired precision. Below is an example

```
q1 = (-1.53283653303955 + 6.08703605256546e-17*sympy.I)
q2 = (-1.53283653303955 + 6.08703605256546e-5*sympy.I)

print('q1:', q1.n(chop=1e-5))
print('q2:', q2.n(chop=1e-5))
```

    q1: -1.53283653303955
    q2: -1.53283653303955 + 6.08703605256546e-5*I

### Dominant Poles

- When we have a BIBO stable system, every mode of the system is an exponentially dumped signal.
- Beyond the initial transient, the main effect is driven by the slowest modes

For example let's consider these poles:

$$
p_1 = -0.1
$$

$$
p_{2,3} = -10 \pm 17.32j
$$

$$
p_4 = -15.5
$$

$$
p_{5,6} = -20 \pm 34.64j
$$

```
fig, axs = plt.subplots(1,1,figsize=(15,7))

plt.plot(-0.1,    0,                  marker='.', markersize=25, color='blue')
plt.plot([-5, -5],   [-8.66, 8.66],   marker='.', markersize=25, linestyle='', color='orange')
plt.plot(-15.5,   0,                  marker='.', markersize=25, color='green')
plt.plot([-20, -20], [-34.64, 34.64], marker='.', markersize=25, linestyle='', color='red')

axs.set_xlim([-24, 1])
axs.set_ylim([-36, 36])
axs.set_xlabel('Re')
axs.set_ylabel('Img')

plt.legend(['$p_1$', '$p_{2,3}$', '$p_4$', '$p_{5,6}$'])
plt.grid()

```

We can now verify what output is associated to each of these poles.

To do this we define four systems that have poles at the positions depicted above:

$$ G_1(s) = \frac{1}{(s + 0.1)} $$

$$ G_2(s) = \frac{100}{(s^2 + 10s + 100)} $$

$$ G_3(s) = \frac{15.5}{(s + 15.5)} $$

$$ G_2(s) = \frac{1600}{(s^2 + 40s + 1600)} $$

```
fig, axs = plt.subplots(1,4,figsize=(15,7))

time = np.linspace(0,10,100)

# Define our systems
G1 = 1/(s + 0.1)

zeta, w_n = 0.5, 10
G2 = w_n**2/(s**2 + 2*zeta*w_n*s + w_n**2)

G3 = 15.5/(s + 15.5)

zeta, w_n = 0.5, 40
G4 = w_n**2/(s**2 + 2*zeta*w_n*s + w_n**2)

# plot them
axs[0].plot(time, evaluate(invL(G1), time), color='blue',   linewidth=3), axs[0].set_ylim(-.5, 1), axs[0].set_title('G1'), axs[0].set_xlabel('time (s)')
axs[1].plot(time, evaluate(invL(G2), time), color='orange', linewidth=3), axs[1].set_ylim(-1, 1), axs[1].set_title('G2'), axs[1].set_xlabel('time (s)')
axs[2].plot(time, evaluate(invL(G3), time), color='green',  linewidth=3), axs[2].set_ylim(-.5, 1), axs[2].set_title('G3'), axs[2].set_xlabel('time (s)')
axs[3].plot(time, evaluate(invL(G4), time), color='red',    linewidth=3), axs[3].set_ylim(-1, 1), axs[3].set_title('G4'), axs[3].set_xlabel('time (s)')

fig.tight_layout()
```

```
invL(G2)
```

- Depending on how far they are in the $s$ plane their influence in time is different.
- **Beyond the initial transient, the slower modes are those the matter**

### Reduction of a second order system to first order

Consider an overdamped second order system:

$$
G(s) = K \frac{\alpha\beta}{(s+\alpha)(s+\beta)}
$$

whose step response is:

$$
Y(s) = K \frac{\alpha\beta}{(s+\alpha)(s+\beta)}\frac{1}{s}
$$

and hence, applying partial fraction expansion:

$$
y(t) = K \bigg ( 1 - \frac{\beta e^{-\alpha t} - \alpha e^{-\beta t}}{\beta - \alpha}\bigg )
$$

If the magnitude of $\beta$ is large compared to $\alpha$ (typically if $\beta/\alpha > 5$), and assuming $s$ is sufficiently small compared to $\beta$, we can write the following approximation for the transfer function (and as well as an approximation for the step response):

$$
G(s) \approx K \frac{\alpha\beta}{(s+\alpha)(\beta)}
$$

at which corresponds the following step response:

$$
y(t) \approx K \bigg ( 1 - \frac{\beta e^{-\alpha t}}{\beta} \bigg ) = K(1-e^{-\alpha t})
$$

Note that $G(0)$ is unchanged and this is necessary to ensure that the steady state value remains the same.

### Example 1, second order system

Let's now consider when we have two cascaded systems:

$$ G_1 = \frac{0.1}{(s+0.1)} $$

$$ G_2 = \frac{1}{(s+1)} $$

```
G1 = 0.1/(s+0.1)

G2 = 1/(s+1)
```

In this case, one pole is 10 times bigger than the other.

The entire system is then

$$ G(s) = G_1(s)G_2(s) = \frac{0.1}{(s+0.1)(s+1)} $$

```
G = G1*G2
print(G)
```

    0.1/((s + 0.1)*(s + 1))

And now we can plot their output:

```
fig, ax = plt.subplots(1,1,figsize=(8,5))

time = np.linspace(0,40,100)

ax.plot(time, evaluate(invL(G), time), linewidth=3)
ax.plot(time, evaluate(invL(G1), time), linewidth=3)
ax.plot(time, evaluate(invL(G2), time), linewidth=3)

ax.set_ylim(-.1, 0.3)
ax.set_xlabel('time (s)')
ax.grid()
fig.legend(['y_g(t)', 'y_g1(t)', 'y_g2(t)']);
```

And we can also calculate the step response:

$$
Y(s)= G(s) U(s) = G(s)\frac{1}{s}
$$

And this is the output of our system when we have a step input:

```
# We can plot the figure above running this cell. Unfortunately, github CI throws an error and I still need to figure out why.

# fig, ax = plt.subplots(1,1,figsize=(8,5))

# time = np.linspace(0,40,100)

# ax.plot(time, evaluate(invL(G*1/s), time), linewidth=3)
# ax.plot(time, evaluate(invL(G1*1/s), time), linewidth=3)
# ax.plot(time, evaluate(invL(G2*1/s), time), linewidth=3)

# ax.set_ylim(-.1, 1.1)
# ax.set_xlabel('time (s)')
# ax.grid()
# fig.legend(['y_g(t)', 'y_g1(t)', 'y_g2(t)']);
```

- The step response plot shows three plots: the blue plot is the exact response, the orange plot is the approximation assuming the pole at -0.1 dominates; the green plot is the approximation assuming that the pole at 1 dominates (which clearly it doesn't because the plot is nowhere near the exact response)

- Note that the blue and orange plots are very close to each other, so the dominant pole approximation is a good one.

- The exact response has two exponentials, a fast one with a relatively short time constant of $\tau_1 = 1/(p_1)$ and a much slower exponential with time constant $\tau_2 = 1/(p_2)$.

- If we look at the overall resonse, the fast exponential comes to equilibrium much more quickly than the slow explonential. From the perspective of the overall response, the faster exponential comes to equilibrium (i.e., has decayed to zero) instantaneously compared to the slower exponential.

- Therefore, the slower response (due to the pole closer to the origin — at s=-0.1) _dominates_.

The second order system $G(s)$ behaves approximately like $G_1(s)$ which is the one with the slower dynamics: $$G(s) = \frac{0.1}{(s+0.1)(s+1)} \approx \frac{0.1}{(s+0.1)}$$

### Example 2, Second order, Pole dominates but not as strongly

Let's now consider a different case and build an overall system composing the following two systems in cascade:

$$
G_1(s) = \frac{0.2}{s+0.2}
$$

$$
G_2(s) = \frac{1}{s+1}
$$

In this case, one pole is only 5 times bigger than the other.

The entire system is then

$$ G(s) = G_1(s)G_2(s) = \frac{0.2}{(s+0.2)(s+1)} $$

```
G1 = 0.2/(s+0.2)
G2 = 1/(s+1)
G=G1*G2
print('G:', G)
```

    G: 0.2/((s + 0.2)*(s + 1))

If we now evaluate the step response for this system:

```
fig, ax = plt.subplots(1,1,figsize=(8,5))

time = np.linspace(0,40,100)

ax.plot(time, evaluate(invL(G*1/s), time), linewidth=3)
ax.plot(time, evaluate(invL(G1*1/s), time), linewidth=3)
ax.plot(time, evaluate(invL(G2*1/s), time), linewidth=3)

ax.set_ylim(-.1, 1.1)
ax.set_xlabel('time (s)')
ax.grid()
fig.legend(['y_g(t)', 'y_g1(t)', 'y_g2(t)']);
```

The approximation is still fairly good, but not not quite as good as when $p_1=0.1$, as we would expect.

## Example 3, Second Order, neither pole dominates

If we instead have two poles quite close to each other (note that it is their relative location - or ratio of the pole locations - that is of interest to determine if the dominant approximation is applicable).

Let's consider:

$$
G_1(s) = \frac{1.25}{s+1.25}
$$

$$
G_2(s) = \frac{1}{s+1}
$$

and the final system is:

$$
G(s) = G_1(s)G_2(s) = \frac{1.25}{(s+1)(s+1.25)}
$$

```
G1 = 1.25/(s+1.25)
G2 = 1/(s+1)
G = G1*G2
print('G:', G)
```

    G: 1.25/((s + 1)*(s + 1.25))

If we now calculate and plot the step response:

```
fig, ax = plt.subplots(1,1,figsize=(8,5))

time = np.linspace(0,10,100)

ax.plot(time, evaluate(invL(G*1/s), time), linewidth=3)
ax.plot(time, evaluate(invL(G1*1/s), time), linewidth=3)
ax.plot(time, evaluate(invL(G2*1/s), time), linewidth=3)

ax.set_ylim(-.1, 1.1)
ax.set_xlabel('time (s)')
ax.grid()
fig.legend(['y_g(t)', 'y_g1(t)', 'y_g2(t)']);
```

In this case, the two poles are very close to each other and the dominat pole approximation cannot be applied.

The three plots above are different: the blue (exact) response is not at all close the approximation of the approximation in which a first order pole dominates (orange or green).

Note that the step response for this case has a different time scale than the other two.

### Simplifying Higher Order Systems

The dominant pole approximation can also be applied to higher order systems. Here we consider a third order system with one real root, and a pair of complex conjugate roots.

- In this case the test for the dominant pole compare $\alpha$ against $\zeta \omega_0$
    - Note: sometimes  $\zeta \omega_0$ is also written $\xi \omega_n$ - they are the same thing
- This is because $\zeta \omega_0$ is the real part of the complex conjugate root
- We only compare the real parts of the roots when determining dominance because it is the real part that determines how fast the response decreases.
- Note, that as with the previous case, the steady state gain $H(0)$ of the exact system and the two approximate systems are equal. This is necessary to ensure that the final value of the step response (which is determined by $H(0)$ is unchanged).

## Example 5, Third order, Real Pole Dominates

Let's now consider the following system

$$G(s) = \frac{\alpha \omega_n^2}{(s+\alpha)(s^2+2\xi\omega_n s + \omega_n^2)} = \frac{17}{(s+0.1)(s^2+2s+17)}$$

$$ \approx G_dp(s) = \frac{\alpha}{s+\alpha} = \frac{0.1}{s+0.1}$$

The second order poles are at $s=-1 \pm j4$ ($\xi$=0.24 and $\omega_n=\sqrt{17}$=4.1) and the real pole is at $s = -\alpha = -0.1$.

As before we write the entire system as a cascade of two systems

$$
G_1(s)=\frac{\alpha}{s + \alpha}
$$

and

$$
G_2(s)=\frac{\omega_n^2}{s^2+2\xi\omega_n s + \omega_n^2)}
$$

with

$$
G(s) = G_1(s)G_2(s)
$$

```
alpha = 0.1 # real pole that dominates
```

```
G1 = 0.1/(s+0.1)
G2 = 17/(s**2+2*s+17)

G = G1*G2
print('G:', G)
```

    G: 1.7/((s + 0.1)*(s**2 + 2*s + 17))

```
fig, ax = plt.subplots(1,1,figsize=(8,5))

time = np.arange(0,40,0.2)

#ax.plot(time, evaluate(invL(G*1/s), time), linewidth=5) # this is commented out only because github CI failes. The response of G matches up the reponse of G1.
ax.plot(time, evaluate(invL(G1*1/s), time), linewidth=3)
ax.plot(time, evaluate(invL(G2*1/s), time), linewidth=3)

ax.set_ylim(-.1, 1.6)
ax.set_xlabel('time (s)')
ax.grid()
fig.legend(['y_g(t)', 'y_g1(t)', 'y_g2(t)']);
```

- The blue (exact) and orange (due to pole at $s=-\alpha=-0.1$) lines are very close since that is the pole that dominates in this example.
- The green line (corresponding to $G_2(s)$, and that would correspond to the case where the second order poles are assumed to dominate) is obviously a bad approximation and not useful, as expected.

**Dominant pole approximation can simplify systems analysis**

The dominant pole approximation is a method for approximating a (more complicated) high order system with a (simpler) system of lower order if the location of the real part of some of the system poles are sufficiently close to the origin compared to the other poles.

Let's consider another example:
$$
G1 = \frac{150.5}{s+150.5}
$$

with pole at -150.5

$$
G2 = \frac{100}{s^2 + 10s +100}
$$

with poles at: $p = -5 \pm 8.66j$

```
G1 = 150.5/(s + 150.5)
print('G1: ', G1)

zeta, w_n = 0.5, 10
G2 = w_n**2/(s**2 + 2*zeta*w_n*s + w_n**2)
print('G2: ', G2)

G = G1*G2
print('G: ', G)
```

    G1:  150.5/(s + 150.5)
    G2:  100/(s**2 + 10.0*s + 100)
    G:  15050.0/((s + 150.5)*(s**2 + 10.0*s + 100))

The step response is:

We can obtain the figure above uncommenting and running the cell below.

```
# fig, ax = plt.subplots(1,1,figsize=(8,5))

# time = np.arange(0, 4, 0.05)

# ax.plot(time, evaluate(invL(G*1/s), time),  linewidth=7, color = 'blue')
# ax.plot(time, evaluate(invL(G1*1/s), time), linewidth=4, color = 'orange')
# ax.plot(time, evaluate(invL(G2*1/s), time), linewidth=3, color = 'green')

# ax.set_ylim(-.1, 1.6)
# ax.set_xlabel('time (s)')
# ax.grid()
# fig.legend(['y_g(t)', 'y_g1(t)', 'y_g2(t)']);
```

--------------------

## First-order and second-order systems

- Given that the main effect is driven by the slowest modes, then the dynamics of the system can be approximated by the modes associated to the dominant poles
- Typically this ends up being a first-order system (real dominant pole) or a second-oder system (complex-conjugated poles), possibly with a constant delay
- It is hence important to understand the response of the first-order and second-order systems as they can be representive of a broader class.

### Step response of first order systems

Given a system:
$$G(s) = \frac{1}{s+p} = \frac{\tau}{1+\tau s}$$

with pole in $s = -p = \frac{-1}{\tau} $

and given a input: $$u(t) = 1(t)$$

the output of the system is:
$$ y(t) = 1 - e^{-\frac{t}{\tau}}$$

This is obtained:

$$\frac{1}{1+\tau s}\frac{1}{s} = \frac{1/\tau}{s + 1/\tau} \frac{1}{s} = \frac{A}{s + 1/\tau} + \frac{B}{s}$$

where $A = -1$, $B = 1$

We can also verify it with Sympy:

```
F = 1/(K*s + 1)*1/s
invL(F)
```

We can then plot the output $y(t)$ for a particular value of the time constant $\tau=2$:

```
time = np.linspace(0,20,100)

tau = 2 # define the time constant

y_t = 1 - np.exp(-time/tau)
```

```
fig = plt.figure(figsize=(10,5))

plt.plot(time, y_t, linewidth=3)
plt.title('Step response')
plt.xlabel('time (s)')
plt.grid()
```

We call:

- $\tau$: time constant - Characterises completely the response of a first order system
- Settling time: the time it takes to get to 95% (or other times 90%, 98%) of the steady state value $y(t)=1$:
    - $t_s = -\tau ln(0.05)$
    - This is because:
       $$
       y(t) = 1 - e^{-\frac{t}{\tau}} \Rightarrow 1 - y(t) = e^{-\frac{t}{\tau}} \Rightarrow \ln(1-y(t)) = -\frac{t}{\tau}, \;\; \text{when} \;\; y(t)=0.95 \Rightarrow t = -\tau ln(0.05)
       $$

```
#y_t = -1 # Desired steady state value
final_value_pc = 0.95 # percentage of y_t

# final_value = 1 - np.exp(-t/tau)
# np.log(1-final_value) = -t/tau

t_s = -tau*np.log(1-final_value_pc)

print('Time to get to {}% of final value {:.1f}: {:.2f}s'.format(final_value_pc, y_t[-1], t_s))
```

    Time to get to 0.95% of final value 1.0: 5.99s

Note that, after $\tau$ seconds, the system gets to the $64\%$ of the final value

### To recap

- We have defined two times in the response of a first order system:
  - Settling time $t_s$: time to get to 0.95\% of the final value
  - Time constant $\tau$, which is the time to get to the 64\% of the final value.

We can plot them, together with the step response of the system:

$$ y(t) = 1 - e^{-\frac{t}{\tau}}$$

```
fig = plt.figure(figsize=(10,5))

# step response
plt.plot(time, y_t, linewidth=3)

# tau - time constant
plt.plot(tau, 1 - np.exp(-tau/tau), marker='.', markersize=15)

# settling time
plt.plot(t_s, final_value_pc*y_t[-1], marker='.', markersize=15)

fig.legend(['Step response', 'Tau seconds', '$t_s$ seconds'], loc='upper left')
plt.title('Step response')
plt.xlabel('time (s)')
plt.grid()
```

------------------------------------------

### Step response of second order systems

Given a system:
$$G(s) = \frac{1}{\frac{s^2}{w_n^2} + \frac{2\xi}{w_n}s + 1}$$

with $ 0 < \xi < 1 $

- $\xi$ is called damping ratio
- $\omega_n$ (sometime also called $\omega_0$) is the natural frequency of the system.

The system has two conjugate-complex poles:

 $$s = -\xi w_n \pm j \sqrt{1-\xi^2} $$

As before, we can calculate the step reponse:

- input $$u(t)=1(t)$$
- output $$y(t) = 1 - \frac{1}{\sqrt{1-\xi^2}} e^{-\xi w_n t} \sin\Big( w_n\sqrt{1-\xi^2}t + arccos(\xi)\Big)$$

_Note: Proving this is a useful exercise_

As before we can plot it.

We will do it for a set of damping ratios: $\xi = [0.1, 0.25, 0.5, 0.7]$ and for a fixed value of $\omega_n=1$.

```
xis = [0.1, 0.25, 0.5, 0.7] # damping ratios
wn = 1 # natural frequency.
```

```
fig = plt.figure(figsize=(15,5))
legend_strs = []
for xi in xis:
    y_t = 1 - (1/np.sqrt(1-xi**2)) * np.exp(-xi*wn*time) * np.sin(wn*np.sqrt(1-xi**2)*time+np.arccos(xi))
    plt.plot(time, y_t, linewidth=4)

# this part is to have a legend
for xi in xis:
    legend_strs.append('xi: ' + str(xi))

fig.legend(legend_strs, loc='upper left')

plt.xlim(0, 20)
plt.title('Step response')
plt.xlabel('time (s)')
plt.grid()
```

There are a few notable points that we can identify for the response of a second order system:

- Rise time (to 90%): $$t_r \approx \frac{1.8}{w_n} $$

- Maximum overshoot: $$ S \% = 100 e^{\Large -\frac{\xi\pi}{\sqrt{1-\xi^2}}} $$

- Time of Maximum overshoot: $$t_{max} = \frac{\pi}{w_n\sqrt{1-\xi^2}} $$

- Settling time (within a desired interval, e.g., 5%): $$t_{s} \approx -\frac{1}{\xi w_n}ln(0.05) $$

- Oscillation period: $$T_{P} = \frac{2\pi}{w_n\sqrt{1-\xi^2}} $$

We can connect $\zeta$ and $w_n$ to $S, T_p, t_{max}, t_s$ with two important caveats:
 - some of the relationships are approximate
 - additional poles and zeros will change the results, so all of the results should be viewed as guidelines.

We can obtain the previous relationships using the _step response_ of $G(s)$ (see above)

Let's see where they are on the plot for one specific choise of $\xi=0.2$ and $\omega_n=1$.

```
xi = 0.2
wn = 1

# Maximum overshoot
S = np.exp(-xi*3.14/(np.sqrt(1-xi**2)))
# Time of maximum overshoot
t_max = 3.14/(wn*np.sqrt(1-xi**2))
# Settling time within 5%
t_s = -1/(xi*wn)*np.log(0.05)
# Period of the oscillations
Tp = 2*3.14/(wn*np.sqrt(1-xi**2))

# time vector
time = np.linspace(0,30,100)

# create the figure
fig = plt.figure(figsize=(15,5))

# overall response y(t)
y_t = 1 - (1/np.sqrt(1-xi**2)) * np.exp(-xi*wn*time) * np.sin(wn*np.sqrt(1-xi**2)*time+np.arccos(xi))
plt.plot(time, y_t, linewidth=4)

# maximum overshoot
plt.plot([t_max, t_max], [y_t[-1], y_t[-1]+S], marker='.', markersize=12)
plt.text(t_max+0.2, y_t[-1]+S/2, 'S', fontsize=15) # text box

# Time of maximum overshoot
plt.plot([t_max, t_max], [0, 1], markersize=12, linestyle='--')
plt.text(t_max+0.2, 0.0, 't_max', fontsize=15) # text box

# Let's also plot +-0.05 boundary lines around y(t)
plt.plot([0, time[-1]], [1-0.05, 1-0.05], linestyle='--', color='k')
plt.plot([0, time[-1]], [1+0.05, 1+0.05], linestyle='--', color='k')

# Settling time within a desired +-0.05 interval
plt.plot([t_s, t_s], [0, 1.1], linestyle='--')
plt.text(t_s+0.2, 0.0, 't_s', fontsize=15) # textbox

# Oscillation period (this is only to show it on the plot)
plt.plot([t_max, t_max+Tp], [y_t[-1]+S+0.1, y_t[-1]+S+0.1], marker='.', linestyle='--', color='k', markersize=10)
plt.plot([t_max+Tp, t_max+Tp], [y_t[-1], y_t[-1]+S+0.1], linestyle='--', color='k')
plt.text((t_max+t_max+Tp)/2, y_t[-1]+S, 'Tp', fontsize=15)

fig.legend(['xi:{:.1f}'.format(xi)], loc='upper left')

plt.title('Step response')
plt.xlabel('time (s)')
plt.grid()
```

---------------------


$ \newcommand{\pd}[2]{ \frac{\partial #1}{\partial #2} }
\newcommand{\od}[2]{\frac{d #1}{d #2}}
\newcommand{\td}[2]{\frac{D #1}{D #2}}
\newcommand{\ab}[1]{\langle #1 \rangle}
\newcommand{\bss}[1]{\textsf{\textbf{#1}}}
\newcommand{\ol}{\overline}
\newcommand{\olx}[1]{\overline{#1}^x}
$

# Advection, Diffusion, and Conservation Laws

## Basin Scale Budgets

As an introduction to this somewhat technical topic, we will first examine the _basin scale_ budgets of _mass, heat, and salt_. This are usually a bit more intuitive to understand.

Consider the volume shown below. It shows the side view of an ocean basin (like the North Atlantic) bounded at the bottom by the sea floor, at the top by the ocean surface, and at the southern edge by an open boundary through which water can flow. The central question here is, _what controls the rate of change of total mass, heat content, and salt inside this volume_?

### Mass Conservation

Let $M$ be the total mass of the ocean in this basin. The rate of change of mass is given by

$$ \od{M}{t} = F_m^{surf} + F_{m}^{side} $$

The two terms on the right represent the mass flux into the volume at the surface and the mass flux into the volume at the side boundary. (Note that the sign coventions we chose for the $F_m$'s was arbitrary: we definied them such that mass will increase if they are positive. This choice dictates the sign in what follows.) At this point the equation is a bit obvious, but let's write what each term is a bit more explicity.

The total mass is given by the integral of the density over the volume (denoted by $V$):

$$ M = \iiint_V \rho dV \ . $$

The mass flux at the surface is given by the integral over the surface of evaporation ($E$, measured in m/s), precipitation ($P$) and runoff ($R$), multiplied by the density of fresh water:

$$ F_m^{surf} = -\iint_{surf} \rho_{fw} (E - P - R) dA \ . $$

The mass flux at the open boundary is given by the area integral of the velocity $\mathbf{u}$ times the density, in the direction normal to the boundary:

$$ F_m^{side} = - \iint_{side} \rho \mathbf{u} \cdot \mathbf{dA} \ .$$

Here $\mathbf{dA}$ is the vector area element which points _out_ of the volume normal to the boundary.

This equation suggests several different possibilities:
- If E-P is _not_ in balance with the net E-P-R, then the total ocean mass in the basin has to increase or decrease.
- In steady state (i.e. time derivative is zero), the net evaporation minus precipitation must balance the mass inflow at the boundary.

It is very common in oceanography to take the mass of the ocean, or of a basin, as constant in time. Although the ocean mass does change slightly on various timescales (most notably through the formation and melting of terrestrial ice sheets), these changes are extremely slight compared to the total ocean mass. In this case, we find

$$ F_m^{surf} = - F_{m}^{side} $$

### Mass Conservation in Boussinesq Approximation

The Boussisesq approximation is that density variations $\delta \rho$ are very small compared to the background density $\rho_0$, i.e.

$$ \rho = \rho _0 + \delta \rho \ ; \ \ |\delta \rho| \ll \rho_0 \ . $$

This approximation simplifies conservations equations considerably. The mass can now be approximated as

$$ M \simeq \iiint_V \rho_0 dV = \rho_0 \iiint_V dV \ . $$

Since $\rho_0$ is a constant, we can divide it out of the mass equation and work with volume instead:

$$ V =  \iiint_V dV \ . $$

The mass equation becomes a volume equation:

$$ \begin{align}
\od{V}{t} =& \frac{F_m^{surf}}{\rho_0} + \frac{F_{m}^{side}}{\rho_0} \\
 =& F_v^{surf} + F_v^{side}
\end{align} $$

with

$$ F_v^{surf} = -\frac{\rho_{fw}}{\rho_0} \iint_{surf} (E - P - R) dA $$

and

$$ F_v^{side} = - \iint_{side} \mathbf{u} \cdot \mathbf{dA} \ . $$

### Heat Conservation

The total heat content of the basin is given by

$$ H = c_p^0 \iiint_V \rho  \Theta dV \ . $$

where $\Theta$ is the conservative temperature (proportional to potential enthalpy).
Some would say that that $\Theta$ should be measured in Kelvins. However, the [first law of thermodynamics](https://en.wikipedia.org/wiki/First_law_of_thermodynamics) only describes the _changes_ in heat content of a system; the absolute value of $H$ is meaningless.

It becomes difficult to reason about heat content when the mass of the system is also changing. For this reason, we will mostly talk about heat transport in situations where the total ocean mass, or the mass of an individual basin under consideration, is constant in time (i.e. $d M / d t = 0$). In these circumstances, an aribitrary constant $\Theta_{ref}$ can be added to $\Theta$ without changing  $d H / d t$, as can be seen by expanding $H$:

$$
\od{}{t} \left [ c_p^0 \iiint_V \rho (\Theta + \Theta_{ref}) dV \right ]
=  c_p^0 \od{}{t} \iiint_V \rho \Theta dV + c_p^0 \Theta_{ref} \od{M}{t}\ .
$$

Consequently, any conclusions we make about heat transport should be unchanged under the transformation $\Theta \to \Theta + \Theta _{ref}$.

The rate of change of heat content depends only on external fluxes into the system:

$$ \od{H}{t} = F_H^{surf} + F_H^{side} \ . $$

The surface flux is the air-sea flux $Q$ we discussed earlier, plus the advection of heat by the evaporating / precipitating water:

$$ F_H^{surf} = \iint_{surf} Q dA - c_p^0 \iint_{surf} \rho_{fw} \Theta (E - P - R) dA $$

where $Q$ is defined as positive for downward heat flux. The second term represents the advetion of heat into / out of the ocean surface by evaporating / precipitating water. Neglecting diffusive fluxes (for now), the flux through the side is

$$ F_H^{side} = -  c_p^0 \iint \mathbf{u} \rho \Theta \cdot \mathbf{dA} $$

An important but subtle point is that _the individual magnitudes of the advective term in $F_H^{surf}$ and $F_H^{side}$ are meaningless._ Only together can we understand these terms impact on the heat content on this basin. If we add an arbitrary constant $ \Theta_{ref} $ to $ \Theta $, we get:

$$ F_H^{surf} = \iint_{surf} Q dA - c_p^0 \iint_{surf} \rho_{fw} \Theta (E - P - R) dA -
c_p^0 \Theta_{ref} \iint_{surf} \rho_{fw} (E - P - R) $$

and

$$ F_H^{side} = -  c_p^0 \iint_{side} \mathbf{u} \rho \Theta \cdot \mathbf{dA} -
c_p^0 \Theta_{ref} \iint_{side} \mathbf{u} \rho \cdot \mathbf{dA}$$

At first glance, by adding a constant to $\Theta$, we have changed the magnitude of the heat transport. However, upon adding these terms together, we see that the terms proportional to $\Theta_{ref}$ cancel, because $F_m^{side} + F_m^{surf} = 0$. This observation tells us that we can only assess the contribution of the circulation to the heat budget for _circulations that conserve mass_. Attempting to break down this contribution into a sum of different components can lead to spurious conclusions, since these individual components potentially depend on an arbitrary reference temperature $\Theta_{ref}$.

### Heat Conservation in Boussinesq Approximation

Under the Boussinesq approximation,

$$ H = c_p^0 \rho_0 \iiint_V \Theta dV \ . $$

The fluxes simplify to

$$ F_H^{surf} = \iint_{surf} Q dA - c_p^0 \frac{ \rho_{fw} }{\rho_0} \iint_{surf} \Theta (E - P - R) dA $$

and

$$ F_H^{side} = -  c_p^0 \rho_0 \iint_{side} \mathbf{u} \Theta \cdot \mathbf{dA} \ . $$

Volume conservation ($d V / dt = 0$) accompanies heat conservation to render it independent of $\Theta_{ref}$.

### Salt and Freshwater Conservation

Since negligible salt is exchanged with the atmosphere (or sea ice), the basin-scale budget for salinity is

$$\mathcal{S} = \iiint_V \rho S dV $$

$$ \frac{d \mathcal{S}}{dt} = F_S^{side} $$

with

$$ F_S^{side} = -  \iint_{side} \mathbf{u} \rho S \cdot \mathbf{dA} \ . $$

In steady state this simply states that the net salt flux through a closed section is zero.

We can construct the freshwater balance by subtracting the mass balance. Assuming steady state, we find

$$ F_M^{side} - F_S^{side} = -F_M^{surf}. $$

This equation can be simplified to

$$ F_{fw}^{side} = - \iint_{side} \mathbf{u} \rho(1 - S) \cdot \mathbf{dA}
   = \iint_{surf} \rho_{fw} (E - P - R) dA $$

which represents the next freshwater flux through the basin. In the Boussinesq approximation, we have

$$ F_{fw}^{side} = - \rho_0 \iint_{side} \mathbf{u} (1 - S) \cdot \mathbf{dA}
   = \rho_{fw}  \iint_{surf}(E - P - R) dA $$

For a basin with a closed volume budget ($F_V^{side} = 0$), the first term in parenthesis vanishes, and we obtain

$$ F_{fw}^{side} = - F_S^{side} $$

i.e. the freshwater transport is equal and opposite to the salt transport.

## Micro-scale View

The integral budgets above illustrate the concept of heat conservation at the basin scale. We now move down-scale to consider an _infintessimally small_ element of fluid. These differential equations are the most general and flexible way to describe the conservation of mass, heat, and salt.

## Material Derivative #

The "water parcel" is hypothetical, infinitesimally small fluid element. We imagine the fluid to be composed of an infinite continuum of such parcels, each following its own unique path.
A fluid parcel position at a given time $t$ is given by a three dimensional vector $\mathbf{x}(x,y,z,t)$. The instantaneous rate of change of the position of such a fluid element defines the fluid velocity:

$$
\mathbf{u}  = (u, v, w)\equiv \od{\mathbf{x}}{t}
$$

One way to do fluid mechanics is to keep track of all such fluid elements as they move around; this is called the _Lagrangian_ approach. In Lagrangian fluid mechanics, the rate of change of some fluid property $c$ is just $dc / dt$; it is implicit that we are following the water parcel along its path through space. As you might imagine, Lagrangian fluid mechanics is very difficult because there is actually an infinite number of such paths, and the trajectories quickly become extremely complex. This approach is also incompatible with the way we usually measure the ocean, which involves taking samples at a fixed point in space.

The alternative is the _Eulerian_ approach. Under this approach, the rate of change of a fluid parcel property is proportional to the _local_ rate of change of that property, plus a contribution from the fluid flow transporting that property around (called advection). We can see this mathemtically by using the chain rule:

$$ \od{}{t} c(\mathbf{x}, t) = \pd{c}{t} + \od{\mathbf{x}}{t} \cdot \nabla c
= \left ( \pd{}{t} + \mathbf{u} \cdot \nabla \right ) c \ . $$

The quantity in parentheses is called the _material derivative_. To distinguish it from an ordinary or partial derivative, we often denote it with a capital $D$:

$$ \td{}{t} = \pd{}{t} + \mathbf{u} \cdot \nabla  $$

It represents a rate of change following a fluid parcel, but in the Eulerian frame of reference.

```python
#The acceleration in Newton's second law involves the _time rate of change_ of momentum. When we first learn about mechanics, we study the motion of discrete particles (e.g.~pool balls, cannonballs, etc.), for which momentum and its rate of change is easy to define. Fluid mechanics, a branch of _continuum mechanics_, confronts a much more difficult situation. There are no "particles" to follow; i
```

### Mass Conservation: Continuity Equation

The mass budget of an infinitesimally small fluid parcel is

$$ \pd{\rho}{t} + \nabla \cdot (\rho \mathbf{u}) = 0 \ . $$

This equation is called the _continuity equation_.

Using some [vector calculus identities](https://en.wikipedia.org/wiki/Vector_calculus_identities), it can be rewritten as.

$$ \pd{\rho}{t} + \mathbf{u} \cdot \nabla \rho + \rho \nabla \cdot \mathbf{u} = 0  $$

or, using the definition of material derivative

$$ \td{\rho}{t} + \rho \nabla \cdot \mathbf{u} = 0  $$

### Continuity Equation in Boussinesq Approximation

In the Boussinesq approximation, fluctuations in density are much smaller than the background density. If we expand the continuity equation interms of background and fluctuations, we obtain

$$ \td{\rho_0}{t} +  \td{\delta \rho}{t} + \rho_0 \nabla \cdot \mathbf{u} + \delta \rho \nabla \cdot \mathbf{u} = 0 $$

The first terms is zero, because $\rho_0$ is constant. Of the remaining nonzero terms, only one has a $\rho_0$ in it. As a "first order" approximation, we find that

$$ \rho_0 \nabla \cdot \mathbf{u} \simeq 0  \ .$$

At this point, we are free to drop the $\rho_0$ factor to give the Boussinesq volume continuity equation:

$$ \nabla \cdot \mathbf{u} = 0  \ .$$

This is also called the incompressibility equation. In the Boussinesq approximation, volume conservation replaced mass conservation.

### Heat Conservation

The left-hand side of the heat conservation equation, representing the rate of change of the heat content of a fluid parcel is

$$ c_p^0 \left [ \pd{}{t}(\rho \Theta) + \nabla \cdot ( \rho \Theta \mathbf{u} ) \right ]$$

which we can rewrite using the continuity equation as

$$ c_p^0 \rho \td{\Theta}{t} \ . $$

The right hand side, representing all non-conservative effects, can be written as the sum of the convergence of a molecular diffusive flux $\mathbf{Q}_{diff}$ and a radiative flux $Q_{rad}$:

(Note we have assumed that the radiative flux is only in the vertical direction, a very reasonable approximation for large-scale processes.)

The diffusive flux is given by [Fick's law of diffusion](https://en.wikipedia.org/wiki/Fick%27s_laws_of_diffusion):

$$ \mathbf{Q}_{diff}  = - c_p^0 \rho \kappa_T \nabla T $$

where $T$ is the _in-situ_ temperature and $\kappa_T$ is the molectular diffusivity of heat. The air-sea heat flux acts as a boundary condition for the diffusive flux.

In the Boussinesq approximation, we replace the $\rho$'s above with $\rho_0$ to obtain:

$$ \td{\Theta}{t} =  \kappa_T \nabla^2 T  - \frac{1}{\rho_0 c_p^0}\pd{Q_{rad}}{z} \ .$$

One complictation of this equation is that molecular diffusion acts on $T$, not $\Theta$. However, we will mostly be thinking about turbulent, rather than molecular, diffusion, for which this is not an issue.

### Salt Conservation

Neglecting the exchange of salt with the surface, salinity is governed just by advection and diffusion

$$  \rho \td{S}{t} =  \nabla \cdot ( \rho \kappa_S \nabla S ) $$

which reduces to

$$ \td{S}{t} =   \kappa_S  \nabla^2 S  $$

in the Boussinesq approximation.

```python

```


```python
from sympy import *
init_printing(use_latex='mathjax')
x, y, z = symbols('x,y,z')
n, m = symbols('n,m', integer=True)
```

```python
%matplotlib inline
```

# Numeric Evaluation

In this section we'll learn how to use our symbolic equations to drive numeric computations

## `.subs` and `.evalf`

The simplest (and slowest) ways to evaluate an expression numerically is with the `.subs` and `.evalf` methods

```python
sin(x)
```

```python
sin(x).subs({x: 0})
```

```python
acos(x).subs({x: -1})
```

```python
acos(x).subs({x: -1}).evalf()
```

```python
acos(x).subs({x: -1}).evalf(n=100)
```

### Exercise

In a previous section we computed the following symbolic integral

$$ \int_y^z x^n dx $$

```python
result = integrate(x**n, (x, y, z))
result
```

Use `.subs` and a dictionary with keys `n, y, z` to evaluate this result when

    n == 2
    y == 0
    z == 3

```python
# Evaluate the resulting integral on the above values

```

### Exercise

This integral takes on a special form when $n = -1$.  Use subs to find the expression when

    n == -1
    y == 5
    z == 100

Then use `.evalf` to evaluate this subs-ed expression as a float.

```python
# Evaluate the resulting integral for the values {n: -1, y: 5, z: 100}
# Then use evalf to get a numeric result

```

## `lambdify`

The `.subs` and `.evalf` methods are great for when you want to evaluate an expression at a single point.  When you want to evaluate your expression on lots of points they quickly become slow.

To resolve this problem SymPy can rewrite its expressions as normal Python functions using the `math` library, vectorized computations using the NumPy library, C or Fortran Code using code printers, or even more sophisticated systems.

We'll talk about some of the more advanced topics later.  For now, `lambdify`...

```python
# function = lambdify(input, output)

f = lambdify(x, x**2)
f(3)
```

```python
import numpy as np
f = lambdify(x, x**2, 'numpy')  # Use numpy backend
data = np.array([1, 2, 3, 4, 5])
f(data)
```

### Exercise

Here is a radial wave function for the Carbon atom at $n=3$, $l=1$

```python
from sympy.physics.hydrogen import R_nl
n = 3
l = 1
r = 6 # Carbon
expr = R_nl(n, l, x, r)
expr
```

Create a function, `f`, that evaluate this expression using the `'numpy'` backend

```python
# Create Numpy function mapping x to expr with the numpy backend

```

We can plot your function from $x \in [0, 5]$ with the following numpy/matplotlib code

```python
from matplotlib.pyplot import plot
nx = np.linspace(0, 5, 1000)
plot(nx, f(nx))
```

### Exercise

Create a numpy function that computes the derivative of our expression.  Plot the result alongside the original.

```python
# Compute derivative of expr with respect to x

```

```python
# Create new fprime function using lambdify

```

```python
# Plot results alongside f(nx)

```