 Text provided under a Creative Commons Attribution license, CC-BY. All code is made available under the FSF-approved MIT license. (c) Kyle T. Mandli

Note:  The presentation below largely follows part II in "Finite Difference Methods for Ordinary and Partial Differential Equations" by LeVeque (SIAM, 2007).

```python
from __future__ import print_function

%matplotlib inline
import numpy
import matplotlib.pyplot as plt
```

# Numerical Solution to ODE Initial Value Problems - Part 2

# Overview -- so far

So far we have discussed 3 basic techniques for integration of ODE IVP's

* **Single-Step Multi-Stage** schemes (explicit and implicit)
* **Taylor's Series** Methods
* **Linear Multi-Step** Schemes (explicit and implicit)

as well as
* **truncation error** of each method (and its relation to step-error)
* **adaptive stepping**

# Overview -- to do

This notebook will take the analysis of these methods to discuss the other important issues of
* **Convergence** (Global Error) -- does the discrete solution converge to the continuous analytic solution?
* **Stability** -- Are there limits to how large a time-step we can take?
* **Stiff Equations**:  consequences of stability

# Convergence

We can think of an ODE method as providing a sequence of approximations $U_N$ where here $N$ is the number of time steps needed to reach the final time of interest $t_f$, in effect we are increasing the resolution of the method.  We then say that a method is convergent if this sequence converges to the true solution at the same time
$$
    \lim_{N\rightarrow \infty} U_N = u(t_f).
$$

We can also define this in a more familiar way in terms of $\Delta t$ such that
$$
    N \Delta t = t_f ~~~~ \Rightarrow ~~~~ N = \frac{t_f}{\Delta t}
$$
so that our definition of convergence becomes
$$
    \lim_{\Delta t \rightarrow 0} U_{\Delta t} = u(t_f).
$$

In general for a method to be convergent it must be
 - **consistent** which as before meant that the local truncation error $T = \mathcal{O}(\Delta t^p)$ where $p > 0$,
 - **zero-stable** which implies that the sum total of the errors as $\Delta t \rightarrow 0$ is bounded and has the same order as $T$ (the truncation error) which we know goes to zero as $\Delta t \rightarrow 0$.

put another way,  a method is convergent if the **global error**

$$
    E = | U_N(t_f) - u(t_f)| \rightarrow 0\quad\mathrm{as}\quad N\rightarrow\infty
$$

### Example:  Forward Euler on a Linear Problem

Consider the simple linear problem
$$
    \frac{\text{d}u}{\text{d}t} = \lambda u \quad \text{with} \quad u(0) = u_0
$$
which we know has the solution $u(t) = u_0 e^{\lambda t}$.  Applying Euler's method to this problem leads to
$$\begin{aligned}
    U_{n+1} &= U_n + \Delta t\lambda U_n \\
            &= (1 + \Delta t \lambda) U_n
\end{aligned}$$

We also know the local truncation error is defined by
$$\begin{aligned}
    T_n &= \frac{1}{\Delta t} \left[ U_{n+1} - u_{n+1} \right ] \\
        &= \frac{1}{\Delta t} \left[ (1 + \lambda \Delta t) u_n - u_{n+1} \right ]
\end{aligned}$$
which can be rearranged to find

$$
    u_{n+1} = (1 + \Delta t \lambda) u_n - \Delta t T_n.
$$

Note that all values here are in terms of the exact solution where as the application of Euler's method is in terms of the approximate solution.

Defining the global error as
$$
    E_{n} = u_n - U_n
$$

we can subtract the last two expressions to find

$$\begin{aligned}
    u_{n+1} - U_{n+1} &= \underbrace{(1 + \Delta t \lambda) u_n - \Delta t T_n}_{u_{n+1}} - \underbrace{(1 + \Delta t \lambda) U_n}_{U_{n+1}} \\
    E_{n+1} &= (1 + \Delta t \lambda) (u_n - U_n) - \Delta t T_n \\
            &= (1 + \Delta t \lambda) E_n - \Delta t T_n,
\end{aligned}$$

a recursive definition for the global error that connects it to the local truncation error.

Working backwards from the global error $E_n$ at maximum time step $n = t_f/\Delta t$,  the first few iterates of this look like

$$\begin{align}
    E_n &= (1 + \lambda \Delta t) E_{n - 1} - \Delta t T_{n-1} \\
    &= (1 + \lambda \Delta t) \left [(1 + \lambda \Delta t) E_{n - 2} - \Delta t T_{n-2} \right ] - \Delta t T_{n-1} \\
    &= (1 + \lambda \Delta t) \left [(1 + \lambda \Delta t) \left \{(1 + \lambda \Delta t) E_{n - 3} - \Delta t T_{n-3} \right \} - \Delta t T_{n-2} \right ] - \Delta t T_{n-1}
\end{align}$$

Which at this point looks like
$$
    E_n = (1 + \lambda \Delta t)^3E_{n-3} - \Delta t\sum_{i=1}^3 (1+\Delta t\lambda)^{i-1}T_{n-i}
$$

So taking it back $n$ steps ($3\rightarrow n$) gives
Which at this point looks like
$$
    E_n = (1 + \lambda \Delta t)^nE_0 - \Delta t\sum_{i=1}^n (1+\Delta t\lambda)^{i-1}T_{n-i}
$$

Expanding this expression out backwards in time to $n=0$ leads to
$$
    E_n = (1 + \Delta t \lambda)^n E_0 - \Delta t \sum^n_{i=1} (1 + \Delta t \lambda)^{i-1} T_{n - i}.
$$

Noting that $(1 + \Delta t \lambda)$ are the first two terms in the Taylor series for $e^{\lambda\Delta t}$ we will bound this term by

$$
    e^{\Delta t \lambda} \geq |1 + \Delta t \lambda|
$$

which then implies the term in the summation can be bounded by

$$
    |1 + \Delta t \lambda|^{n - 1} \leq e^{(n-1) \Delta t |\lambda|} \leq e^{n \Delta t |\lambda|} \leq e^{|\lambda| t_f}
$$

Using this expression in the expression for the global error we find
$$\begin{aligned}
    E_n &= (1 + \Delta t \lambda)^n E_0 - \Delta t \sum^n_{i=1} (1 + \Delta t \lambda)^{i-1} T_{n-i} \\
    |E_n| &\leq e^{|\lambda| \Delta t n} |E_0| + \Delta t \sum^n_{i=1} e^{|\lambda| t_f} |T_{n-i}| \\
          &\leq e^{|\lambda| t_f} \left(|E_0| + \Delta t \sum^n_{i=1} |T_{n - i}|\right) \\
          &\leq e^{|\lambda| t_f} \left(|E_0| + n \Delta t \max_{1 \leq i \leq n} |T_{n - i}|\right)
\end{aligned}$$
or

$$
\begin{aligned}
E_n &\leq e^{|\lambda| t_f} \left(|E_0| + t_f \max_{1 \leq i \leq n} |T_{n - i}|\right)
\end{aligned}
$$

In other words the global error is bounded by the original global error and the maximum one-step error made multiplied by the number of time steps taken.  If $N = \frac{t_f}{\Delta t}$ as before and taking into account the local truncation error we can simplify this expression further to

$$
    |E_n| \leq e^{|\lambda| t_f} \left[|E_0| + t_f \left(\frac{1}{2} \Delta t |u''| + \mathcal{O}(\Delta t^2)\right ) \right]
$$

If we assume that we have used the correct initial condition $u_0$ then $E_0 \rightarrow 0$ as $\Delta t \rightarrow 0$ and we see that the global error $E_n$ is bounded by $\mathcal{O}(\Delta t)$, the same as the local truncation error:
$$
    |E_n| \leq e^{|\lambda| t_f} t_f \left(\frac{1}{2} \Delta t |u''| + \mathcal{O}(\Delta t^2)\right ) = \mathcal{O}(\Delta t).
$$

More generally,  it can be shown that for all single-step multi-stage schemes (of which Euler is the simplest), the Global error has the same order as the Truncation error, all of which scale as

$$
    \Delta t^p,\quad p>0
$$

and therefore all of these schemes converge.

## Absolute Stability
Although zero-stability guarantees stability it is much more difficult to work with in general as the limit $\Delta t \rightarrow 0$ can be difficult to compute (and in general isn't ideal as it requires an infinite amount of computation).  Instead we often consider a finite $\Delta t$ and examine if the method is stable for this particular choice of $\Delta t$.  This has the practical upside that it will also tell us what particular $\Delta t$ will ensure that our method is indeed stable.

### Example
Consider the problem
$$
    u'(t) = \lambda (u - \cos t) - \sin t ~~~~ \text{with} ~~~~ u(0) = 1
$$

whose exact solution is
$$
    u(t) = \cos t.
$$

We can compute an estimate for what $\Delta t$ we need to use by examining the truncation error for Euler's method

$$\begin{aligned}
    T &= \frac{1}{2} \Delta t u''(t) + \mathcal{O}(\Delta t^2) \\
      &= -\frac{1}{2} \Delta t \cos t + \mathcal{O}(\Delta t^2)
\end{aligned}$$

and therefore
$$
    |E_n| \leq \Delta t \max_{0 \leq t \leq t_f} |\cos t| = \Delta t.
$$

If we want a solution where $|E_n| < 10^{-3}$ then $\Delta t \approx 10^{-3}$.  Turning to the application of Euler's method lets apply this to the case where $\lambda = -10$ and $\lambda = -2100$.

```python
# Implement vectorized Forward Euler
def euler(f, t_span, u0, N):
    """ simple implementation of constant step-size forward euler method for vector valued f
        This doc string should have so much more in it
    """
    t = numpy.linspace(t_span[0], t_span[1],N)
    if numpy.isscalar(u0):
        u = numpy.empty(t.shape)
    else:
        u = numpy.empty((len(t),len(u0)))
    u[0] = u0
    delta_t = t[1] - t[0]
    for (n, t_n) in enumerate(t[:-1]):
        K1 = delta_t * f(t_n, u[n])
        u[n + 1] = u[n] + K1
    return t, u.transpose()
```

```python
# Compare accuracy between Euler for different values of lambda
f = lambda t, u, lam: lam * (u - numpy.cos(t)) - numpy.sin(t)
u_exact = lambda t: numpy.cos(t)
u_0 = u_exact(0.)

t_span = [0., 2.]
num_steps = [2**n for n in range(4, 9)]
```

```python
#num_steps = [2**n for n in range(15,20)]
delta_t = numpy.empty(len(num_steps))
error_10 = numpy.empty(len(num_steps))
error_2100 = numpy.empty(len(num_steps))

t_f = t_span[1]
u_f = u_exact(t_f)

for (i, N) in enumerate(num_steps):
    # Compute Euler solution
    ff = lambda t, u: f(t, u, -10.)
    t, U_10 = euler(ff, t_span, u_0, N)
    delta_t[i] = t[1] - t[0]
    error_10[i] = numpy.abs(U_10[-1] - u_f) / numpy.abs(u_f)
    ff = lambda t, u: f(t, u, -2100.)
    t, U_2100 = euler(ff, t_span, u_0, N)
    error_2100[i] = numpy.abs(U_2100[-1] - u_f) / numpy.abs(u_f)

print('Error: lambda =   10: {}'.format(error_10))
print('Error: lambda = 2100: {}'.format(error_2100))

# Plot error vs. delta_t
fig = plt.figure()
fig.set_figwidth(fig.get_figwidth() * 2)
axes = fig.add_subplot(1, 2, 1)

axes.loglog(delta_t, error_10, 'bo', label='Forward Euler')

order_C = lambda delta_x, error, order: numpy.exp(numpy.log(error) - order * numpy.log(delta_x))
axes.loglog(delta_t, order_C(delta_t[1], error_10[1], 1.0) * delta_t**1.0, 'r--', label="1st Order")

axes.legend(loc=4)
axes.set_title("Error - $\lambda = 10$")
axes.set_xlabel("$\Delta t$")
axes.set_ylabel("$|U(t_f) - u(t_f)|$")

axes = fig.add_subplot(1, 2, 2)

axes.loglog(delta_t, error_2100, 'bo', label='Forward Euler')
axes.loglog(delta_t, order_C(delta_t[1], error_2100[1], 1.0) * delta_t**1.0, 'r--', label="1st Order")

axes.set_title("Error - $\lambda = 2100$")
axes.set_xlabel("$\Delta t$")
axes.set_ylabel("$|U(t_f) - u(t_f)|$")

plt.show()
```

So what went wrong with $\lambda = -2100$, the global error should go as

$$
    E_n = \mathcal{O}(\Delta t)?
$$

If $\Delta t \approx 10^{-3}$ then for the case $\lambda = -10$ the previous **global error** is multiplied by $1+\lambda\Delta t$

$$1 + 10^{-3} \cdot -10 = 0.99$$

which means the contribution from $E^n$ will slowly decrease as we take more time steps.  For the other case we have

$$1 + 10^{-3} \cdot -2100 = -1.1$$

which means that for this $\Delta t$ the error made in previous time steps will grow!  For this not to happen we would have to have $\Delta t < 1 / 2100$ which would lead to convergence again.

### A simple example
Consider our simplest test problem
$$
    u'(t) = \lambda u, \quad u(0) = u_0
$$

whose exact solution is
$$
    u(t) = u_0 e^{\lambda t}.
$$
which for real $\lambda < 0$ should decay to zero as $t\rightarrow\infty$

### Question?

What is the **largest** step-size $\Delta t$ for an Euler method, such that the discrete solution $U_N\rightarrow 0$ as $N\rightarrow\infty$?

Euler's method on this simplest problem can be written

$$
\begin{align}
    U_{n+1} &= U_n + \Delta t f(U_n) = U_n + \Delta t \lambda U_n\\
            &= (1 + \lambda\Delta t)U_n\\
\end{align}
$$

i.e. at each step the last solution is multiplied by the factor $(1 + \lambda\Delta t)$

After $N$ steps

$$
    U_N = (1 + \lambda\Delta t)^N U_0
$$

Assuming $\lambda < 0$ what is the largest step size $\Delta t$ such that $U_N\rightarrow 0$ as $N\rightarrow\infty$?

Hopefully it's clear that for this scheme to be stable requires

$$
    |1 + \lambda\Delta t| < 1
$$

The key parameter is actually the combination of $\lambda$ and the time step $z=\lambda\Delta t$, so stability requires that

$$
    | 1 + z | < 1
$$
or  𝑧∈[?,?]

```python
plt.figure(figsize=(8,6))
z = numpy.linspace(-3,2,200)
plt.plot(z,numpy.abs(1 + z))
plt.plot(z,numpy.ones(z.shape),'k--')
plt.plot([0.,0],[0.,3.])
plt.xlabel('$z$',fontsize=16)
plt.ylabel('$|1 + z|$',fontsize=16)
plt.grid()
plt.show()

```

Given $z\in[-2,0]$,  the  time step is simply

$$
    \Delta t = \frac{z}{\lambda}
$$
so the maximum time step we can take for $\lambda<0$ is

$$
    \Delta t = -\frac{2}{\lambda} > 0
$$

```python
# Compare accuracy between Euler for different values of time steps and $\lambda = -1$
lam = -1.
f = lambda t, u :  lam * u
u_exact = lambda t: numpy.exp(lam * t)
u_0 = u_exact(0.)

t_span = [0., 10.]

N = 21
#N = 11
#N = 9
#N = 6
#N = 5
abs_z = numpy.abs(lam*t_span[-1]/(N-1))
print('Abs(z)={}'.format(abs_z))
t, u = euler(f, t_span, u_0, N)

```

```python
tt = numpy.linspace(0.,t_span[1],100)
fig = plt.figure(figsize=(8,6))
axes = fig.add_subplot(1,1,1)
axes.plot(tt, u_exact(tt),label='$u_{{exact}}$')
axes.plot(t, u, 'ro-', label='$u_{{euler}}$')
axes.set_xlabel('t')
axes.set_title('$\Delta t = {}$, $|z|={}$'.format(t[1]-t[0],abs_z), fontsize=18)
axes.grid()
axes.legend(loc='best',fontsize=14)
plt.show()

```

### Absolute Stability of the Forward Euler Method for complex $z$

The region $|1 + z| < 1$  is the **region of absolute stability**.  For more general problems $\lambda$ may be complex (stemming from eigenvalues of real problems),  so we generally consider the region of stability on the complex plane.

$$
    | 1 + z | < 1
$$
for $z\in\mathbb{C}$

```python
# Plot the region of absolute stability for Forward Euler
fig = plt.figure(figsize=(8,6))
axes = fig.add_subplot(1, 1, 1)

t = numpy.linspace(0.0, 2.0 * numpy.pi, 100)

axes.fill(numpy.cos(t) - 1.0, numpy.sin(t), color=(255.0/255.0,145.0/255.0,0/255.0,1.0))
axes.plot([-3, 3],[0.0, 0.0],'k--')
axes.plot([0.0, 0.0],[-3, 3],'k--')
axes.set_xlim((-3, 3.0))
axes.set_ylim((-3,3))
axes.set_aspect('equal')

axes.set_xlabel('Re(z)')
axes.set_ylabel('Im(z)')

axes.set_title("Absolute Stability Region for Forward Euler")
plt.show()
```

### Using the stability plots to choose a stable $\Delta t$

If you know the spectrum of eigenvalues $\lambda$ for a given problem,  you can plot them on the stability diagram and then find the value of $\Delta t$ such that all $z_i = \lambda_i\Delta t$ are within the stability region.

E.g. consider Euler's method on a single eigenvalue
$$
    \lambda = -2 + 2i
$$
with modulus $|\lambda| = 2\sqrt{2}$ which  plots outside of the stability region.

```python
# Plot the region of absolute stability for Forward Euler
fig = plt.figure(figsize=(8,6))
axes = fig.add_subplot(1, 1, 1)

lam = (-2,2)
t = numpy.linspace(0.0, 2.0 * numpy.pi, 100)

axes.fill(numpy.cos(t) - 1.0, numpy.sin(t), color=(255.0/255.0,145.0/255.0,0/255.0,1.0))
axes.plot(lam[0],lam[1],'rx',markersize=8)
axes.plot((lam[0], 0.), (lam[1], 0.), 'r--')
axes.plot([-3, 3],[0.0, 0.0],'k--')
axes.plot([0.0, 0.0],[-3, 3],'k--')
axes.set_xlim((-3, 3.0))
axes.set_ylim((-3,3))
axes.set_aspect('equal')

axes.set_xlabel('Re(z)')
axes.set_ylabel('Im(z)')

axes.set_title("Absolute Stability Region for Forward Euler")
plt.show()
```

However $z=\lambda\Delta t$ so
$$|z|=\Delta t|\lambda|$$

Thus absolute stability requires that we find a time-step such that $|1 + z| < 1$. If we just look for the intersection with the boundaries of the stability region

$$
\begin{align}
    | 1 +z| &= | 1 +\Delta t(-2+2i) | = 1\\
    & = \sqrt{ (1-2\Delta t)^2 + 4\Delta t^2} = 1 \\
\end{align}
$$

implies (after a little work) that

$$
    \Delta t(2\Delta t - 1) = 0
$$
or $\Delta t \in (0, 1/2)$

```python
# Plot the region of absolute stability for Forward Euler
fig = plt.figure(figsize=(8,6))
axes = fig.add_subplot(1, 1, 1)

lam = (-2,2)
t = numpy.linspace(0.0, 2.0 * numpy.pi, 100)

axes.fill(numpy.cos(t) - 1.0, numpy.sin(t), color=(255.0/255.0,145.0/255.0,0/255.0,1.0))
axes.plot(lam[0],lam[1],'rx',markersize=8)
axes.text(lam[0],1.1*lam[1], '$\lambda$',fontsize=18)
axes.plot(0.5*lam[0],0.5*lam[1],'kx',markersize=8)
axes.plot(0.5*lam[0],0.5*lam[1],'kx',markersize=8)
axes.text(0.5*lam[0],0.6*lam[1], '$\Delta t = 1/2$', fontsize=18)
axes.plot((lam[0], 0.), (lam[1], 0.), 'r--')
axes.plot([-3, 3],[0.0, 0.0],'k--')
axes.plot([0.0, 0.0],[-3, 3],'k--')
axes.set_xlim((-3, 3.0))
axes.set_ylim((-3,3))
axes.set_aspect('equal')

axes.set_xlabel('Re(z)')
axes.set_ylabel('Im(z)')

axes.set_title("Absolute Stability Region for Forward Euler")
plt.show()
```

### Why complex eigenvalues?  And why should we care?

#### A simple example:  The simple harmonic oscillator

The linear harmonic oscillator can be written as a 2nd-order ODE for position $x$ of a mass on a spring
$$
    \frac{d^2 x}{dt^2} + x = 0, \quad x(0)=x_0,\,  \frac{dx}{dt} = 0.
$$
Which has solutions
$$
    x(t) = x_0\cos(t)
$$

Alternatively, we can write this as a 2 dimensional system of first order equations by  defining the velocity $v= dx/dt$ and
$$
\begin{align}
    \frac{dx}{dt} &= v \\
    \frac{dv}{dt} &= -x \\
\end{align}
$$
or more cleanly as
$$
    \frac{d\mathbf{u}}{dt} = A\mathbf{u},\quad\mathbf{u}(0)=\mathbf{u_0}
$$
with
$$
\mathbf{u}=\begin{bmatrix} x\\ v\\\end{bmatrix}\quad A= \begin{bmatrix} 0 & 1 \\ -1 & 0 \\ \end{bmatrix}\quad\mathbf{u}_0=\begin{bmatrix} x_0\\ 0\\\end{bmatrix}
$$

All diagonalizable linear dynamical systems have solutions of the form

$$
    \mathbf{u}(t) = \sum_{i=1}^n c_i e^{\lambda_i t}\mathbf{s}_i
$$

where $\lambda_i$, $\mathbf{s}_i$ are corresponding eigenvalues and eigenvectors of $A$ and each eigenvector satisfies
$$
    \frac{d\mathbf{s}_i}{dt} = \lambda_i\mathbf{s}_i
$$
which is exactly our model problem.

In the case where
$$
A= \begin{bmatrix} 0 & 1 \\ -1 & 0 \\ \end{bmatrix}
$$

The eigenvalues are _ _ _ _ _ _ _ _ ?

Even with complex Eigenvalues and Eigenvectors, the solutions are real and can be shown to be perfect circles on the phase plane

$$
\begin{align}
    x(t) &= \cos(t)\\
    v(t) &=-\sin(t)\\
\end{align}
$$

However,  the plotting the eigenvalues on the stability diagram show's that Euler's method will be completely unstable for this problem for all time steps $\Delta t$

```python
# Plot the region of absolute stability for Forward Euler
x = numpy.linspace(-2, 2, 11)
v = numpy.linspace(-2, 2, 11)
X,V = numpy.meshgrid(x,v)
t = numpy.linspace(0.0, 2.0 * numpy.pi, 100)

fig = plt.figure(figsize=(16,6))
axes = fig.add_subplot(1,2,1)
axes.quiver(X, V, V, -X)
axes.plot(1.5*numpy.cos(t), -1.5*numpy.sin(t))
axes.plot([-2, 2],[0.0, 0.0],'k--')
axes.plot([0.0, 0.0],[-2, 2],'k--')
axes.set_xlabel('x',fontsize=16)
axes.set_ylabel('v',fontsize=16)

axes.set_title('Phase Plane: simple Harmonic oscillator',fontsize=16)
axes.set_aspect('equal')

axes = fig.add_subplot(1, 2, 2)
axes.fill(numpy.cos(t) - 1.0, numpy.sin(t), color=(255.0/255.0,145.0/255.0,0/255.0,1.0))
axes.plot([-3, 3],[0.0, 0.0],'k--')
axes.plot([0.0, 0.0],[-3, 3],'k--')
axes.plot([0., 0.0], [-1., 1.],'rx',markersize=10)
axes.set_xlim((-3, 3.0))
axes.set_ylim((-3,3))
axes.set_aspect('equal')

axes.set_title("Absolute Stability Region for Forward Euler",fontsize=16)
plt.show()
```

```python
f = lambda t, u : numpy.array([ u[1], -u[0]])
t_span = [0., 4*numpy.pi]
u_0 = numpy.array([1., 0.])
N = 100
t, u = euler(f,t_span,u_0, N)
```

```python
fig = plt.figure(figsize=(8,6))
axes = fig.add_subplot(1,1,1)
axes.quiver(X, V, V, -X)
axes.plot(1.*numpy.cos(t), -numpy.sin(t))
axes.plot(u[0],u[1],'r')
axes.plot([-2, 2],[0.0, 0.0],'k--')
axes.plot([0.0, 0.0],[-2, 2],'k--')
axes.set_xlabel('x',fontsize=16)
axes.set_ylabel('v',fontsize=16)

axes.set_title("Euler's method: simple Harmonic oscillator", fontsize=16)
axes.set_aspect('equal')
```

### Absolute Stability of Single-Step Multi-Stage schemes -- The R method

This approach can be applied to **all** Single-Step Multi-Stage schemes including all the Runge-Kutta and Taylor Series methods.  The recipe is straightforward

*  Apply the stepping scheme for one step of the model problem $u' = \lambda u$, $u(0)=u_0$ assuming complex $\lambda$
*  This will result in a discrete approximation

$$
    U(\Delta t) = R(z) U_0
$$

where $z=\lambda\Delta t\in\mathbb{C}$, which will be a discrete approximation to the true solution

$$
    U(\Delta t) = e^{z} U_0
$$
* Absolute stability will require that $|R(z)| < 1$

### Absolute Stability of the backward Euler Method

Now try this on backward Euler.

$$
    U_{n+1} = U_n + \Delta t f(t_{n+1}, U_{n+1}).
$$

which for our model problem is

$$
    U_{n+1} = U_n + \Delta t\lambda U_{n+1}
$$
or

$$
    (1 - \Delta t\lambda) U_{n+1} = U_n
$$

or rearranging and solving for $U_{n+1}$ gives

$$
\begin{align}
    U_{n+1} &= \frac{1}{1-\Delta t\lambda} U_n\\
            &= \frac{1}{1-z}U_n\\
\end{align}
$$

so
$$
    R(z) = \frac{1}{1-z}
$$
and absolute stability requires

$$
    |R(z)| \leq 1 \leftrightarrow |1 - z| \geq 1$$

so in fact the stability region encompasses the entire complex plane except for a circle centered at $(1, 0)$ of radius 1 implying that the backward Euler method is in fact stable for any choice of $\Delta t$ for $\lambda<0$ (and even some positive values of $\lambda$.

```python
# Plot the region of absolute stability for Backward Euler
fig = plt.figure(figsize=(8,6))
axes = fig.add_subplot(1, 1, 1)

t = numpy.linspace(0.0, 2.0 * numpy.pi, 100)

axes.set_facecolor((255.0/255.0,145.0/255.0,0/255.0,1.0))
axes.fill(numpy.cos(t) + 1.0, numpy.sin(t), 'w')
axes.plot([-3, 3],[0.0, 0.0],'k--')
axes.plot([0.0, 0.0],[-3, 3],'k--')
# axes.set_xlim((-3, 1))
axes.set_ylim((-2,2))
axes.set_aspect('equal')

axes.set_title("Absolute Stability Region for Backward Euler")
plt.show()
```

However, stability doesn't imply accuracy either.  In this case, backwards Euler will still fail after long times for the simple harmonic oscillator as well as it will damp towards zero.  For the general Linear autonomous dynamical system

$$
    \frac{d\mathbf{u}}{dt} = A\mathbf{u}, \quad \mathbf{u}(0) = \mathbf{u}_0
$$

Backwards Euler method can be written as

$$
        \mathbf{U}_{n+1} = \mathbf{U}_n + \Delta t A\mathbf{U}_{n+1}
$$

or rearranging, requires solving the linear algebraic problem at each step

$$
    (I - \Delta t A)\mathbf{U}_{n+1} = \mathbf{U}_n
$$

```python
# Implement Backwards Euler for a linear system $u' = Au$
def beuler(A, t_span, u0, N):
    """ simple implementation of constant step-size forward euler method for vector valued f
        This doc string should have so much more in it
    """
    t = numpy.linspace(t_span[0], t_span[1],N)
    delta_t = t[1] - t[0]

    u = numpy.empty((len(t),len(u0)))
    B = numpy.eye(len(u0)) - delta_t*A
    u[0] = u0
    for (n, t_n) in enumerate(t[:-1]):
        u[n + 1] = numpy.linalg.solve(B,u[n])
    return t, u.transpose()
```

```python
A = numpy.array([[0, 1], [-1, 0]])
t_span = [0., 4*numpy.pi]
u_0 = numpy.array([1., 0.])
N = 100
t, u = beuler(A,t_span,u_0, N)
```

```python
fig = plt.figure(figsize=(8,6))
axes = fig.add_subplot(1,1,1)
axes.quiver(X, V, V, -X)
axes.plot(1.*numpy.cos(t), -numpy.sin(t))
axes.plot(u[0],u[1],'r')
axes.plot([-2, 2],[0.0, 0.0],'k--')
axes.plot([0.0, 0.0],[-2, 2],'k--')
axes.set_xlabel('x',fontsize=16)
axes.set_ylabel('v',fontsize=16)

axes.set_title("Backward Euler: simple Harmonic oscillator", fontsize=16)
axes.set_aspect('equal')
```

## General Stability Regions for Linear Methods

We can also map out  stability regions for Linear Multi-step methods (although the analysis is slightly more complicated).

All of the Linear Multi-step methods from the previous lecture can be written
$$
    \sum^r_{j=0} \alpha_j U_{n+j} = \Delta t \sum^r_{j=0} \beta_j f(t,U_{n+j})
$$

which for our model problem, $f(t,u) = \lambda u$ becomes
$$
    \sum^r_{j=0} \alpha_j U_{n+j} = \Delta t \sum^r_{j=0} \beta_j \lambda U_{n+j}
$$

or  rearranging and again defining $z=\Delta t\lambda$ becomes

$$
    \sum^r_{j=0} (\alpha_j - \beta_j z) U_{n+j} = 0.
$$
which takes the form of a "Linear Difference Equation"

### Characteristic Polynomials and Linear Difference Equations

To help us analyze stability regions we need to take a small aside and consider linear difference equations and their solutions.  Say we wanted to solve

$$\sum^r_{j=0} \alpha_j U_{n+j} = 0$$

given initial conditions $U_0, U_1, \ldots, U_{r-1}$.  This expression has a solution in the general form $U_n = \xi^n$.

Plugging this into the equation we have

$$\sum^r_{j=0} \alpha_j \xi^{n+j} = 0$$

which simplifies to

$$\sum^r_{j=0} \alpha_j \xi^j = 0 $$

by dividing by $\xi^n$.  If $\xi$ then is a root of the polynomial

$$\rho(\xi) \equiv \sum^r_{j=0} \alpha_j \xi^j$$

then $\xi$ solves the original difference equation.

### Example:  the Fibonacci Numbers

A classic example of  linear difference equation is the Fibonnacci sequence, $0,1,1,2,3,5,\ldots$.

Which are solutions to the two-term difference equation

$$
    U_{n+2} = U_{n+1} + U_n, \quad U_0=0,\, U_1 = 1
$$

or

$$
    U_{n+2} - U_{n+1} - U_n = 0
$$

or $\alpha = \begin{bmatrix} -1 & -1 & 1 \\\end{bmatrix}$

Substituting $U_n = \xi^n$ into
$$
U_{n+2} - U_{n+1} - U_n =0
$$
yields
$$
    \xi^n\left(\xi^2 - \xi -1\right) = 0
$$
which has solutions $\xi =  \frac{1\pm\sqrt{5}}{2}$.  Where the positive root is our favorite number $\phi=1.618033$ which is the golden ratio.  The negative root is $1-\phi$. Note, these roots are related to the eigenvalues of a related problem

Thus all Fibonacci numbers can be written as a linear combination of the two roots
$$
    U_n = A\phi^n + B(1-\phi)^n
$$
where $A$ and $B$ are determined by the first two terms in the sequence [0,1].

Note, in this case $\phi > 1$ (while $|(1-\phi)|<1$) thus $U_n\rightarrow\infty$ as $n\rightarrow\infty$.

### Stability for LMM's
Anyway,  Back to the Linear difference equations arising from applying any Linear multistep method to the model problem
$$
    \sum^r_{j=0} (\alpha_j - \beta_j z) U_{n+j} = 0.
$$

Letting
$$
    \rho(\xi) = \sum^r_{j=0} \alpha_j \xi^j, \quad \sigma(\xi) = \sum^r_{j=0} \beta_j \xi^j
$$
and substituting $U_n = \xi^n$ into the difference equations reduces to
$$
    \pi(\xi, z) = \rho(\xi) - z \sigma(\xi) = 0
$$
where $\pi(\xi, z)$ is the **stability polynomial** of the method, whose roots are solutions of the difference method.

### Absolute stability of the linear multi-step methods

For $U_n$ to stay bounded as $n\rightarrow\infty$,  it requires that all roots $\xi_i$ of $\pi(\xi, z)$ satisfy
$$
    |\xi_i| \leq 1
$$
If this is the case, then the multi-step method is **zero-stable**.  Roughly you can think of these roots as multiplying the past truncation errors so that if they are less than or equal to 1 they do not grow in magnitude.  We then define the region of **absolute stability** as the values for $z$ for which this is true.  This approach reduces to the $R$ method for  one-step methods as a special case.

### Example:  Forward Euler's Method

Examining forward Euler's method we have

$$\begin{aligned}
    0 &= U_{n+1} - U_n - \Delta t \lambda U_n \\
    &= U_{n+1} - U_n (1 + \Delta t \lambda)\\
\end{aligned}
$$

setting $U_n = \xi^n$ implies
$$
\begin{aligned}
   0 &= \xi^n\left(\xi -  (1 + z)\right)\\
\end{aligned}$$

or $\pi(\xi, z) = \xi -  (1 + z)$ whose root is $\xi = 1 + z$ and we have re-derived the stability region we had found before.

### Example:  Backwards Euler's Method

Similarly for backwards Euler

$$\begin{aligned}
    0 &= U_{n+1} - U_n - \Delta t \lambda U_{n+1} \\
    &= U_{n+1}(1 - \Delta t\lambda) - U_n \\
    &= \xi(1 - z)  - 1 \\
    &=\pi(\xi, z)
\end{aligned}$$

whose root is $\xi = 1/(1 - z)$

### Example: Adams-Bashforth 2

As an example consider the Adams-Bashforth 2-stage LMM
$$
    U_{n+2} = U_{n+1} + \frac{\Delta t}{2} (-f(U_n) + 3 f(U_{n+1}))
$$

Let $f(U) = \lambda U$,  $z = \Delta t \lambda$
$$
    U_{n+2} - U_{n+1} - \frac{1}{2} (-z U_n + 3 z U_{n+1}) = 0 \\
$$

substituting $U_n=\xi^n$, and factoring out $\xi^n$ yields
$$
    \pi(\xi, z) = \xi^{2} - \xi - \frac{1}{2} (-z  + 3 z \xi)
$$
with roots when

$$
     2 \xi^2 - (2 + 3z) \xi + z =0
$$

## Plotting Stability Regions

Given a stability polynomial $\pi(\xi,z)$, absolute stability requires that

$$
    \max{|\xi_i|} <= 1
$$
where $\xi_i$ are the (generally complex) roots of $\pi(\xi, z)$ and therefore a function of $z$.  In the case of single step schemes, the problem reduces to finding the region of the complex plane where $|R(z)| < 1$.

Either way,  the simplest way to visualize the regions of the complex plane where the scheme is stable, is to calculate either $|R(z)|$ or $\max|\xi_i(z)|$ and plot the 1-contour and determine all regions that are $<1$.

### Some useful codes

study the following cells for some useful routines for plotting stability regions for both Single-step multi-stage schemes and Linear Multi-step schemes

```python
def stability_plot(X, Y, C, axes, title=None, continuous=True):
    """
    Utility function to make stability diagram given complex stability scalar C

    parameters:
    -----------

    X, Y:  numpy.meshgrids for complex plane
    C: numpy array
        Field to plot,  either |R(z)| for a single step scheme, or max(|xi_i(z)|) for a LMM scheme
    axes: matplotlib axes object
        subplot or plot to draw in.
    title: string
        subplot title if not None
    continuous: boolean
        if True, plot a continous coloring of C
        if False, plot Heaviside(C)
    """
    if  continuous:
        Ch = C
    else:
        Ch = numpy.heaviside(C-1,0.)
    pcolor_plot = axes.pcolor(X, Y, Ch, vmin=0, vmax=1, cmap=plt.get_cmap('Greens_r'))
    axes.contour(X, Y, C, 'k', levels=[1.0])
    fig = plt.gcf()
    fig.colorbar(pcolor_plot)
    axes.plot(x, numpy.zeros(x.shape),'k--')
    axes.plot(numpy.zeros(y.shape), y,'k--')

    axes.set_xlabel('Re', fontsize=16)
    axes.set_ylabel('Im', fontsize=16)
    if title is not None:
        axes.set_title(title, fontsize=16)

    axes.set_aspect('equal')

def plot_stability_ssms(R, x, y, axes=None, title=None, continuous=True):
    """
    plot stability regions for single-step multi-stage ODE schemes given the function R(z)
    such that U_{n+1} = R(z)U_n   and z is complex

    parameters:
    -----------

    R: calleable
        function of a complex variable z such that if |R|<=1, the scheme is absolutely stable
    x: numpy array
        array of values for the real axis
    y: numpy array
        values to plot for the imaginary axis
    axes: matplotlib axes object
        subplot or plot to draw in.  If axes=None create a new figure
    title: string
        subplot title if
    continuous: boolean
        if True, plot a continous coloring of C
        if False, plot Heaviside(C)
    """

    X,Y = numpy.meshgrid(x,y)
    Z = X + 1j * Y
    if axes is None:
        fig = plt.figure(figsize=(8,6))
        axes = fig.add_subplot(1,1,1)

    abs_R = numpy.abs(R(Z))
    stability_plot(X, Y, abs_R, axes, title, continuous)

def plot_stability_lmm(pi_coeff, x, y, axes=None, title=None, continuous=True):
    """
    plot stability regions for linear multi-step  ODE schemes given the coefficients of the stability polynomial
    pi(xi, z)

    parameters:
    -----------

    pi_coeff: calleable (function of z)
        function that returns array of stability polynomial pi(z)
    x: numpy array
        array of values for the real axis
    y: numpy array
        values to plot for the imaginary axis
    axes: matplotlib axes object
        subplot or plot to draw in.  If axes=None create a new figure
    title: string
        subplot title if not None
    continuous: boolean
        if True, plot a continous coloring of C
        if False, plot Heaviside(C)
    """

    X,Y = numpy.meshgrid(x,y)
    Z = X + 1j * Y
    if axes is None:
        fig = plt.figure(figsize=(8,6))
        axes = fig.add_subplot(1,1,1)

    norm_max = numpy.empty(Z.shape)
    for i,row in enumerate(Z):
        for j, z in enumerate(row):
            norm_max[i,j] = max(numpy.abs(numpy.roots(pi_coeff(z))))

    stability_plot(X, Y, norm_max, axes, title, continuous)

```

```python
x = numpy.linspace(-3,3,100)
y = numpy.linspace(-3,3,100)

R_euler = lambda z: 1 + z
R_beuler = lambda z: 1./(1. - z)
R_trap = lambda z: (1. + 0.5*z)/(1. - 0.5*z)
R_RK2 = lambda z: 1 + z + z**2/2
R_Taylor4 = lambda z: 1 + z + z**2/2 + z**3/6. + z**4/24.

continuous=True
```

```python
fig = plt.figure(figsize=(12,12))
axes = fig.add_subplot(2,2,1)
plot_stability_ssms(R_euler, x, y, axes=axes, title='Stability of Eulers method', continuous=continuous)

axes = fig.add_subplot(2,2,2)
plot_stability_ssms(R_beuler, x, y, axes=axes, title='Stability of Backwards Eulers', continuous=continuous)

axes = fig.add_subplot(2,2,3)
plot_stability_ssms(R_trap, x, y, axes=axes, title='Stability of Trapezoidal method', continuous=continuous)

axes = fig.add_subplot(2,2,4)
plot_stability_ssms(R_Taylor4, x, y, axes=axes, title='Stability of Taylor4 method', continuous=continuous)

```

```python
x = numpy.linspace(-3,3,100)
y = numpy.linspace(-3,3,100)

pi_euler = lambda z: numpy.array([1., -(1 + z)])
pi_AB2 = lambda z: numpy.array([2, -(2. + 3*z), z])
```

```python
fig = plt.figure(figsize=(12,6))
axes = fig.add_subplot(1,2,1)
plot_stability_lmm(pi_euler, x, y, axes=axes, title='Stability of Eulers method')
axes = fig.add_subplot(1,2,2)
plot_stability_lmm(pi_AB2, x, y, axes=axes, title='Stability of Adams-Bashforth2 method')
```

## Application to Stiff ODEs

Consider  the ODE IVP

$$u'(t) = \lambda (u - \cos t) - \sin t,\quad u(t_0)=u_0$$

The general solution of the ODE is

$$u(t) = e^{\lambda (t - t_0)} (u_0 - \cos t_0) + \cos t$$.

For $u_0=1$ at $t_0=0$,  the solution is simply $\cos t$

What happens to solutions that are slightly different from $u_0 = 1$ or $t_0 = 0$?

```python
# Plot "hairy" solutions to the ODE
u = lambda t_0, u0, lam, t: numpy.exp(lam * (t - t_0)) * (u0 - numpy.cos(t_0)) + numpy.cos(t)

fig = plt.figure(figsize=(12, 10))

for i,lam in enumerate([-1, -10, 1, 10]):
    axes = fig.add_subplot(2, 2, i+1)
    for u0 in numpy.linspace(-1, 1, 10):
        for t_0 in numpy.linspace(0.0, 9.0, 10):
            t = numpy.linspace(t_0,10.0,100)
            axes.plot(t, u(t_0, u0, lam, t),'b')
    t = numpy.linspace(0.0,10.0,100)
    axes.plot(t, numpy.cos(t), 'r', linewidth=5)
    axes.set_ylim((-2, 2))

    axes.set_title("Perturbed Solutions $\lambda = %s$" % lam)
    axes.set_xlabel('$t$')
    axes.set_ylabel('$u(t)$')
axes.set_ylim((-2, 2))
plt.show()

```

```python
# Plot "inverse hairy" solutions to the ODE
u = lambda t_0, u0, lam, t: numpy.exp(lam * (t - t_0)) * (u0 - numpy.cos(t_0)) + numpy.cos(t)

num_steps = 10
error = numpy.ones(num_steps) * 1.0
t_hat = numpy.linspace(0.0, 10.0, num_steps + 1)
t_whole = numpy.linspace(0.0, 10.0, 1000)

fig = plt.figure()
axes = fig.add_subplot(1, 1, 1)
eta = 1.0
lam = 0.1

for n in range(1,num_steps):
    t = numpy.linspace(t_hat[n-1], t_hat[n], 100)
    U = u(t_hat[n-1], eta, lam, t)
    axes.plot(t, U, 'b')
    axes.plot(t_whole, u(t_hat[n-1], eta, lam, t_whole),'b--')
    axes.plot([t[-1], t[-1]], (U[-1], U[-1] + -1.0**n * error[n]), 'r')
    eta = U[-1] + -1.0**n * error[n]

t = numpy.linspace(0.0, 10.0, 100)
axes.plot(t, numpy.cos(t), 'g')

axes.set_title("Perturbed Solutions $\lambda = %s$" % lam)
axes.set_xlabel('$t$')
axes.set_ylabel('$u(t)$')
axes.set_ylim((-10,10))
plt.show()

```

### Example:  Chemical systems

Consider the transition of a chemical $A$ to a chemical $C$ through the process

$$A \overset{K_1}{\rightarrow} B \overset{K_2}{\rightarrow} C.$$

We can write this problem as a system of Linear ODE's
$$
\begin{align*}
    \frac{dA}{dt} &= -K_1 A\\
    \frac{dB}{dt} &= K_1 A -K_2B\\
    \frac{dC}{dt} &= K_2B\\
\end{align*}
$$
with initial condition $A_0, B_0, C_0$

If we let

$$\mathbf{u} = \begin{bmatrix} A \\ B \\ C \end{bmatrix}$$

then we can rewrite this as a linear dynamical system
$$
 \frac{d\mathbf{u}}{dt} = M\mathbf{u},  \quad \mathbf{u}(0) = \mathbf{u}_0
$$
where

$$ M =
\begin{bmatrix}
    -K_1 & 0 & 0 \\
    K_1 & -K_2 & 0 \\
    0 & K_2 & 0
\end{bmatrix}
$$

If $A$ is diagonalizable, the general solution of a linear dynamical system  can be written in terms of the eigenvalues ($\lambda_i$) and eigenvectors $\mathbf{x}_i$ of $M$ as

$$\mathbf{u}(t) = c_{1} e^{\lambda_1 t}\mathbf{x}_1 + c_{2}e^{\lambda_2t}\mathbf{x}_2 + c_{3}e^{\lambda_3t}\mathbf{x}_3$$

And given our matrix
$$ M =
\begin{bmatrix}
    -K_1 & 0 & 0 \\
    K_1 & -K_2 & 0 \\
    0 & K_2 & 0
\end{bmatrix}
$$

The eigenvalues are ??

And the general solution is

$$\mathbf{u}(t) = c_{1} e^{-K_1t}\mathbf{x}_1 + c_{2}e^{-K_2t}\mathbf{x}_2 + c_{3}\mathbf{x}_3$$

and the constants $c_i$ can be found by solving $X\mathbf{c} = \mathbf{u}_0$ which describe the initial condition as a linear combination of the eigenvectors.

```python
# Solve the chemical systems example using a Forward Euler scheme
# Problem parameters
K_1 = 3
K_2 = 1
N = 29

A = numpy.array([[-K_1, 0, 0], [K_1, -K_2, 0], [0, K_2, 0]])
f = lambda u: numpy.dot(A, u)
u_0 = [2.5, 5.0, 2.0]

```

```python
t_span = [0, 2.]
f = lambda t, u : A.dot(u)
t, U = euler(f, t_span, u_0, N)

fig = plt.figure(figsize=(8,6))
axes = fig.add_subplot(1, 1, 1)

axes.plot(t, U.transpose())
axes.legend(["[A]", "[B]", "[C]"])
axes.set_title("Chemical System")
axes.set_xlabel("$t$")
axes.set_title("$[A], [B], [C]$")
#axes.set_ylim((0.0, 10.))
#axes.set_xlim((0.0, 2.0))
axes.grid()

plt.show()
```

### What is stiffness?

In general a **stiff** ODE is one where $u'(t) \ll f'(t, u)$.  For linear dynamical systems like the chemical decay problem,  the **stiffness ratio**

$$\frac{\max_p |\lambda_p|}{\min_p |\lambda_p|}$$

can be used to characterize the stiffness of the system.  In our last example this ratio was $K_1 / K_2$ if $K_1 > K_2$.  As we increased this ratio we observed that the numerical method became unstable only a reduction in $\Delta t$ lead to stable solution again.  For explicit time step methods this is problematic as the reduction of the time step for only one of the species leads to very expensive evaluations.  For example, forward Euler has the stability criteria

$$|1 + \Delta t \lambda| < 1$$

where $\lambda$ will have to be the maximum eigenvalue of the system.

```python
# set the eigenvalues and time step
K_1 = 3.0
K_2 = 1.0
delta_t = 1.0
eigenvalues = [-K_1, -K_2]

```

```python
# Plot the region of absolute stability for Forward Euler
fig = plt.figure(figsize=(8,6))
axes = fig.add_subplot(1, 1, 1)

t = numpy.linspace(0.0, 2.0 * numpy.pi, 100)

axes.fill(numpy.cos(t) - 1.0, numpy.sin(t), color=(255.0/255.0,145.0/255.0,0/255.0,1.0))
for lam in eigenvalues:
    print('z = {}'.format(lam * delta_t))
    axes.plot(lam * delta_t, 0.0, 'ko')
axes.plot([-3, 3],[0.0, 0.0],'k--')
axes.plot([0.0, 0.0],[-3, 3],'k--')
# axes.set_xlim((-3, 1))
axes.set_ylim((-2,2))
axes.set_aspect('equal')

axes.set_title("Absolute Stability Region for Forward Euler")
plt.show()
```

```python
# solve using backwards Euler
K_1 = 3.0
K_2 = 1
A = numpy.array([[-K_1, 0, 0], [K_1, -K_2, 0], [0, K_2, 0]])

N = 100
t_span = [0., 2.]
U0 = [2.5, 5.0, 2.0]

t, U = beuler(A, t_span, U0, N)

```

```python

fig = plt.figure(figsize=(8,6))
axes = fig.add_subplot(1, 1, 1)

axes.plot(t, U.T)
axes.legend(["[A]", "[B]", "[C]"])
axes.set_title("Chemical System")
axes.set_xlabel("$t$")
axes.set_title("$[A], [B], [C]$")
axes.set_ylim((0.0, 10.))
axes.set_xlim((0.0, 2.0))
axes.grid()

plt.show()
```

### A-Stability
What if we could expand the absolute stability region to encompass more of the left-half plane or even better, all of it.  A method that has this property is called **A-stable**.  We have already seen one example of this with backward Euler which as a stability region of

$$|1 - z| \geq 1$$

which covers the full left-half plane.  It turns out that for linear multi-step methods a theorem by Dahlquist proves that there are no LMMs that satisfies the A-stability criterion beyond second order (trapezoidal rule).  There are higher-order Runge-Kutta methods do however.

Perhaps this is too restrictive though.  Often large eigenvalues for systems (for instance coming from a PDE discretization for the heat equation) lie completely on the real line.  If the stability region can encompass as much of the real line as possible while leaving out the rest of the left-half plane we can possibly get a more efficient method.  There are a number of methods that can be constructed that have this property but are higher-order.

```python
# Plot the region of absolute stability for Backward Euler
fig = plt.figure()
axes = fig.add_subplot(1, 1, 1)

t = numpy.linspace(0.0, 2.0 * numpy.pi, 100)

K_1 = 3.0
K_2 = 1.0
delta_t = 1.0
eigenvalues = [-K_1, -K_2]

axes.set_facecolor((255.0/255.0,145.0/255.0,0/255.0,1.0))
axes.fill(numpy.cos(t) + 1.0, numpy.sin(t), 'w')
for lam in eigenvalues:
    print(lam * delta_t)
    axes.plot(lam * delta_t, 0.0, 'ko')
axes.plot([-3, 3],[0.0, 0.0],'k--')
axes.plot([0.0, 0.0],[-3, 3],'k--')
# axes.set_xlim((-3, 1))
axes.set_ylim((-2,2))
axes.set_aspect('equal')

axes.set_title("Absolute Stability Region for Backward Euler")
plt.show()
```

# L-Stability

It turns out not all A-stable methods are alike.  Consider the backward Euler method and the trapezoidal method defined by

$$\frac{U_{n+1} - U_n}{\Delta t} = \frac{1}{2}(f(U_n) + f(U_{n+1}))$$

whose stability polynomial (or $R(z))$ is

$$\begin{aligned}
    0 &= U_{n+1} - U_n - \Delta t \frac{1}{2} (\lambda U_n + \lambda U_{n+1}) \\
      &= U_{n+1}\left(1 - \frac{1}{2} \Delta t \lambda \right ) - U_n \left(1 + \frac{1}{2}\Delta t \lambda \right)\\
\end{aligned}$$

or
$$
      \pi(\xi,z) = \left(1 - \frac{z}{2}\right ) \xi - \left(1 + \frac{z}{2}\right)
$$
with single root
$$
\xi(z) = R(z) = \frac{1 + \frac{z}{2}}{1 - \frac{z}{2}}
$$

which shows that it is A-stable (i.e. $|R(z)| < 1$ for all $\Re(z)<0$)

Let's apply both these methods to a problem we have seen before and see what happens.

```python
# Compare accuracy between Euler and Trapezoidal for the stiff ODE
f = lambda t, lam, u: lam * (u - numpy.cos(t)) - numpy.sin(t)
u_exact = lambda t_0, eta, lam, t: numpy.exp(lam * (t - t_0)) * (eta - numpy.cos(t_0)) + numpy.cos(t)

t_0 = 0.0
t_f = 1.0
eta = 2.0
lam = -1e6
```

```python
num_steps = [10, 20, 40, 50]
# num_steps = numpy.arange(100, 1000, 100)

delta_t = numpy.empty(len(num_steps))
error_euler = numpy.empty(len(num_steps))
error_trap = numpy.empty(len(num_steps))

for (i, N) in enumerate(num_steps):
    t = numpy.linspace(0, t_f, N)
    delta_t[i] = t[1] - t[0]
    u = u_exact(t_0, eta, lam, t_f)

    # Compute Euler solution
    U_euler = numpy.empty(t.shape)
    U_euler[0] = eta
    for (n, t_n) in enumerate(t[1:]):
        U_euler[n+1] = (U_euler[n] - lam * delta_t[i] * numpy.cos(t_n) - delta_t[i] * numpy.sin(t_n)) / (1.0 - lam * delta_t[i])
    error_euler[i] = numpy.abs(U_euler[-1] - u) / numpy.abs(u)

    # Compute using trapezoidal
    U_trap = numpy.empty(t.shape)
    U_trap[0] = eta
    for (n, t_n) in enumerate(t[1:]):
        U_trap[n+1] = (U_trap[n] + delta_t[i] * 0.5 * f(t_n, lam, U_trap[n]) - 0.5 * lam * delta_t[i] * numpy.cos(t_n) - 0.5 * delta_t[i] * numpy.sin(t_n)) / (1.0 - 0.5 * lam * delta_t[i])
    error_trap[i] = numpy.abs(U_trap[-1] - u)
```

```python

# Plot error vs. delta_t
fig = plt.figure()
fig.set_figwidth(fig.get_figwidth() * 2)
axes = fig.add_subplot(1, 2, 1)
axes.plot(t, U_euler, 'ro-')
axes.plot(t, u_exact(t_0, eta, lam, t),'k')

axes = fig.add_subplot(1, 2, 2)
axes.loglog(delta_t, error_euler, 'bo')
order_C = lambda delta_x, error, order: numpy.exp(numpy.log(error) - order * numpy.log(delta_x))
axes.loglog(delta_t, order_C(delta_t[1], error_euler[1], 1.0) * delta_t**1.0, 'r--', label="1st Order")
axes.loglog(delta_t, order_C(delta_t[1], error_euler[1], 2.0) * delta_t**2.0, 'b--', label="2nd Order")

axes.legend(loc=4)
axes.set_title("Comparison of Errors for Backwards Euler")
axes.set_xlabel("$\Delta t$")
axes.set_ylabel("$|U(t_f) - u(t_f)|$")

# Plots for trapezoid
fig = plt.figure()
fig.set_figwidth(fig.get_figwidth() * 2)
axes = fig.add_subplot(1, 2, 1)
axes.plot(t, U_trap, 'ro-')
axes.plot(t, u_exact(t_0, eta, lam, t),'k')

axes = fig.add_subplot(1, 2, 2)
axes.loglog(delta_t, error_trap, 'bo', label='Trapezoidal')
order_C = lambda delta_x, error, order: numpy.exp(numpy.log(error) - order * numpy.log(delta_x))
axes.loglog(delta_t, order_C(delta_t[1], error_trap[1], 1.0) * delta_t**1.0, 'r--', label="1st Order")
axes.loglog(delta_t, order_C(delta_t[1], error_trap[1], 2.0) * delta_t**2.0, 'b--', label="2nd Order")

axes.legend(loc=4)
axes.set_title("Comparison of Errors for Trapezoidal Rule")
axes.set_xlabel("$\Delta t$")
axes.set_ylabel("$|U(t_f) - u(t_f)|$")

plt.show()
```

It turns out that if we look at a one-step method and define the following ratio

$$U_{n+1} = R(z) U_n$$

we can define another form of stability, called **L-stable**, where we require that the method is A-stable and that

$$\lim_{z \rightarrow \infty} |R(z)| = 0.$$

Turns out that backwards Euler is L-stable while trapezoidal rule is not.

```python
R_beuler = lambda z: 1./(1. - z)
R_trap = lambda z: (1. + 0.5*z)/(1. - 0.5*z)
```

```python
fig = plt.figure(figsize=(12,6))
axes = fig.add_subplot(1,2,1)
plot_stability_ssms(R_beuler, x, y, axes=axes, title='Stability of Backwards Eulers')

axes = fig.add_subplot(1,2,2)
plot_stability_ssms(R_trap, x, y, axes=axes, title='Stability of Trapezoidal')
```

## Backward Differencing Formulas

A class of LMM methods that are useful for stiff ODE problems are the backward difference formula (BDF) methods which have the form

$$\alpha_0 U_n + \alpha_1 U_{n+1} + \cdots + \alpha_r U_{n+r} = \Delta t\beta_r f(U_{n+r})$$

These methods can be derived directly from backwards finite differences from the point $U_{n+r}$ and the rest of the points back in time.  One can then derive r-step methods that are rth-order accurate this way.  Some of the methods are

$$\begin{aligned}
    \text{BDF-1}~& (r = 1):& & U_{n+1} - U_n = \Delta t f(U_{n+1}) \\
    \text{BDF-2}~& (r = 2):& &3 U_{n+2} - 4 U_{n+1} + U_n = 2 \Delta t f(U_{n+2}) \\
    \text{BDF-3}~& (r = 3):& &11U_{n+3} - 18U_{n+2} + 9U_{n+1} - 2 U_n = 6 \Delta t f(U_{n+3}) \\
    \text{BDF-4}~& (r = 4):& &25 U_{n+4} - 48 U_{n+3} +36 U_{n+2} -16 U_{n+1} +3 U_n = 12 \Delta t f(U_{n+4})
\end{aligned}$$

* Inspection of BDF-1 shows that it's equivalent to ??
* You should also recognize BDF-2 from our Numerical differentiation notes$\ldots$

```python
from fdcoeffV import fdcoeffV
# order of the BDF scheme (i.e. N=2 implies BDF-2)
N=3
# order of the derivative
k = 1
x = numpy.array(range(N+1))
w = fdcoeffV(k, x[N], x)
print('weights = {}'.format(w))
print('weights x 6 = {}'.format(6*w))
```

```python
## Finish the code off here for calculating BDF coefficients
```

```python
pi_BDF1 = lambda z: numpy.array([ 1. - z, -1.])
pi_BDF2 = lambda z: numpy.array([ 3. - 2*z, -4. , 1.])
pi_BDF3 = lambda z: numpy.array([ 11 - 6.*z, -18., 9., -2.])
pi_BDF4 = lambda z: numpy.array([ 25. - 12*z, -48., 36, -16., 3])
```

```python
x = numpy.linspace(-10,10,50)
y = numpy.linspace(-10,10,50)
fig = plt.figure(figsize=(12,12))
axes = fig.add_subplot(2,2,1)
plot_stability_lmm(pi_BDF1, x, y, axes=axes, title='Stability of BDF1 method')

axes = fig.add_subplot(2,2,2)
plot_stability_lmm(pi_BDF2, x, y, axes=axes, title='Stability of BDF2 method')

axes = fig.add_subplot(2,2,3)
plot_stability_lmm(pi_BDF3, x, y, axes=axes, title='Stability of BDF3 method')

axes = fig.add_subplot(2,2,4)
plot_stability_lmm(pi_BDF4, x, y, axes=axes, title='Stability of BDF4 method')

```

### BDF Schemes

#### Pro's

* LMM's so only require 1 (non-linear) solve per time step
* relatively easy to derive and code methods of variable order
* L-Stable and A-Stable

#### Con's
* Still need to deal solving for system's of non-linear equations
* LMM's are harder to make adaptive (but can do so using interpolation techniques)

#### Example,  BDF-1 for a general non-linear ODE

Consider the general $n$-dimensional nonlinear system of ODE's (e.g van-der pol oscillator or SIR problem) which can be written as

$$
    \frac{d\mathbf{u}}{dt} = \mathbf{f}(t,\mathbf{u}), \quad \mathbf{u}(0) = \mathbf{u}_0
$$

where
$$
    \mathbf{u}(t) = \begin{bmatrix} u_1(t) \\ u_2(t) \\ \vdots \\ u_n(t) \end{bmatrix},\quad
    \mathbf{f}(t,\mathbf{u}) = \begin{bmatrix} f_1(t,\mathbf{u}) \\ f_2(t,\mathbf{u}) \\ \vdots \\ f_n(t,\mathbf{u}) \end{bmatrix}
$$

and $\mathbf{f}$ is a vector of scalar, possibly non-linear  functions of the solution vector $\mathbf{u}$

for example, in the Van-der-pol oscillator $n=2$ and

$$
    \mathbf{f}(t,\mathbf{u}) = \begin{bmatrix} u_2 \\ \mu(1 - u_1^2)u_2 - u_1 \end{bmatrix}
$$

## BDF-1 discretization (Backwards-Euler)

So the BDF-1 (Backwards Euler) discretization of this system can be written

$$
    \mathbf{U}_{n+1} - \mathbf{U}_{n} - \Delta t\mathbf{f}(t,\mathbf{U}_{n+1}) = \mathbf{0}
$$

Which itself is a more general system of non-linear Equations for $\mathbf{U}_{n+1}$ of form

$$
    \mathbf{F}(\mathbf{U}_{n+1}) = \begin{bmatrix} F_1(t,\mathbf{U}_{n+1},\mathbf{U}_n) \\
    F_2(t,\mathbf{U}_{n+1},\mathbf{U}_n) \\
    \vdots\\
    F_n(t,\mathbf{U}_{n+1},\mathbf{U}_n)\\ \end{bmatrix} =
    \mathbf{0}
$$

where, in this case
$$
    F_i = {U_{i}}_{(n+1)} - {U_{i}}_{n} - \Delta tf_i(t,\mathbf{U}_{n+1})
$$

## Solution of systems of Non-linear equations:  Newton's method

If our system had only one scalar variable and function $f(t,u)$, then each step would reduce to solving the scalar non-linear problem
$$
    F(u_{n+1}) = 0
$$
which is a rootfinding problem, and we could use any of our rootfinders to solve this (in particular we could use robust bracketing schemes like brent).  Unfortunately once we move to higher-dimensions the problems become significantly harder (and solutions may not exist).  However we can still attempt to apply Newton's method to the general non-linear problem

$$
    \mathbf{F}(\mathbf{x}) = \mathbf{0}
$$

##  Newton's method: preliminaries

As a simpler non-linear system, we will consider the system of equations

$$
\begin{align}
    x_1^2 + 2x_2^2 &=\gamma_1 \\
    x_1 + 5x_2 &=\gamma_2 \\
\end{align}
$$

which geometrically is the intersection of an ellipse and a straight line, which, depending on the values of $\gamma_1$ and $\gamma_2$ should have 0,1, or 2 solutions

```python
x = numpy.linspace(-4,4,100)
y = numpy.linspace(-4,4,100)
X,Y = numpy.meshgrid(x,y)

Z1 = lambda gamma: X**2 + 2*Y**2 - gamma
Z2 = lambda gamma: X + 5*Y - gamma

gamma_1 = 10*numpy.ones(3)
gamma_2 = [-2, 10, -15]
fig = plt.figure(figsize=(24,6))

for i in range(3):
    axes = fig.add_subplot(1,3,i+1)
    axes.contour(X,Y,Z1(gamma_1[i]),[0],colors='r')
    axes.contour(X,Y,Z2(gamma_2[i]),[0],colors='b')
    axes.axis('equal')

    axes.set_xlabel('$x_1$')
    axes.set_ylabel('$x_2$')
    axes.set_title('$\gamma_1={}, \gamma_2={}$'.format(gamma_1[i],gamma_2[i]))
    axes.grid()

plt.show()

```

Now this system is sufficiently simple that we could solve it using substitution and solve
a quadratic equation for the roots (or even use a 1-D rootfinder).  But in general we would like a more general method where we can start from some initial guess and rapidly converge to one of the roots.  Newton's method provides a general way to solve these sorts of problems (with all the issues that go with Newton for scalar problems)

##  Newton's method: Review $f(x)=0$

Newton's method for a scalar function of a single variable says, given any $x$ such that $f(x)\neq 0$, there should be a correction $\delta$ such that

$$
    f(x+\delta) = 0
$$

Or expanding in a Taylor's series around $x$

$$
    f(x+\delta) = f(x) + f'(x)\delta + O(f''\delta^2) = 0
$$

Neglecting the higher order terms this becomes

$$
    f(x) + f'(x)\delta = 0
$$

or solving for the correction
$$
    \delta =\frac{-f(x)}{f'(x)}
$$

If the correction were exact it would follow that $f(x+\delta)=0$ and we would have our solution.

In general,  the higher-order terms are important so the residual is not zero, but this gives us an iterative scheme that we hope will converge

Set an initial guess $x_0$. Then iterate for $k=0,1,2\ldots$

$$
\begin{align}
    \text{Solve:} & & \delta_k &= \frac{-f(x_k)}{f'(x_k)}\\
    & & x_{k+1} &= x_k + \delta_k
\end{align}
$$

until $|f(x_k)| < \text{tol}$

If $f$ is well behaved with simple roots and the initial guess is within the basin of attraction of a given root,  Newton can find the root with quadratic convergence.

It can also behave very badly and fly off to infinity...

##  Newton's method: $\mathbf{F}(\mathbf{x})=\mathbf{0}$

Newton's method for a vector valued function is actually very similar (without the comfort of things such as brackets)

Again for general $\mathbf{x}$, $\mathbf{F}(\mathbf{x})\neq\mathbf{0}$ but is a vector often called the "residual".

By the rules of vector norms

$$
    ||\mathbf{F}(\mathbf{x})|| \geq 0
$$

and is only zero at a root of $\mathbf{F}$.

If the residual is non-zero for some arbitrary $\mathbf{x}$, Newton's method assumes that there is some correction vector $\boldsymbol{\delta}$ such that

$$
\mathbf{F}(\mathbf{x}+\boldsymbol{\delta})=\mathbf{0}
$$

And we simply need to expand a vector-valued function in its Taylor series and truncate at linear terms to solve for the correction.

### Minor digression: Linear systems

We can actually use Newton to solve linear systems as well.  Consider the general linear problem

$$
    A\mathbf{x} = \mathbf{b}
$$

which we can rewrite in residual form as

$$
    \mathbf{F}(\mathbf{x}) = \mathbf{b} - A\mathbf{x} = \mathbf{0}
$$

i.e. the solution of our original problem is also a root of $\mathbf{F}$.

For any other initial guess $\mathbf{x}_0$,  $\mathbf{F}(\mathbf{x}_0)\neq\mathbf{0}$.  However we can easily find the correction $\boldsymbol{\delta}$ as

$$
\begin{align}
 \mathbf{F}(\mathbf{x}_0+\boldsymbol{\delta})&= \mathbf{b} - A\mathbf{x_0} - A\boldsymbol{\delta} = \mathbf{0}\\
      &=\mathbf{F}(\mathbf{x}_0) - A\boldsymbol{\delta} = \mathbf{0}\\
\end{align}
$$

which we can solve immediately for the correction by solving the linear problem
$$
    A\boldsymbol{\delta} = \mathbf{F}(\mathbf{x}_0)
$$

Or the solution of our original problem is

$$
\begin{align}
    \mathbf{x} &= \mathbf{x}_0 + \boldsymbol{\delta} \\
    &= \mathbf{x}_0 + A^{-1}\mathbf{F}(\mathbf{x_0})
\end{align}
$$

Which for a simple linear function $\mathbf{F}(\mathbf{x}) = \mathbf{b} - A\mathbf{x}$ becomes

$$
\begin{align*}
    \mathbf{x} &= \mathbf{x}_0 + A^{-1}\left(\mathbf{b} - A\mathbf{x}_0\right) \\
                &= A^{-1}\mathbf{b}\\
\end{align*}
$$

Thus we can solve for either the solution itself or the correction with the residual.  These are equivalent

### Non-linear Systems

Non-linear systems actually look similar, however, we first need to linearize $\mathbf{F}(\mathbf{x}+\boldsymbol{\delta})$

#### Preliminaries:  Taylor Series expansion of   $f(\mathbf{x}):\mathbb{R}^n\rightarrow\mathbb{R}$.

if $f(\mathbf{x})$ is a scalar function of a vector valued variable (e.g. a surface like $f_1(\mathbf{x}$), then the Taylor series of

$$
    f(\mathbf{x} +\boldsymbol{\delta}) = f(\mathbf{x}) + \sum_{i=1}^n \frac{\partial f}{\partial x_i}(\mathbf{x})\delta_i + \frac{1}{2}\sum_{j=1}^n\sum_{i=1}^n \frac{\partial^2 f}{\partial x_i\partial x_j}(\mathbf{x})\delta_i\delta_j + HOT
$$

or more beautifully in vector notation
$$
    f(\mathbf{x} +\boldsymbol{\delta}) = f(\mathbf{x}) + (\nabla f)^T\boldsymbol{\delta} + \frac{1}{2}\boldsymbol{\delta}^TH(\mathbf{x})\boldsymbol{\delta} +  HOT
$$

where $\nabla f$ is the gradient of $f$,  and $H$ is the Hessian, a symmetric matrix whose components are
$$
    H_{ij} = \frac{\partial^2 f}{\partial x_i\partial x_j}
$$

####  Taylor Series expansion of   $\mathbf{F}(\mathbf{x}):\mathbb{R}^n\rightarrow\mathbb{R}^n$.

This is readily extended to vector valued functions as
$$
    \mathbf{F}(\mathbf{x}+\boldsymbol{\delta}) = \begin{bmatrix}
        F_1(\mathbf{x}+\boldsymbol{\delta}) \\
        F_2(\mathbf{x}+\boldsymbol{\delta}) \\
        \vdots \\
        F_n(\mathbf{x}+\boldsymbol{\delta}) \\
        \end{bmatrix} \approx \begin{bmatrix}
        F_1(\mathbf{x}) + (\nabla F_1)^T\boldsymbol{\delta} \\
        F_2(\mathbf{x}) + (\nabla F_2)^T\boldsymbol{\delta} \\
        \vdots \\
        F_n(\mathbf{x}) + (\nabla F_n)^T\boldsymbol{\delta} \\
\end{bmatrix} + HOT
$$

or more succinctly as

$$
\mathbf{F}(\mathbf{x}+\boldsymbol{\delta}) \approx \mathbf{F}(\mathbf{x}) + J(\mathbf{x})\boldsymbol{\delta}
$$

where $J$ is the ``Jacobian'' of $\mathbf{F}$

####  The Jacobian

The Jacobian of $\mathbf{F}(\mathbf{x})$ is the matrix whose rows are the gradients of the individual functions $F_i(\mathbf{x})$

$$
 J =
    \begin{bmatrix}
        \nabla F_1^T \\
        \nabla F_2^T \\
        \vdots \\
        \nabla F_n^T\\
\end{bmatrix}
$$

or whose components are

$$
    J_{ij} = \frac{\partial F_i}{\partial x_j}
$$

i.e. all the partial derivatives of all the functions with respect to all the variables

### Example

Our toy non-linear problem
$$
\begin{align}
    x_1^2 + 2x_2^2 &=\gamma_1 \\
    x_1 + 5x_2 &=\gamma_2 \\
\end{align}
$$

Can be rewritten in residual form as
$$
\mathbf{F}(\mathbf{x}) = \begin{bmatrix}
    x_1^2 + 2x_2^2 - \gamma_1 \\
    x_1 + 5x_2 - \gamma_2 \\
 \end{bmatrix}
$$

with Jacobian?

$$
    J(\mathbf{x}) = \begin{bmatrix}
    2x_1 &  4x_2  \\
    1 & 5  \\
 \end{bmatrix}
$$

## Newton's method for $\mathbf{F}(\mathbf{x}) = \mathbf{0}$ (finally)

Given all the pieces,  raw Newton for $\mathbf{F}(\mathbf{x}):\mathbb{R}^n\rightarrow\mathbb{R}^n$ is simply

given an initial guess $\mathbf{x}$ such that $\mathbf{F}(\mathbf{x})\neq\mathbf{0}$: seek a correction such that

$$
   \mathbf{F}(\mathbf{x}+\boldsymbol{\delta})\approx \mathbf{F}(\mathbf{x}) + J\boldsymbol{\delta} = \mathbf{0}
$$

or solve

$$
    J\boldsymbol{\delta} = -\mathbf{F}(\mathbf{x})
$$

and correct $\mathbf{x}\leftarrow\mathbf{x}+\boldsymbol{\delta}$

or as an iterative scheme:

Set an initial guess $x_0$. Then iterate for $k=0,1,2\ldots$

$$
\begin{align}
    \text{Solve:} & &  J(\mathbf{x}_k)\boldsymbol{\delta_k} &= -\mathbf{F}(\mathbf{x}_k)\\
    & &\mathbf{x}_{k+1} &= \mathbf{x}_k + \boldsymbol{\delta}_k
\end{align}
$$

until $||\mathbf{F}(\mathbf{x}_k)|| < \text{tol}$

```python
# Some quick and dirty Code
def newton(F,J,x0,tol=1.e-6,MAX_ITS=100,verbose=True):
    """ Solve F(x) = 0 using Newton's method until ||F(x)|| < tol or reaches Max iterations

    Params:
    -------
        F: calleable:
            Function returning residual F(x)
        J: calleable
            Function returning Jacobian J(x)
        tol: float
            stopping criteria for ||F|| < tol
        MAX_ITS: integer
            maximum number of iterations
        verbose: bool
            if true spits out norm of the residual at each iteration

    Returns:
    --------
        x: list
            list of points for each Newton iteration (just used for plotting intermediate results)
            the solution is x[-1]
    Raises:
    -----------
        ValueError if number of its exceeds MAX_ITS

    """
    x = [ x0 ]
    for k in range(MAX_ITS+1):
        xk = x[k]
        res = numpy.linalg.norm(F(xk))
        if verbose:
            print('k = {}, ||F|| = {}'.format(k,res))
        delta = numpy.linalg.solve(J(xk),-F(xk))
        x.append( xk + delta)

        if res < tol:
            return numpy.array(x), k

    raise ValueError('Maximum number of iterations exceeded {}'.format(MAX_ITS))

```

### Let's test with our toy problem

```python
gamma = numpy.array([10, -2])
Z1 = lambda gamma: X**2 + 2*Y**2 - gamma
Z2 = lambda gamma: X + 5*Y - gamma
F = lambda x: numpy.array([ x[0]**2 + 2*x[1]**2 - gamma[0],
                            x[0]    + 5*x[1]    - gamma[1] ])
J = lambda x: numpy.array([[ 2*x[0],  4*x[1] ],
                           [ 1    ,    5   ]])
x0 = numpy.array([-2, 1.])

x, nits = newton(F,J,x0)
print('\n',x)
```

```python
xa = numpy.linspace(-4,4,100)
ya = numpy.linspace(-4,4,100)
X,Y = numpy.meshgrid(xa,ya)

Z1 = lambda gamma: X**2 + 2*Y**2 - gamma
Z2 = lambda gamma: X + 5*Y - gamma

fig = plt.figure(figsize=(8,6))

axes = fig.add_subplot(1,1,1)
axes.contour(X,Y,Z1(gamma[0]),[0],colors='r')
axes.contour(X,Y,Z2(gamma[1]),[0],colors='b')
for xi in x:
    axes.plot(xi[0],xi[1],'gx',markersize=8)
axes.plot(xi[0],xi[1],'go',markersize=10)

axes.axis('equal')

axes.set_xlabel('$x_1$')
axes.set_ylabel('$x_2$')
axes.set_title('$\gamma_1={}, \gamma_2={}$'.format(gamma_1[i],gamma_2[i]))
axes.grid()

plt.show()

```

### A project idea:

Write an adaptive BDF-2/PDF-3  scheme that can solve a general non-linear problem and test it using the vander-pol oscillator.  This turns out to be a lot more challenging than it looks for the vander-pol problem.


# $\frac{dE_{\rm GW}}{dt}$, the gravitational wave flux, and $\frac{dM}{dt}$, the tidal energy injected into the black holes

## This notebook constructs $\frac{dE_{\rm GW}}{dt}$, the post-Newtonian gravitational wave flux with all known terms up to 4PN order, and $\frac{dM}{dt}$, the tidal energy injected into the black holes at 2.5PN

The status of various terms in the PN expression for $\frac{dE_{\rm GW}}{dt}$ is discussed in [Ajith *et al* (2007)](https://arxiv.org/abs/0709.0093). [Ossokine *et al* (2015)](https://arxiv.org/abs/1502.01747) is the primary reference for this notebook, and their expression was corrected and extended using [Blanchet (2014)](https://link.springer.com/content/pdf/10.12942/lrr-2014-2.pdf) and [Ajith *et al* (2007)](https://arxiv.org/abs/0709.0093), respectively.

**Notebook Status:**  Validated

**Validation Notes:** All expressions in this notebook were transcribed twice by hand on separate occasions, and expressions were corrected as needed to ensure consistency with published work. Published work was cross-validated and typo(s) in published work were corrected. In addition, this tutorial notebook has been confirmed to be self-consistent with its corresponding NRPy+ module, as documented [below](#code_validation). **Additional validation tests may have been performed, but are as yet, undocumented.**

## Author: Zach Etienne

### This notebook exists as the following Python module:
1. [PN_dE_GW_dt_and_dM_dt.py](../../edit/NRPyPN/PN_dE_GW_dt_and_dM_dt.py)

### This notebook & corresponding Python module depend on the following NRPy+/NRPyPN Python modules:
1. [indexedexp.py](../../edit/indexedexp.py): [**documentation+tutorial**](../Tutorial-Indexed_Expressions.ipynb)
1. [NRPyPN_shortcuts.py](../../edit/NRPyPN/NRPyPN_shortcuts.py): [**documentation**](NRPyPN_shortcuts.ipynb)

# Table of Contents
$$\label{toc}$$

1. Part 0: [Import needed Python modules](#imports)
1. Part 1: [Define constants appearing within $\frac{dE_{\rm GW}}{dt}$ and $\frac{dM}{dt}$ expressions](#constants)
1. Part 2: [Construct $\frac{dE_{\rm GW}}{dt}$ and $\frac{dM}{dt}$ expressions](#expressions)
1. Part 3: [Validation against corresponding Python module](#code_validation)
1. Part 4: [LaTeX PDF output](#latex_pdf_output): $\LaTeX$ PDF Output

# Part 0: Import needed Python modules \[Back to [top](#toc)\]
$$\label{imports}$$

```python
# Step 0: Add NRPy's directory to the path
# https://stackoverflow.com/questions/16780014/import-file-from-parent-directory
import sympy as sp               # SymPy: The Python computer algebra package upon which NRPy+ depends
import indexedexpNRPyPN as ixp         # NRPy+: Symbolic indexed expression (e.g., tensors, vectors, etc.) support
from NRPyPN_shortcuts import div,dot,gamma_EulerMascheroni  # NRPyPN: shortcuts for e.g., vector operations
```

# Part 1: Define constants appearing within $\frac{dE_{\rm GW}}{dt}$ expression \[Back to [top](#toc)\]
$$\label{constants}$$

This notebook uses three sources for the terms in $\frac{dE_{\rm GW}}{dt}$:
* [Blanchet (2014)](https://link.springer.com/content/pdf/10.12942/lrr-2014-2.pdf), for the nonspinning terms
* [Ossokine *et al* (2015)](https://arxiv.org/abs/1502.01747), including precessing spin terms
* [Ajith *et al* (2007)](https://arxiv.org/abs/0709.0093), including tidal-heating injected energy into the black holes [Alvi (2001)](https://arxiv.org/abs/gr-qc/0107080)

Constants appearing within $\frac{dE_{\rm GW}}{dt}$ expression in the above sources are as follows. Note that to reduce possibility of copying error, these equations are taken directly from Eqs A1-A13 of the LaTeX source code of [Ossokine *et al* (2015)](https://arxiv.org/abs/1502.01747), and only mildly formatted to (1) improve presentation in Jupyter notebooks and (2) to ensure some degree of consistency in notation across different NRPyPN notebooks:

\begin{equation}
\begin{split}
m &= m_{1}+m_{2},\\
\nu &= \frac{m_{1}m_{2}}{m^{2}},\\
\delta &= \frac{m_{1}-m_{2}}{m},\\
\mathbf{S} &= \mathbf{S}_{1}+\mathbf{S}_{2},\\
s_{l} &= \frac{\mathbf{S}\cdot\hat\ell}{m^{2}},\\
s_{n} &= \frac{\mathbf{S}\cdot\hat n}{m^{2}},\\
\mathbf{\Sigma} &= \frac{m}{m_2}\mathbf{S}_{2} - \frac{m}{m_1}\mathbf{S}_{1},
\end{split}
\quad\quad\quad
\begin{split}
\sigma_{l} &=\frac{\mathbf\Sigma\cdot\hat\ell}{m^{2}}, \\
\sigma_{n} &= \frac{\mathbf\Sigma\cdot\hat n}{m^{2}}, \\
\mathbf{\chi}_{s} &= \frac{1}{2}\left(\mathbf{\chi}_{1}+\mathbf{\chi}_{2}\right),\\
\mathbf{\chi}_{a} &= \frac{1}{2}\left(\mathbf{\chi}_{1}-\mathbf{\chi}_{2}\right),\\
\mathbf{S}_0 & = \frac{m}{m_1}\mathbf{S}_{1}+\frac{m}{m_2}\mathbf{S}_{2},\\
\mathbf{s}_0 & = \frac{\mathbf{S}_0}{m^2},
\end{split}
\end{equation}
where $\mathbf{\ell}$ is the unit normal to the instantaneous orbital plane. Since the instantaneous orbital plane here is assumed to be the $xy$ plane, below we set $\mathbf{\ell}=\{0,0,1 \}$.

In addition the [Euler-Mascheroni constant](https://en.wikipedia.org/wiki/Euler%E2%80%93Mascheroni_constant) $\gamma_{E}$ is used in the expression for $\frac{dE_{\rm GW}}{dt}$.

```python
# Constants given in Eqs A1-13 of https://arxiv.org/abs/1502.01747
def dE_GW_dt_OBKPSS2015_consts(m1,m2, n12U, S1U,S2U):
    # define scalars:
    m  = (m1+m2)
    nu = m1*m2/m**2
    delta = (m1-m2)/m
    # define vectors:
    Stot = ixp.zerorank1()
    Sigma= ixp.zerorank1()
    l    = ixp.zerorank1()
    l[2] = sp.sympify(1)
    chi1U = ixp.zerorank1()
    chi2U = ixp.zerorank1()
    chi_s = ixp.zerorank1()
    chi_a = ixp.zerorank1()
    for i in range(3):
        Stot[i] = S1U[i] + S2U[i]
        Sigma[i] = (m1+m2)/m2*S2U[i] - (m1+m2)/m1*S1U[i]
        chi1U[i] = S1U[i]/m1**2
        chi2U[i] = S2U[i]/m2**2
        chi_s[i] = div(1,2) * (chi1U[i] + chi2U[i])
        chi_a[i] = div(1,2) * (chi1U[i] - chi2U[i])
    # define scalars that depend on vectors
    s_l = dot(Stot,l)   /m**2
    s_n = dot(Stot,n12U)/m**2
    sigma_l = dot(Sigma,l)/m**2
    sigma_n = dot(Sigma,n12U)/m**2
    return nu,delta,  l,chi_a,chi_s,  s_l,s_n,sigma_l,sigma_n
```

# Part 2: Construct $\frac{dE_{\rm GW}}{dt}$ and $\frac{dM}{dt}$ expressions \[Back to [top](#toc)\]
$$\label{expressions}$$

As described in [Ossokine *et al* (2015)](https://arxiv.org/abs/1502.01747), the gravitational wave flux is given by

$$
  \frac{dE}{dt} = -\mathcal{F},
$$
where

$$
\mathcal{F}(x) = \frac{32}{5}\nu^{2}x^{5}\left(1+\sum_{k=2} b_{k}x^{k/2}\right),
$$
and (taking the following equations directly from Eqs A22-A28 of the LaTeX source code of [Ossokine *et al* (2015)](https://arxiv.org/abs/1502.01747), and only mildly formatted to (1) improve presentation in Jupyter notebooks and (2) to ensure some degree of consistency in notation across different NRPyPN notebooks):

\begin{eqnarray}
b_{2} &=& -\frac{1247}{336}-\frac{35}{12}\nu, \\
b_{3} &=& 4\pi - 4s_l - \frac{5}{4}\delta\sigma_{l}, \\
b_{4} &=& -\frac{44711}{9072} + \frac{9271}{504}\nu + \frac{65}{18}\nu^{2}  + \left(\frac{287}{96}+\frac{\nu}{24}\right)(\mathbf{\chi}_{s}\cdot\mathbf{\ell})^{2}\nonumber\\
&&- \left(\frac{89}{96}+\frac{7\nu}{24}\right)\mathbf{\chi}_{s}^{2} + \left(\frac{287}{96}-12\nu\right)(\mathbf{\chi}_{a}\cdot\mathbf{\ell})^{2} + \left(-\frac{89}{96}+4\nu\right)\mathbf{\chi}_{a}^{2} + \frac{287}{48}\delta(\mathbf{\chi}_{s}\cdot\mathbf{\ell})(\mathbf{\chi}_{a}\cdot\mathbf{\ell}) - \frac{89}{48}\delta(\mathbf{\chi}_{s}\cdot\mathbf{\chi}_{a}),
\\
b_{5} &=& -\frac{8191}{672}\pi - \frac{9}{2}s_{l} - \frac{13}{16}\delta\sigma_{l} + \nu\left[-\frac{583}{24}\pi + \frac{272}{9}s_{l} + \frac{43}{4}\delta\sigma_{l}\right],\\
b_{6} &=& \frac{6643739519}{69854400} + \frac{16}{3}\pi^{2} - \frac{1712}{105}\gamma_{E} - \frac{856}{105}\log(16x)+
 \left(\frac{-134543}{7776}+\frac{41}{48}\pi^{2}\right)\nu -
 \frac{94403}{3024}\nu^{2}-\frac{775}{324}\nu^{3} \nonumber\\
 && - 16\pi s_{l} - \frac{31\pi}{6}\delta\sigma_{l},\\
 b_{7}&=& \left(\frac{476645}{6804} + \frac{6172}{189}\nu -
   \frac{2810}{27}\nu^{2}\right)s_{l} +
 \left(\frac{9535}{336}+\frac{1849}{126}\nu -
   \frac{1501}{36}\nu^{2}\right)\delta\sigma_{l} \nonumber\\
&&+  \left(-\frac{16285}{504} \boxed{+} \frac{214745}{1728}\nu +\frac{193385}{3024}\nu^{2}\right)\pi,\\
 b_{8} &=& \left(-\frac{3485\pi}{96} + \frac{13879\pi}{72}\nu\right)s_{l} + \left(-\frac{7163\pi}{672} + \frac{130583\pi}{2016}\nu\right)\delta\sigma_l;
\end{eqnarray}
notice the boxed term above, which contains a missing plus sign, as confirmed by comparing nonspinning terms to those found in [Blanchet (2014)](https://link.springer.com/content/pdf/10.12942/lrr-2014-2.pdf). Finally,

$$
x = (m\Omega)^{2/3}.
$$
Finally, from Eq A.14 of [Ajith *et al* (2007)](https://arxiv.org/abs/0709.0093), we may include the time-changing mass of the black holes $\frac{dM}{dt}$ due to injected energy from tidal heating [Alvi (2001)](https://arxiv.org/abs/gr-qc/0107080):

$$
b_{5,\rm Mdot} = \left\{
      -\frac{1}{4}\, \Big[ (1 - 3\nu) \chi_{s} (1 + 3\chi_{s}^{2}
      + 9\chi_{a}^{2}) + (1 - \nu) \delta \chi_{a} (1 + 3\chi_{a}^{2}
      + 9\chi_{s}^{2}) \Big] \right\}~,
$$
where $\chi_{s}=\mathbf{\chi_s}\cdot\mathbf{\ell}$ and $\chi_{a}=\mathbf{\chi_a}\cdot\mathbf{\ell}$ (i.e., the components aligned with the orbital angular momentum).

```python
# Based on Eqs A22-28 of https://arxiv.org/abs/1502.01747, with
#       Eq A.14 of https://arxiv.org/abs/0709.0093 for Mdot
#       and correction on b[7] term by comparison with
#  https://link.springer.com/content/pdf/10.12942/lrr-2014-2.pdf
def f_dE_GW_dt_and_dM_dt(mOmega, m1,m2, n12U, S1U,S2U):
    def f_compute_quantities(mOmega, m1,m2, n12U, S1U,S2U, which_quantity):
        if not (which_quantity == "dM_dt" or
                which_quantity == "dE_GW_dt" or
                which_quantity == "dE_GW_dt_plus_dM_dt"):
            print("which_quantity == "+str(which_quantity)+" not supported!")
            sys.exit(1)

        nu,delta,  l,chi_a,chi_s,  s_l,s_n,sigma_l,sigma_n = dE_GW_dt_OBKPSS2015_consts(m1,m2, n12U, S1U,S2U)
        x = (mOmega)**div(2,3)

        # Compute b_5_Mdot:
        b_5_Mdot = (-div(1,4)*(+(1-3*nu)*dot(chi_s,l)*(1+3*dot(chi_s,l)**2+9*dot(chi_a,l)**2)
                            +(1-  nu)*delta*dot(chi_a,l)*(1+3*dot(chi_a,l)**2+9*dot(chi_s,l)**2)))
        if which_quantity == "dM_dt":
            return div(32,5)*nu**2*x**5*b_5_Mdot*x**div(5,2)

        b = ixp.zerorank1(DIM=10)
        b[2] = -div(1247,336) - div(35,12)*nu
        b[3] = +4*sp.pi - 4*s_l - div(5,4)*delta*sigma_l
        b[4] =(-div(44711,9072) + div(9271,504)*nu + div(65,18)*nu**2
               +(+div(287,96) + div( 1,24)*nu)*dot(chi_s,l)**2
               -(+div( 89,96) + div( 7,24)*nu)*dot(chi_s,chi_s)
               +(+div(287,96) -         12*nu)*dot(chi_a,l)**2
               +(-div( 89,96) +          4*nu)*dot(chi_a,chi_a)
               +div(287,48)*delta*dot(chi_s,l)*dot(chi_a,l) - div(89,48)*delta*dot(chi_s,chi_a))
        b[5] =(-div(8191,672)*sp.pi - div(9,2)*s_l - div(13,16)*delta*sigma_l
               +nu*(-div(583,24)*sp.pi + div(272,9)*s_l + div(43,4)*delta*sigma_l))
        if which_quantity == "dE_GW_dt_plus_dM_dt":
            b[5]+= b_5_Mdot
        b[6] =(+div(6643739519,69854400) + div(16,3)*sp.pi**2 - div(1712,105)*gamma_EulerMascheroni
               -div(856,105)*sp.log(16*x) + (-div(134543,7776) + div(41,48)*sp.pi**2)*nu
               -div(94403,3024)*nu**2 - div(775,324)*nu**3 - 16*sp.pi*s_l - div(31,6)*sp.pi*delta*sigma_l)
        b[7] =(+(+div(476645,6804) + div(6172,189)*nu - div(2810,27)*nu**2)*s_l
               +(+div(9535,336) + div(1849,126)*nu - div(1501,36)*nu**2)*delta*sigma_l
               +(-div(16285,504) + div(214745,1728)*nu + div(193385,3024)*nu**2)*sp.pi)
        b[8] =(+(-div(3485,96)*sp.pi + div(13879,72)*sp.pi*nu)*s_l
               +(-div(7163,672)*sp.pi + div(130583,2016)*sp.pi*nu)*delta*sigma_l)
        b_sum = sp.sympify(1)
        for k in range(9):
            b_sum += b[k]*x**div(k,2)
        return div(32,5)*nu**2*x**5*b_sum

    global dE_GW_dt_plus_dM_dt, dE_GW_dt, dM_dt
    dE_GW_dt_plus_dM_dt = \
               f_compute_quantities(mOmega, m1,m2, n12U, S1U,S2U, "dE_GW_dt_plus_dM_dt")
    dE_GW_dt = f_compute_quantities(mOmega, m1,m2, n12U, S1U,S2U, "dE_GW_dt")
    dM_dt    = f_compute_quantities(mOmega, m1,m2, n12U, S1U,S2U, "dM_dt")
```

## Part 2.b: Validation against another version transcribed on a separate occasion

```python
# Second version, used for validation purposes only.
#   Eq A15 of https://arxiv.org/pdf/1502.01747.pdf
#   Eq A.14 of https://arxiv.org/pdf/0709.0093.pdf for Mdot
def f_dE_GW_dt_plus_dM_dt_v2(Omega, m1,m2, n12U, S1U,S2U):
    m  = (m1+m2)
    nu = m1*m2/m**2
    delta = (m1-m2)/m
    Stot = ixp.zerorank1()
    Sigma= ixp.zerorank1()
    l    = ixp.zerorank1()
    l[2] = sp.sympify(1)
    chi1U = ixp.zerorank1()
    chi2U = ixp.zerorank1()
    chi_s = ixp.zerorank1()
    chi_a = ixp.zerorank1()
    for i in range(3):
        Stot[i] = S1U[i] + S2U[i]
        Sigma[i] = (m1+m2)/m2*S2U[i] - (m1+m2)/m1*S1U[i]
        chi1U[i] = S1U[i]/m1**2
        chi2U[i] = S2U[i]/m2**2
        chi_s[i] = div(1,2) * (chi1U[i] + chi2U[i])
        chi_a[i] = div(1,2) * (chi1U[i] - chi2U[i])
    s_l = dot(Stot,l)   /m**2
    s_n = dot(Stot,n12U)/m**2
    b_2 = -div(1247,336)-div(35,12)*nu
    sigma_l = dot(Sigma,l)/m**2
    sigma_n = dot(Sigma,n12U)/m**2

    # We already multiply Omega by m=m1+m2
    x = (Omega)**div(2,3)

    b_0 = sp.sympify(1)
    b_2 = -div(1247,336) - div(35,12)*nu
    b_3 = 4*sp.pi - 4*s_l - div(5,4)*delta*sigma_l
    b_4 = (-div(44711,9072) + div(9271,504)*nu + div(65,18)*nu**2
           + (div(287,96) + nu/24)*dot(chi_s,l)**2
           -(div(89,96)+7*nu/24)*dot(chi_s,chi_s) + (div(287,96)-12*nu)*dot(chi_a,l)**2
           +(-div(89,96)+4*nu)*dot(chi_a,chi_a) + div(287,48)*delta*dot(chi_s,l)*dot(chi_a,l)
           -div(89,48)*delta*dot(chi_s,chi_a))
    b_5 = (-div(8191,672)*sp.pi - div(9,2)*s_l - div(13,16)*delta*sigma_l
           +nu*(-div(583,24)*sp.pi + div(272,9)*s_l + div(43,4)*delta*sigma_l))
    b_6 = (+div(6643739519,69854400) + div(16,3)*sp.pi**2 - div(1712,105)*gamma_EulerMascheroni
           -div(856,105)*sp.log(16*x) + (-div(134543,7776)+div(41,48)*sp.pi**2)*nu
           -div(94403,3024)*nu**2 - div(775,324)*nu**3 - 16*sp.pi*s_l - div(31,6)*sp.pi*delta*sigma_l)
    b_7 = (+(+div(476645,6804)+div(6172,189)*nu-div(2810,27)*nu**2)*s_l
           +(+div(9535,336)  + div(1849,126)   *nu - div(1501,36)    *nu**2)*delta*sigma_l
           +(-div(16285,504) + div(214745,1728)*nu + div(193385,3024)*nu**2)*sp.pi)
    b_8 = (+(-div(3485,96)*sp.pi + div(13879,72)*sp.pi*nu)*s_l
           +(-div(7163,672)*sp.pi + div(130583,2016)*sp.pi*nu)*delta*sigma_l)

    # Eq A.14 of https://arxiv.org/pdf/0709.0093.pdf for Mdot.
    #   note that this expression only considers spin-aligned cases,
    #   so we dot the chi's with the zhat direction.
    b_5_Mdot = -div(1,4)*(+(1-3*nu)*dot(chi_s,l)*(1+3*dot(chi_s,l)**2+9*dot(chi_a,l)**2)
                          +(1-  nu)*delta*dot(chi_a,l)*(1+3*dot(chi_a,l)**2+9*dot(chi_s,l)**2))
    global dE_GW_dt_plus_dM_dtv2
    dE_GW_dt_plus_dM_dtv2 = div(32,5)*nu**2*x**5*(+b_0
                                                  +b_2*x**div(2,2)
                                                  +b_3*x**div(3,2)
                                                  +b_4*x**div(4,2)
                                                  +(b_5+b_5_Mdot)*x**div(5,2)
                                                  +b_6*x**div(6,2)
                                                  +b_7*x**div(7,2)
                                                  +b_8*x**div(8,2))
```

# Part 3: Validation against corresponding Python module \[Back to [top](#toc)\]
$$\label{code_validation}$$

As a code validation check, we verify agreement between the SymPy expressions generated in this notebook, and the corresponding Python module.

```python
from NRPyPN_shortcuts import m1,m2,n12U,S1U,S2U # NRPyPN: import needed input variables.

Omega = sp.symbols('Omega',real=True)
f_dE_GW_dt_and_dM_dt(Omega, m1,m2, n12U, S1U,S2U)

def error(varname):
    print("ERROR: When comparing Python module & notebook, "+varname+" was found not to match.")
    sys.exit(1)

# Validation against second transcription of the expressions:
f_dE_GW_dt_plus_dM_dt_v2(Omega, m1,m2, n12U, S1U,S2U)
f_dE_GW_dt_and_dM_dt(Omega, m1,m2, n12U, S1U,S2U)

if sp.simplify(dE_GW_dt_plus_dM_dt - dE_GW_dt_plus_dM_dtv2) != 0: error("dE_GW_dt_plus_dM_dtv2")

# Validation against corresponding Python module:
import PN_dE_GW_dt_and_dM_dt as dEdt
dEdt.f_dE_GW_dt_and_dM_dt(Omega, m1,m2, n12U, S1U,S2U)

if sp.simplify(dE_GW_dt_plus_dM_dt - dEdt.dE_GW_dt_plus_dM_dt) != 0: error("dE_GW_dt_plus_dM_dt")
if sp.simplify(dE_GW_dt            - dEdt.dE_GW_dt)            != 0: error("dE_GW_dt")
if sp.simplify(dM_dt               - dEdt.dM_dt)               != 0: error("dM_dt")

# Finally, confirm that dE_GW_dt_plus_dM_dt = dE_GW_dt + dM_dt
if sp.simplify(dE_GW_dt_plus_dM_dt - (dE_GW_dt + dM_dt)) != 0: error("dE_GW_dt + dM_dt")

print("ALL TESTS PASS")
```

    ALL TESTS PASS

# Part 4: Output this notebook to $\LaTeX$-formatted PDF file \[Back to [top](#toc)\]
$$\label{latex_pdf_output}$$

The following code cell converts this Jupyter notebook into a proper, clickable $\LaTeX$-formatted PDF file. After the cell is successfully run, the generated PDF may be found in the root NRPy+ tutorial directory, with filename
[PN-dE_GW_dt_and_dM_dt.pdf](PN-dE_GW_dt_and_dM_dt.pdf) (Note that clicking on this link may not work; you may need to open the PDF file through another means.)

```python
import os,sys                    # Standard Python modules for multiplatform OS-level functions
import cmdline_helperNRPyPN as cmd    # NRPy+: Multi-platform Python command-line interface
cmd.output_Jupyter_notebook_to_LaTeXed_PDF("PN-dE_GW_dt_and_dM_dt",location_of_template_file=os.path.join(".."))
```

    Created PN-dE_GW_dt_and_dM_dt.tex, and compiled LaTeX file to PDF file PN-
        dE_GW_dt_and_dM_dt.pdf


```python
from sympy import *
import numpy as np
import sys

#a, b, c, d = map(float, input('Please input a, b, c, d\n').split())

var("a:z")
result = solve(a*x**2 + b*x + c, x)
```

```python
init_printing()
display(result)
```

```python

```


You'll need a few packages for this to work. You can install everything you need by running these two commands:

    conda install astropy numpy h5py matplotlib tqdm
    pip install astro-gala pyia

```python
import sys
from os import path
import warnings

# Third-party
from astropy.table import Table
from astropy.io import fits
import astropy.coordinates as coord
import astropy.units as u
import h5py
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
%matplotlib inline
from tqdm import tqdm

import gala.dynamics as gd
import gala.integrate as gi
import gala.potential as gp
from gala.units import galactic
from pyia import GaiaData
```

    /Users/adrian/anaconda/lib/python3.6/site-packages/h5py/__init__.py:36: FutureWarning: Conversion of the second argument of issubdtype from `float` to `np.floating` is deprecated. In future, it will be treated as `np.float64 == np.dtype(float).type`.
      from ._conv import register_converters as _register_converters

We'll use a simple model for the Milky Way potential that is implemented in `Gala` that contains components for the Galactic disk, bulge, nucleus, and dark matter halo:

```python
mw = gp.MilkyWayPotential()
H = gp.Hamiltonian(mw)
print(mw.keys())
```

    odict_keys(['disk', 'bulge', 'nucleus', 'halo'])

We'll need to transform observed positions and velocities to the Galactocentric rest frame. To do that, we have to make assumptions about the solar velocity and position. We'll assume the Sun is at:

$$
\begin{align}
\boldsymbol{r}_{\odot} &= (-8, 0, 0)~{\rm kpc}\\
\boldsymbol{v}_{\odot} &= (11.1, 232.24, 7.25)~{\rm kpc}
\end{align}
$$

(but feel free to play with the definitions if you prefer other values).

```python
rsun = 8 * u.kpc
vsun = [11.1, 232.24, 7.25] * u.km/u.s
```

```python
gc_frame = coord.Galactocentric(galcen_distance=rsun,
                                galcen_v_sun=coord.CartesianDifferential(*vsun))
```

We next need to load some Gaia data. For now, I'll load a mock (simulated_ dataset with DR2-like uncertainties for all stars within 200 pc of the Sun:

```python
g = GaiaData('/Users/adrian/data/GaiaDR2-mock/Gaia-DR2-mock-200pc.fits')
```

We can use this object to get an Astropy sky coordinate object, which has the sky positions, distance (parallax), proper motions, and radial velocity automatically filled:

```python
c = g.skycoord
c
```

    <SkyCoord (ICRS): (ra, dec, distance) in (deg, deg, pc)
        [(315.07577633,  35.40392509, 197.99079895),
         (314.88167156,  35.3907023 , 123.34000397),
         (314.86214545,  35.39305834, 148.10754395), ...,
         ( 45.02060181, -35.32089102, 187.71482849),
         ( 44.93473399, -35.33031997, 172.88237   ),
         ( 44.94769253, -35.28921706, 129.5793457 )]
     (pm_ra_cosdec, pm_dec, radial_velocity) in (mas / yr, mas / yr, km / s)
        [( -8.97676, -44.3531 , -18.882  ), ( 20.8935 , -68.4191 , -11.6318 ),
         ( 15.4162 ,  -1.7871 ,  -6.852  ), ...,
         (-58.3823 , -29.8226 ,  -5.77041), ( -0.30213,  10.4613 ,  21.0896 ),
         ( 86.0874 ,  -4.13801,  26.4174 )]>

**Note: not all Gaia DR2 stars will have radial velocities, so you may have to filter out those stars here**

Next, we will transform these heliocentric positions/velocities into Galactocentric values, then pass in to a Gala class that will handle computing dynamical quantities from the Galactocentric Cartesian phase-space positions:

```python
w = gd.PhaseSpacePosition(c.transform_to(gc_frame).cartesian)
```

With this object, we can do things like compute the angular momentum:

```python
L = w.angular_momentum()
```

```python
L.shape
```

    (3, 2799632)

Or, given a model for the gravitational potential, compute the energy of the stellar orbits:

```python
E = H.energy(w)
```

Let's now plot the energy vs. $z$-component of the angular momentum (index 2):

```python
L_unit = u.km/u.s * u.kpc
E_unit = u.km/u.s * u.kpc/u.Myr

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

ax.plot(L[2].to(L_unit).value,
        E.to(E_unit).value,
        linestyle='none', marker=',', alpha=0.2)

ax.set_xlim(-3000, -500)
ax.set_ylim(-160, -100)
ax.set_xlabel('$L_z$ [{0:latex_inline}]'.format(L_unit))
ax.set_ylabel('$E$ [{0:latex_inline}]'.format(E_unit))

fig.tight_layout()
```

That's a lot of points! Let's instead make a histogram so we can look at the log-density of points:

```python
E_grid = np.linspace(-160, -100, 128)
Lz_grid = np.linspace(-3000, -500, 128)
H, xedg, yedg = np.histogram2d(L[2].to(L_unit).value, E.to(E_unit).value,
                               bins=(Lz_grid, E_grid))
```

```python
norm = mpl.colors.LogNorm(vmin=1e-1, vmax=1E5)

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

ax.pcolormesh(xedg, yedg, H.T, norm=norm, cmap='Blues')

ax.set_xlabel('$L_z$ [{0:latex_inline}]'.format(L_unit))
ax.set_ylabel('$E$ [{0:latex_inline}]'.format(E_unit))

fig.tight_layout()
```

That's a very smooth distribution! The real Galaxy probably won't look like that...but we'll see!

We can also compute other quantities for these stars, like the actions. These are other integrals of motion that are useful because they are adiabatically invariant, and because they are conserved in a static potential (unlike the angular momentum components which can vary if the orbit is not planar and the potential is non-spherical). The problem is that, except in very simple potential models, computing the actions has to be done numerically. There are many algorithms out there for estimating actions (see papers by Jason Sanders, James Binney, Jo Bovy). Here, with Gala, we'll use a method that requires numerically integrating orbits in order to compute the actions. This makes it quite slow to run for millions of stars, but we can at least run for a subset of stars as a demo. In practice, you can parallelize this or run on batches (subsets) of stars.

### How do we choose an integration timestep?

One thing we have to choose when numerically estimating the actions is the timestep and length of orbit integration. We'll set the length to 5 Gyr (~20 complete orbits of a sun-like orbit around the Galaxy), and vary the timestep to see if the value of the actions converges:

```python
all_actions = []
dts = [0.1, 0.2, 0.4, 0.8, 1., 2., 4, 8] * u.Myr

with warnings.catch_warnings(record=True):
    warnings.simplefilter("ignore")
    for dt in tqdm(dts):
        orbit = mw.integrate_orbit(w[0], dt=dt, t1=0*u.Gyr, t2=5*u.Gyr,
                                   Integrator=gi.DOPRI853Integrator)
        res = gd.actionangle.find_actions(orbit, N_max=8)
        all_actions.append(res['actions'])
```

    100%|██████████| 8/8 [00:15<00:00,  1.95s/it]

```python
act = u.Quantity(all_actions)

plt.figure(figsize=(6, 5))

for k in range(3):
    plt.plot(dts[1:], np.abs((act[1:, k]-act[0, k])/act[0, k]),
             label='$J_{0}$'.format(k+1))

plt.xscale('log')
plt.yscale('log')
plt.xlabel('timestep [Myr]')
plt.ylabel('fractional error')
plt.legend(loc='best', fontsize=16)
plt.tight_layout()
```

From this, it looks like we can set the timestep to 2 Myr and only suffer a fractional error of $10^{-5}$. How long does computing the actions for one orbit take?

```python
%%time
orbit = mw.integrate_orbit(w[0], dt=2*u.Myr, t1=0*u.Gyr, t2=5*u.Gyr,
                           Integrator=gi.DOPRI853Integrator)
res = gd.actionangle.find_actions(orbit, N_max=8)
```

    CPU times: user 594 ms, sys: 28.5 ms, total: 623 ms
    Wall time: 429 ms

    /Users/adrian/projects/gala/build/lib.macosx-10.7-x86_64-3.6/gala/dynamics/actionangle.py:502: UserWarning: More unknowns than equations!
      warnings.warn("More unknowns than equations!")

~0.5 seconds! Let's run on a subset (128) of the orbits:

```python
some_w = w[:128]
orbits = mw.integrate_orbit(some_w, dt=1*u.Myr, t1=0*u.Gyr, t2=4*u.Gyr,
                            Integrator=gi.DOPRI853Integrator)

with warnings.catch_warnings(record=True):
    warnings.simplefilter("ignore")

    all_actions = []
    for orbit in tqdm(orbits.orbit_gen(), total=some_w.shape[0]):
        res = gd.actionangle.find_actions(orbit, N_max=8)
        all_actions.append(res['actions'])
    all_actions = u.Quantity(all_actions)
```

    100%|██████████| 128/128 [01:20<00:00,  1.59it/s]

This is a simulated data set, and I don't remember what kind of age-action relations were put in, but we generally expect the vertical action, $J_z$, to increase with stellar age. Let's see if that's the case for these simulated stars:

```python
plt.figure(figsize=(6, 5))
plt.scatter(g.age[:some_w.shape[0]],
            all_actions[:, 2].to(L_unit))
plt.xscale('log')
plt.yscale('log')
plt.xlim(1E-2, 20)
plt.ylim(1E-4, 1e2)
plt.xlabel('age [Gyr]')
plt.ylabel('$J_z$ [{0:latex_inline}]'.format(L_unit))
```

Older stars definitely tend to have larger values of vertical action! Another way to look at this is by looking at the maximum height a star reaches above the plane, $|z_{\rm max}|$:

```python
plt.figure(figsize=(6, 5))
plt.scatter(g.age[:some_w.shape[0]],
            orbits.zmax(approximate=True).to(u.pc).value)
plt.xscale('log')
plt.yscale('log')
plt.xlim(1E-2, 20)
plt.ylim(10, 2e3)
plt.xlabel('age [Gyr]')
plt.ylabel(r'$\left|z_{\rm max}\right|$ ' + '[{0:latex_inline}]'.format(L_unit))
```

```python

```


# CHEM 1000 - Spring 2022
Prof. Geoffrey Hutchison, University of Pittsburgh

## 3 Derivatives

Chapter 3 in [*Mathematical Methods for Chemists*](http://sites.bu.edu/straub/mathematical-methods-for-molecular-science/)

By the end of this session, you should be able to:
- Understand the concept of a "total differential"
- Use Euler's test for exact differentials

### Total Differential

As a reminder, we discussed the basic concept of a one-dimensional [derivative](https://en.wikipedia.org/wiki/Derivative).

Consider a generic function and the slope between two points:
(Image from [*Mathematical Methods for Chemists*](http://sites.bu.edu/straub/mathematical-methods-for-molecular-science/))

$$ \frac{df}{dx} \approx \frac{f(x+\Delta x) - f(x)}{\Delta x} $$

In other words, the function changes a little bit ($\Delta f$) when we change $x$ a little bit ($\Delta x$).

If we want to know the change in the function, we can do:

$$
\require{cancel}
\left(\frac{d f}{d x}\right) \times \Delta x=\left(\frac{d f}{\bcancel{d x}}\right) \bcancel{d x}=d f
$$

At first, this may seem like a strange concept - *can you do that*? (i.e., multiplying the derivative $\frac{df}{dx}$ by $dx$.

Remember that the derivative is simply the limit of the slope of the function as $dx \rightarrow 0$. Thus, we can multiply the derivative by an [infinitesimal](https://en.wikipedia.org/wiki/Infinitesimal) $dx$ and get the *differential* of the function $df$.

**Why?**

[Rudolf Clausius](https://en.wikipedia.org/wiki/Rudolf_Clausius) was interested in heat flow, energy, and thermodynamics.

Naturally, when considering heat engines, expansion of gases, etc. the total change in volume, work, etc. are important concepts.

Thus, consider a multivariate function, like the volume of a cylinder. The **partial derivatives** reflect the change in volume from a change in the height or a change in the radius:

(Images from [*Mathematical Methods for Chemists*](http://sites.bu.edu/straub/mathematical-methods-for-molecular-science/))

But what about the *total* change in volume:

$$
d f=\left(\frac{\partial f}{\partial x}\right) d x+\left(\frac{\partial f}{\partial y}\right) d y
$$

or for multiple variables:

$$
d f=\sum_{k=1}^{N}\left(\frac{\partial f}{\partial x_{k}}\right) d x_{k}
$$

For our cylinder example:

$$
d V=\left(\frac{\partial V}{\partial r}\right) d r+\left(\frac{\partial V}{\partial h}\right) d h=2 \pi r h d r+\pi r^{2} d h
$$

Notice that the volume change depends on the radius and height - if the radius is large, even a small change in $dr$ can still have a large change in the volume.

```python
import math # to get pi

dr = 0.01
dh = 0.01
r = 5
h = 3

for r in range(1, 6):
    for h in range(1,4):
        V = math.pi * r**2 * h
        dV = 2*math.pi * r * h * dr + math.pi*r**2 * dh
        print(r, h, V, dV)
```

Notice that as the radius gets larger, $dV$ gets very large because of the $r^2$ term.

### Euler's test and Exact Differentials

Sometimes it's easy to establish the total differential (i.e., you know the function). Sometimes, as we'll find later with differential equations, we don't know the algebraic form exactly.

How do we know if our answer is an ***exact differential***?

We can use the Euler test.

Let's say I give you some function that looks like this and claim it's the total differential for a function:

$$s(x,y)dx + t(x,y) dy$$

Do you believe me?

If the following is true:
$$
\frac{\partial}{\partial y} s(x, y)=\frac{\partial}{\partial x} t(x, y)
$$

then there ***must*** exist some function $f(x,y)$ for which:

$$df = s(x,y)dx + t(x,y) dy$$

Naturally, if the proposed total differential function looks something like this:

$$a(x) dx + b(y) dy$$

Then the function clearly satisfies Euler's test since:
$$
\frac{\partial a}{\partial y} = \frac{\partial b}{\partial x} = 0
$$

***Example:***

Consider:
$$
2x dx + 2y dy
$$

Then:

$$
\frac{\partial}{\partial y} s(x, y)=\frac{\partial}{\partial y} 2 x=0 \quad \frac{\partial}{\partial x} t(x, y)=\frac{\partial}{\partial x} 2 y=0
$$

You should be able to guess that the original function was:

$$f(x,y) = x^2 + y^2$$

***Ideal Gas Law:***

Consder:

$$PV = nRT$$

$$P(V,T) = \frac{nRT}{V}$$

What's the total differential?

$$
d p=\frac{\partial p}{\partial V} d V+\frac{\partial p}{\partial T} d T=-\frac{nR T}{V^{2}} d V+\frac{nR}{V} d T
$$

Does this pass the Euler test?

```python
# Let's use sympy again to help us do the partial derivatives
from sympy import init_session
init_session()
```

```python
# these declare V and T as new symbols to sympy
# (rather than regular Python variables)
n, R, V, T = symbols('n R V T')

p = n*R*T / V
```

```python
diff(p, V)
```

```python
diff(p, T)
```

```python
dP = diff(p, V) + diff(p, T)
dP # print it
```

Okay, now we can test the total differential by taking the first part ($dV$) and taking the partial derivative with respect to T:

```python
diff(p, V, T)
```

Now we take the second term and take the partial derivative with respect to V:

```python
diff(p, T, V)
```

These are obviously the same:

```python
diff(p, V, T) == diff(p, T, V)
```

Essentially, Euler's test is equivalent to requiring:

$$
\frac{\partial^{2} f}{\partial x \partial y}=\frac{\partial^{2} f}{\partial y \partial x}
$$

This is true if the functions have continuous mixed (i.e., both $x$ and $y$) second derivatives.

-------
This notebook is from Prof. Geoffrey Hutchison, University of Pittsburgh
https://github.com/ghutchis/chem1000