---
## 01. Ecuaciones Diferenciales Ordinarias I

Eduard Larrañaga (ealarranaga@unal.edu.co)

---

### Resumen

En este cuaderno se presentarán algunos de los métodos para resolver un sistema de ecuaciones diferenciales Ordinarias.

`A. Garcia. Numerical Methods for Physics. (1999). Chapter 3 `

---

## Ecuaciones Diferenciales Ordinarias (ODE)

Un sistema de ecuaciones deiferenciales ordinarias (ODEs) de primer orden es una relación entre una función (vectorial) desconocida $y(x)$ y su derivada $y^\prime(x)$. Un sistema general de  ODEs de primer orden se puede escribir en la forma

\begin{equation}
y^\prime (x) =f(x,y(x))\,.
\end{equation}

Una solución de la ecuación diferencial es una función $y(x)$ que satisface la ODE.

En general, se tienen dos clases de problemas de ODE de primer orden:

1. Problemas de valor inicial : $y(x_i)$ es dado en un punto inicial $x_i$.

2. Problemas de valor de frontera (en dos puntos): $y$ es conicida en dos extremos (fronteras) del dominio y estas **condiciones de frontera** deben satisfacerse simultáneamente.

---
### Reducción a un problema de ODE de primer orden

Cualquier ODE puede reducirse a un problema de primer orden introduciendo variables adicionales.

#### Ejemplo

\begin{equation}
y''(x) + q(x) y'(x) = r(x)\,\,.
\end{equation}

Introduciendo una nueva función $z(x)$, este sistema se puede escribir como un sistema de ODE de primer orden,

\begin{align}
\text{(1)}\,\, y'(x) &= z(x)\\
\text{(2)}\,\, z'(x) &= r(x) - q(x) z(x).
\end{align}

---
## Métodos Explícitos e Implícitos para resolver ODEs

En terminos generales, los métodos para integrar un sistema de ODEs se clasifican en *explícitos* e *implícitos*.

Los métodos explícitos utilizan diferencias para construir y actualizar la solución utilizando los valores actuales y previos de las variables. Algunos ejemplos de métodos explícitos son el método de Euler y los métodos Runge-Kutta.

Los métodos implícitos evaluan el lado derecho de las ecuaciones diferenciales utilizando el nuevo valor de la función que se está buscando.

---
## Errores y ODEs

Todos los procedimientos para resolver numéricamente un sistema de ODEs de valor inicial implícan la transformación delas ecuaciioines diferenciales continuas en un proceso iterativo discreto que comienza a partir de una condición incial y retorna los valores de la función $y(x)$ en puntos $x_m = x_0 + m * h$, donde $h$ es el paso de discretización (el cual se asumirá como constante en esta descripción).

Dos clases de error pueden surgir en estos procedimientos:

1. **Error de redondeo.** Debido a la precicisión limitada de los números de punto flotante. El error de redondeo global es la sumade los errores de punto flotante locales (en cada paso de la iteración).

2. **Error de truncamiento.**

* Local: El error asociado con cada uno de los pasos cuando se reemplaza la descripción continua (e.g. una derivada) con una descripción discreta (e.g., diferencias finitas).

* Global: Si el error de truncamiento local es $\mathcal{O}(h^{n+1})$, entonces el error de truncamiento global será $\mathcal{O}(h^n)$ porque el número de pasos utilizados para evaluar las derivadas hasta alcanzar un punto arbitrario $x_f$, comenzando desde $x_0$, es $\frac{x_f - x_0}{h}$.

---
---

## Método de Euler

Considere el siguiente sistema de ODEs,

\begin{equation}
y' = f(x,y)
\end{equation}

con la condición inicial $y(x_0) = y_0$. Introduciremos un tamaño de paso fijo $\Delta x$ y se obtendrá un estimado para $y(x)$ en el punto $x_1 = x_0 + \Delta x$ utilizando el teorema de Taylor,

\begin{equation}
\begin{aligned}
y(x_1) &= y(x_0+\Delta x) = y(x_0) + y'(x_0) \Delta x + \mathcal{O}(\Delta x^2)\,,\\
       &= y(x_0) + \Delta x f(x_0,y(x_0)) +  \mathcal{O}(\Delta x^2)\,.
\end{aligned}
\end{equation}

De la misma forma, se obtiene el valor $y_{n+1}$ de la función en el punto $x_{n+1} = x_0 + (n+1) \Delta x$ mediante

\begin{equation}
y_{n+1} = y(x_{n+1}) = y_n + \Delta x f(x_n,y(x_n)) + \mathcal{O}(\Delta x^2)\,.
\end{equation}

Este se conoce como el **método de forward Euler**.

---
El método de Euler es extremadamente simple, pero puede ser impreciso y potencialmente inestable.

El error de este método escala como $\propto \Delta x^2$ localmente. Sin embargo, si $L$ es la longitud del dominio, entonces $\Delta x = L / N$, donde $N$ es el número de sub-intervalos definidos en el dominio. Ya que se están tomando $N$ pasos de integración, el error global será $\propto N \Delta x^2 = N L^2 / N^2 = L L/N \propto \Delta x$.

Por lo tanto, el método forward Euler es preciso a primer-orden globalmente.

### Estabilidad

Forward Euler es un método **explícito**. Esto significa que el valor $y_{n+1}$ es dado explícitamente en términos de cantidades conocidas como $y_n$ y $f(x_n,y_n)$.

Los métodos explícitos son simples y eficientes pero debe utilizarse un tamaño de paso adecuado (usualmente pequeño) para asegurar estabilidad.

### Ejemplo. Ecuación de Transferencia Radiativa

Consideraremos radiación electromagnética atravesando materia. En general, la intensidad de la radiación no permanecerá constante porque a lo largo de su camino, este puede perder o ganar energía por procesos de absorción o emisión.

En el caso de un rayo de luz con intensidad $I$ que atraviesa un material con *coeficiente de absorción* $\alpha$, se puede establecer un problema diferencial que representa la perdida de intensidad al viajar una distancia $ds$ así:

\begin{equation}
\frac{dI}{ds} = -\alpha I\,\,, \hspace{1cm}\text{with} \hspace{1cm}
I(0) = 1\,,\,\, \alpha > 0\,\,.
\end{equation}

Por convención, un valor de $\alpha$ positivo representa que el rayo pierde energía y se considerará que la intensidad original del haz estará normalizada a $1$.

Es claro que la solución exacta de este problema es $I(s) = e^{-\alpha s}$, la cual es estable y suave con $I(0) = 1$ y $I(\infty) = 0$.

Al aplicar el método de forward Euler se tiene

\begin{equation}
I_{n+1} = I_n - \alpha \, \Delta s \, I_n = (1-\alpha \Delta s) I_{n}
\end{equation}

\begin{equation}
I_{n+1}  = (1 - \alpha \Delta s)^2 I_{n-1} = \cdots = (1 - \alpha \Delta s)^{n+1} I_0\,\,.
\end{equation}

Esto implica que, para prevenir cualquier posible amplificación de los errores, se debe asegurar que $|1 - \alpha \Delta s|<1$.

En efecto, se pueden identificar tres posibles casos

| | | |
|:-: | :-: | :--|
| $0 < 1- \alpha \Delta s < 1$  | : | $(1- \alpha \Delta s)^{n+1}$ decae (muy bueno!) |
|$-1 < 1- \alpha \Delta s < 0$ | : | $(1- \alpha \Delta s)^{n+1}$ oscila (no tan bueno!)|
|$1- \alpha \Delta s < -1$     | : | $(1- \alpha \Delta s)^{n+1}$ oscila y diverge (muy malo!)|

Este análisis da un criterio de estabilidad para el método cuando $0< \Delta s < \frac{2}{\alpha}$.

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

def ODE(s,I):
    alpha = 2.  # Absorption coefficient
    rhs = -alpha*I
    return rhs

def FEuler(ODE, t0, q0, tf, n):
    '''
    ------------------------------------------
    FEuler(ODE, t0, q0, tf, n)
    Forward Euler's method for solving a ODEs
    system.
    ------------------------------------------
    Arguments:
    ODE:function with the ODE system
    t0: independent parameter initial value
    q0: numpy array with the initial values of
        the functions in the ODEs
    t0: independent parameter final value
    n : Number of steps to perform
    ------------------------------------------
    Dependences: NumPy
    ------------------------------------------
    '''
    dq = (tf - t0)/n
    q = np.zeros([n,len(q0)+1])
    q[0,0] = t0
    q[0,1:] = q0
    for i in range(1,n):
        q[i,0] = q[i-1,0] + dq
        q[i,1] = q[i-1,1] + dq*ODE(q[i-1,0], q[i-1,1])
    return q

# Creation of the Grid
s_0 = 0.
s_f = 10.
n = 1000

ds = (s_f - s_0)/n

# Variables setup

# Initial value
I_0 = np.array([1.])

# Main Loop
I = FEuler(ODE, s_0, I_0, s_f, n)

# Plot
fig, ax = plt.subplots(figsize=(10,7))
ax.plot(I[:,0], I[:,1], label=f'$\Delta s =$ {ds:.2f}')
ax.set_title('Método Forward Euler')
ax.set_xlabel(r'$s$')
ax.set_ylabel(r'$I(s)$')
plt.legend()
plt.show()
```

Ahora comprobaremos la estabilidad del método.

```python

# Initial Condition
I_0 = np.array([1.])

# Creation of the grid
s_0 = 0.
s_f = 10.

# Three stepsizes to compare
n1 = 100
ds1 = (s_f - s_0)/n1

n2 = 15
ds2 = (s_f - s_0)/n2

n3 = 10
ds3 = (s_f - s_0)/n3

I1 = FEuler(ODE, s_0, I_0, s_f, n1)
I2 = FEuler(ODE, s_0, I_0, s_f, n2)
I3 = FEuler(ODE, s_0, I_0, s_f, n3)

fig, ax = plt.subplots(figsize=(10,7))
ax.plot(I3[:,0], I3[:,1], color='crimson', label=f'$\Delta s_3=$ {ds3:.1f}')
ax.plot(I2[:,0], I2[:,1], color='purple', label=f'$\Delta s_2=$ {ds2:.1f}')
ax.plot(I1[:,0], I1[:,1], color='cornflowerblue', label=f'$\Delta s_1=$ {ds1:.1f}')

ax.set_title('Método Forward Euler')
ax.set_xlabel(r'$s$')
ax.set_ylabel(r'$I(s)$')
plt.legend()
plt.show()
```

---
## Método Predictor-Corrector

Considere la siguiente modificación del método de Euler,

\begin{equation}
y_{n+1} = y_{n} + h\, \frac{f(x_n, y_n) + f(x_{n+1},y_{n+1})}{2}\,\,.
\end{equation}

Esta puede dar un mejor estimado debido a que utiliza la *pendiente promedio* de $y$.

Sin embargo, cabe notar que en este paso aún no se conoce $y_{n+1}$. Para evitar este inconveniente, se puede utilizar el método usual de forward Euler para estimar incialmente el valor de $y_{n+1}$ y luego, utilizar este valor en la modificación presentada arriba. De esta forma, el método completo tendría la estructura

\begin{equation}
\begin{aligned}
y_{n+1}^{(\mathrm{P})} &= y_n + h f(x_n,y_n)\,\,, & \text{(predictor)}\\
y_{n+1} &= y_n + \frac{h}{2} \left[f(x_n,y_n) + f(x_{n+1}, y^{(\mathrm{P})}_{n+1}) \right]\,\,. & \text{(corrector)}
\end{aligned}
\end{equation}

Es posible mostrar que el error del **método predictor-corrector**  es localmente de $\Delta x^3$ y globalmente de $\Delta x^2$.  Esto quiere decir que este método es preciso a segundo orden, mientras que Euler es preciso a primer orden.

---
---

## Métodos  Runge-Kutta

La idea detrás de los métodos Runge-Kutta (RK) es ajustar la expansión en serie de Taylor de $y(x)$ en $x=x_n$ hasta el orden más alto posible.

---
### RK2. Método RK de segundo orden

Para el sistema

\begin{equation}
\frac{dy}{dx} = f(x,y)\,\,,
\end{equation}

se considerará la iteración

\begin{equation}
y_{n+1} = y_n + a k_1 + b k_2\,\,,
\end{equation}

donde

\begin{align}
k_1 &= \Delta x \,f(x_n,y_n)\,\,,\nonumber\\
k_2 &= \Delta x\,f(x_n+\alpha \Delta x,y_n + \beta k_1)\,\,.
\end{align}

Los cuatro parámetros desconocidos $a,b,\alpha,\beta$ se fijan de tal forma que la ecuación para $y_{n+1}$ concuerde con la expansión en serie de Taylor de $y' = f(x,y)$:

\begin{align}
y_{n+1} &= y_n + \Delta x y_n' + \frac{\Delta x^2}{2} y_n'' + \mathcal{O}(\Delta x^3)\,\,,\nonumber\\
&= y_n + \Delta x f(x_n,y_n) + \frac{\Delta x^2}{2} \frac{d}{dx} f(x_n,y_n) + \mathcal{O}(h^3)\,\,,\nonumber\\
&= y_n + \Delta x f_n + \Delta x^2 \frac{1}{2}\left( \frac{\partial f_n}{\partial x} + \frac{\partial f_n}{\partial y} f_n \right) + \mathcal{O}(\Delta x^3)\,\,,
\end{align}

donde $f_n = f(x_n,y_n)$. Al considerar la suposición hecha arriba,

\begin{equation}
y_{n+1} = y_n + a \Delta x f_n + b \Delta x f(x_n + \alpha \Delta x, y_n + \beta \Delta x f_n)\,\,.
\label{eq:rk2c}
\end{equation}

Al expandir el último término en serie de Taylor a primer orden $(x_n,y_n)$, se tiene

\begin{equation}
y_{n+1} = y_n + a \Delta x f_n + bh\left[ f_n + \frac{\partial f}{\partial x}(x_n,y_n) \alpha \Delta x +
\frac{\partial f}{\partial y}(x_n,y_n) \beta \Delta x f_n \right]\,\,.
\end{equation}

Una comparaión directa de los resultados permite identificar las constantes

\begin{equation}
a+b = 1\,\,,\hspace{2em} \alpha b = \frac{1}{2}\,\,\hspace{2em} \beta b = \frac{1}{2}\,\,.
\end{equation}

Sin embargo, se nota que solo se tienen 3 ecuaciones para determianr 4 parámetros. Esto quiere decir que tomando un valor arbitrario para uno de ellos, se fijan los restantes. Una elección típica es

\begin{equation}
\alpha = \beta = \frac{1}{2}\,\,,\hspace{2em} a=0\,\,, \hspace{2em} b = 1\,\,.
\end{equation}

Con esto se obtienen las ecuaciones del método RK2:

\begin{align}
k_1 & = \Delta x f(x_n,y_n)\,\,,\\
k_2 & = \Delta x f(x_n + \frac{1}{2}\Delta x, y_n + \frac{1}{2}k_1)\,\,,\\
y_{n+1} & = y_n + k_2 + \mathcal{O}(\Delta x^3)\,\,.
\end{align}

Este método es localmente  $\mathcal{O}(\Delta x^3)$ y globalmente $\mathcal{O}(\Delta x^2)$.

**Nota:** Utilizando $a=b=1/2$ y $\alpha=\beta=1$, se recupera el método predictor-corrector!

---

### RK3

\begin{align}
k_1 &= \Delta x f(x_n,y_n)\,\,\nonumber\\
k_2 &= \Delta x f(x_n + \frac{\Delta x}{2},y_n + \frac{1}{2} k_1)\,\,,\nonumber\\
k_3 &= \Delta x f(x_n + \Delta x, y_n - k_1 + 2 k_2)\,\,,\nonumber\\
y_{n+1} &= y_n + \frac{1}{6}(k_1 + 4 k_2 + k_3) + \mathcal{O}(\Delta x^4)\,\,.
\end{align}

---

### RK4

\begin{align}
k_1 &=\Delta x f(x_n,y_n)\,\,,\\
k_2 &=\Delta x f(x_n + \frac{\Delta x}{2},y_n + \frac{1}{2}k_1)\,\,,\nonumber\\
k_3 &=\Delta x f(x_n + \frac{\Delta x}{2},y_n + \frac{1}{2}k_2)\,\,,\nonumber\\
k_4 &=\Delta x f(x_n+\Delta x,y_n + k_3)\,\,,\nonumber\\
y_{n+1} &= y_n + \frac{1}{6}(k_1 + 2 k_2 + 2 k_3 + k_4) + \mathcal{O}(\Delta x^5)\,\,.
\end{align}

---

### Ejemplo. Ecuación de Transferencia Radiativa

Considere de nuevo ODE de transferencia radiativa con un coeficiente de absorción.

\begin{equation}
\frac{dI}{ds} = -\alpha I\,\,, \hspace{1cm}\text{with} \hspace{1cm}
I(0) = 1\,,\,\, \alpha > 0.
\end{equation}

```python
import numpy as np
import matplotlib.pyplot as plt

def ODE(s, I, alpha=1.):
    rhs = -alpha*I
    return rhs

def RK4(ODE, t0, q0, tf, n):
    '''
    ------------------------------------------
    RK4(ODE, t0, q0, tf, n)
    4th Order Runge-Kutta method for solving
    a system of ODEs.
    ------------------------------------------
    Arguments:
    ODE: function defining the system of ODEs
    t0: independent parameter initial value
    q0: numpy array with the initial values of
        the functions in the ODEs system
    t0: independent parameter final value
    n: Number of steps for the integration
    ------------------------------------------
    Dependences: NumPy
    ------------------------------------------
    '''
    dt = (tf - t0)/n
    q = np.zeros([n,len(q0)+1])
    q[0,0] = t0
    q[0,1:] = q0

    for i in range(1,n):
        q[i,0] = q[i-1,0] + dt
        k1 = dt*ODE(q[i-1,0], q[i-1,1:])
        k2 = dt*ODE(q[i-1,0] + dt/2, q[i-1,1:] + k1/2)
        k3 = dt*ODE(q[i-1,0] + dt/2, q[i-1,1:] + k2/2)
        k4 = dt*ODE(q[i-1,0] + dt, q[i-1,1:] + k3)
        q[i,1:] = q[i-1,1:] + (k1 + 2*k2 + 2*k3 + k4)/6

    return q

# Initial Condition
I_0 = np.array([1.])

# Creation of the grid
s_0 = 0.
s_f = 20.

# Three stepsizes to compare
n1 = 100
ds1 = (s_f - s_0)/n1

n2 = 15
ds2 = (s_f - s_0)/n2

n3 = 10
ds3 = (s_f - s_0)/n3

I1 = RK4(ODE, s_0, I_0, s_f, n1)
I2 = RK4(ODE, s_0, I_0, s_f, n2)
I3 = RK4(ODE, s_0, I_0, s_f, n3)

fig, ax = plt.subplots(figsize=(10,7))
ax.plot(I1[:,0], I1[:,1], color='cornflowerblue', label=f'$n_1=$ {n1:.0f}  $\Delta s_1=$ {ds1:.1f}')
ax.plot(I2[:,0], I2[:,1], color='orange', label=f'$n_2=$ {n2:.0f}  $\Delta s_2=$ {ds2:.1f}')
ax.plot(I3[:,0], I3[:,1], color='crimson', label=f'$n_3=$ {n3:.0f}  $\Delta s_3=$ {ds3:.1f}')

ax.set_title('Método RK4')
ax.set_xlabel(r'$s$')
ax.set_ylabel(r'$I(s)$')
plt.legend()
plt.grid()
plt.show()

```

## Métodos Runge-Kutta con paso adaptativo

Los métodos RK descritos arriba requieren la elección de un paso fijo $\Delta x$ pero, como se debe escoger su valor?

Adicionalmente, en algunas situaciones físicas puede ocurrir que, dentro de la dinámica del sistema, se puedan identificar regiones en las cuales la evolución se puede describir apropiadamente con pasos grandes y otras regiones en donde se necesite un paso mas pequeño para descibir adecuadamente el comportamiento. De esta forma, es posible introducir la idea de un **paso adpatativo** para integrar el sistema.

Una opción interesante es escoger un valor de **tolerancia al error** y dejar que el valor de $\Delta x$ sea elegido automáticamente para que el error del método este siempre dentro de esta tolerancia.

Por ejemplo, si se elije una tolerancia al error de $\epsilon = 0.0001$ y al realizar un paso de la integración con un tamaño $\Delta x = 10$ se obtiene un error de $0.01$, es necesario disminuir el tamaño del paso para rehacer el calculo con un error por debajo de la tolerancia. Luego, al realizar un paso de integración en el cual el error se encuentra por debajo de $\epsilon$, se puede volver a aumentar $\Delta x$ para evitar que aumente el tiempo de computo.

Así, para implementar la idea del paso adaptativo es necesario definir:

1. Un método para estimar el error en cada paso.
2. Una forma de ajustar el tamaño del paso $\Delta x$ si el error es muy grande (o muy pequeño).

---

### Paso Adaptativo en RK4

Como primer ejemplo de un algoritmo con paso adaptativo, considere el método RK4 descrito arriba.

Para iniciar, se toma un valor inicial $\Delta x$ y se realizan dos pasos RK4 con este valor (uno después del otro) para obtener el valor de la función $y^{(1)} = y(x+2\Delta x)$.

Ahora, volvemos al valor inicial nuevamente y realizamos un solo avance con el método RK4 utilizando un paso de $\Delta ^*_x = 2\Delta x$ para obtener el valor de la función $y^{(2)}(x + \Delta^* x) = y^{(2)}(x+2\Delta x)$ el cual, en general debe ser diferente del valor obtenido antes, $y^{(1)}(x+2\Delta x)$. Comparando estos dos valores obtendremos un estimado del error asociado con los pasos dados.

Como ya sabemos, el método RK4 tiene una precisión de cuarto orden, es decir que el error que se tiene será de quinto orden. De esta forma, en un paso con tamaño $\Delta x$ se tiene un error $\sim c \Delta x^5$ donde $c$ es una constante.

Al realizar dos pasos consecutivos de tamaño $\Delta x$, el valor real de la función, $y(t+2\Delta x)$ se relaciona con el obtenido por RK4 mediante la ecuación

\begin{equation}
y(t+2\Delta x) = y^{(1)} + 2 c \Delta x^5 .
\end{equation}

Por otra parte, al realizar solamente un paso de tamaño $2 \Delta x$ se tiene la relación

\begin{equation}
y(t+2\Delta x) = y^{(2)} +  c (2\Delta x)^5 = y^{(2)} + 32 c \Delta x^5.
\end{equation}

Ya que el valor real de la función es uno solo, igualamos estas ecuaciones para obtener

\begin{equation}
y^{(1)} +  2c \Delta x^5 = y^{(2)} + 32 c \Delta x^5
\end{equation}

\begin{equation}
y^{(1)} = y^{(2)} + 30 c \Delta x^5,
\end{equation}

lo cual implica que  el error por paso en pasos de tamaño $\Delta x$ será de

\begin{equation}
\delta = c \Delta x^5 =\frac{\left|y^{(1)}- y^{(2)}\right|}{30}.
\end{equation}

Cuando este error por paso es menor que la tolerancia definida, significa que el tamaño del paso utilizado es mas pequeño de lo deberia ser y por ello se esta perdidendo tiempo de computo.

Por otra parte, si el error por paso es mayor que la tolerancia, debe disminuirse el tamaño del paso. Para ello consideramos un nuevo paso, denotado por $\Delta x_{new}$, para el cual se tendrá un error de

\begin{equation}
\delta_{new} = c \Delta x_{new}^5 = c \Delta x^5 \left( \frac{\Delta x_{new}}{\Delta x}\right)^5 =\frac{\left|y^{(1)}- y^{(2)}\right|}{30} \left( \frac{\Delta x_{new}}{\Delta x}\right)^5.
\end{equation}

Introducimos ahora el valor de tolerancia para un paso con el nuevo tamaño como  $\Delta x_{new} \epsilon$, de tal manera que exigimos que

\begin{equation}
\frac{\left|y^{(1)}- y^{(2)}\right|}{30} \left( \frac{\Delta x_{new}}{\Delta x}\right)^5 \simeq \Delta x_{new} \epsilon.
\end{equation}

De esta ecuación obtenemos

\begin{equation}
\Delta x_{new} \simeq \left( \frac{30\epsilon \Delta x}{\left|y^{(1)}- y^{(2)}\right|} \right)^{1/4} \Delta x = \frac{1}{\Theta ^{1/4}} \Delta x,
\end{equation}

donde hemos definido

\begin{equation}
\Theta = \frac{\left|y^{(1)}- y^{(2)}\right|}{30\epsilon \Delta x} .
\end{equation}

La cantidad $\Theta$ corresponde a la razón entre la precisión actual, $\frac{\left|y^{(1)}- y^{(2)}\right|}{30}$, y la precisión deseada para un paso, $\epsilon \Delta x$.

---
Habiendo definido todas las cantidades importantes, podemos resumir el método de pasos adaptativos así:

1. Se realizan dos pasos de tamaño $\Delta x$ y luego, desde el mismo punto de partida, un paso de tamaño $2\Delta x$. con ello se obtienen los dos estimados $y^{(1)}$ y $y^{(2)}$.

2. Establecida una tolerancia $\epsilon$, se calcula la razón $\Theta$.

- Si $\Theta < 1$, la precisión actual del paso RK es mejor que la tolerancia, por lo que el calculo es adecuado (porque cumple la condición de tolerancia) pero los pasos son más pequeños de lo que se necesita. El resultado se acepta y se mueve al paso $x + 2\Delta x$ para continuar el proceso pero se define un tamaño de paso más grande mediante la ecuación $\Delta x_{new}  = \frac{1}{\Theta ^{1/4}} \Delta x$.

- Si $\Theta >1$, la precisión actual del paso RK NO es aceptable de acuerdo con la tolerancia. De esta forma, el paso actual no es correcto y debe repetirse utilizando con un paso más pequeño dado por la condición $\Delta x_{new}  = \frac{1}{\Theta ^{1/4}} \Delta x$.

Es importante notar que la solución de la ecuación diferencial se obtiene a partir de los estimados $y^{(1)}$, construidos con los pasos de tamaño pequeño y por lo tanto tendrá una mejor precisión. El valor $y^{(2)}$ solo es utilizado para el calculo del error y NUNCA para construir la solución final.

Eventualmente puede ocurrir que los valores de $y^{(1)}$ y $y^{(2)}$ sean exactamente (o casi exactamente) iguales, con lo que el valor de $\Theta$ daría lugar a un nuevo paso $\Delta x_{new} = 0$ y el proceso de integración se interrumpiría. Para evitar que esto ocurra, es usual imponer un tamaño mínimo para el tamaño del paso o un valor máximo para el decremento (o incremento) que puede tener $\Delta x$ en cada uno de os pasos de integración.

Finalmente, es importante notar que el método de paso adaptativo requiere un mayor número de calculos (se requieren al menos tres pasos RK por cada dos que realmente se utilizan en la construcción de la solución). Sin embargo, el esfuerzo extra permite obtener una solución con la precisión que se busca.

---
### Formula RK 3/4 con estimación de errores

Ahora presentaremos un esquema para el RK de orden 3/4 (Bogaki and Shampine), cuyas ecuaciones fundamentales serán

\begin{equation}
\begin{aligned}
k_1 &= \Delta x f(x_n, y_n)\,\,,\\
k_2 &= \Delta x f(x_n + \frac{1}{2} \Delta x, y_n + \frac{1}{2} k_1)\,\,,\\
k_3 &= \Delta x f(x_n + \frac{3}{4} \Delta x, y_n + \frac{3}{4} k_2)\,\,,\\
y_{n+1} &= y_n + \frac{2}{9} k_1 + \frac{1}{3} k_2 + \frac{4}{9} k_3 + \mathcal{O}(\Delta x^4)\,\,\\
k_4 &= \Delta x f(x_n + \Delta x, y_{n+1})\,\,\\
y^*_{n+1} &= y_n + \frac{7}{24} k_1 + \frac{1}{4}k_2 + \frac{1}{3}k_3 + \frac{1}{8} k_4 + \mathcal{O}(\Delta x^3)\,\,.
\end{aligned}
\end{equation}

Nótese que en este esquema, la función $k_4$ del paso $n$ es igual a la función $k_1$ del paso $n+1$. Por lo tanto, no es necesario evaluar de nuevo $k_1$ en el paso $n+1$; simplemente se reserva el valor calculado de $k_4$ y se re-utiliza en el siguiente paso. Este truco se denomina **FSAL (First Same As Last)**.

El error en cada uno de los pasos de integración está medido al comparar el resultado obtenido por el método RK3 con el el resultado obtenido por el método RK4,

\begin{equation}
\delta y_{n+1} = y_{n+1} - y^*_{n+1}\,\,.
\end{equation}

Ahora se exige que este error estimado  permanezca más pequeño que el valor de tolerancia, $|\delta y_{n+1}| \le \epsilon$ y ara ello se ajustará el valor de $\Delta x$.

Usualmente se utiliza una tolerancia determinada en la forma

\begin{equation}
\epsilon = \epsilon_a + |y_{n+1}|\epsilon_r
\end{equation}

donde

$\epsilon_a$ : tolerancia del error absoluto

$\epsilon_r$ : tolerancia del error relativo

### Ajuste del paso $\Delta x$

Para ajustar el tamaño del paso se define la cantidad

\begin{equation}
\Theta = \frac{|\delta y_{n+1}|}{\epsilon}\,\,,
\end{equation}

y se exige que  $\Theta_\text{desired} \approx 1$.

Si se tiene un algoritmo con $\Theta \sim \mathcal{O}(\Delta x^p)$, es posible ajustar el valor de $\Theta$ modificando  el paso $\Delta x$ en la forma

\begin{equation}
\Delta x_\text{desired} = \Delta x \left|\frac{\Theta_\text{desired}}{\Theta} \right| ^\frac{1}{p}\,\,,
\end{equation}

se obtendrá un valor de $\Theta_\text{desired} = 1$.

---

De esta forma, una forma general del algoritmo para ajustar $\Delta x$ es el siguiente:

1. Considere un paso $\Delta x$ y calcule el valor de $\Theta$.
2. Si $\Theta > 1$ (error muy grande), entonces

  - defina $\Delta x_\text{new} = \Delta x \left| \frac{1}{\Theta} \right|^{\frac{1}{p}} S$, donde $S$ es un factor de suavizado ($\sim$ $0.9$ ).

  - **Descarte** la iteración anterior y repitala con $\Delta x_\mathrm{new}$.

3. Si $\Theta < 1$ (error muy pequeño), entonces

  - defina $\Delta x_\text{new} = \Delta x \left| \frac{1}{\Theta} \right|^{\frac{1}{p}} S$.

  - **Acepte** la iteración anterior y realice la siguiente iteración con $\Delta x_\text{new}$.


# Physics 420/580 Final Exam
## December 19, 2018 9am-12am

Do three of the four following problems.  If you attempt all four, your best three will be used for your mark.  Use the Jupyter notebook, inserting your code and any textual answers/explanations in cells between the questions. (Feel free to add additional cells!)  Marks will be given based on how clearly you demonstrate your understanding.

There are no restrictions on downloading from the internet, eclass,  or the use of books, notes, or any other widely available computing resources.  However, **you are not allowed** to communicate with each other or collaborate in any way and uploading to the internet or sending or receiving direct communications is not appropriate.

When you are finished, upload the jupyter notebook to eclass.

Also be careful to save the notebook periodically and double check **that you upload the file that has your answers in it!**

```python

```

# Monte Carlo

Write a Monte Carlo that calculates that calculates the area of a circle of radius 1 cm by randomly picking points from a square 2cm x 2 cm and checking to see if the points are within 1 cm of the center.

```python
import numpy as np
xy=2*np.random.rand(100000,2)-1
n=0
for aa in xy:
    if np.linalg.norm(aa)<=1:
        n=n+1
area= n/len(xy)*4
print(area)
import matplotlib.pyplot as plt
plt.figure()
plt.hist(xy[:,1])
plt.show()
```

    3.1486

    <Figure size 640x480 with 1 Axes>

Write a Monte Carlo that calculates the efficiency, $\epsilon$, for detecting cosmic ray muons with a set of three detectors as shown in the figure.  Here \begin{equation} \epsilon=\frac{N_3}{N_c},\end{equation} with $N_3$=number of muons that pass through all three detectors and $N_c$ is the number that pass through the central detector.  Assume the cosmic ray flux $I$ in units of $\frac{\rm{muons}}{\rm{m}^2\rm{s}}$ is uniform in position, and that the angular distribution is \begin{equation}I(\theta)=I_0\cos^2\theta \end{equation} where $\theta$ is the angle to the vertical ($\theta=0$)points straight down.  The actual value of $I_0$ doesn't matter here since we take a ratio- so you just need to throw a large number of muons with this angular distribution.  All three detectors are 0.25 m x 1 m.

Generate and plot 100000 muon positions uniformly through the central counter.

```python

```

Generate and plot thetas according to the distribution for the muons.

```python

```

Calculate the efficiency.

```python
#generate Nc muons passing through the central detector
Nc=100000
xyPhi=np.random.rand(Nc,4)*np.array([1,.25,0,2*np.pi]) #x,y,z Phi

#Now we want to generate the cos**2 (theta distribution)  Easiest just to throw and pick- so we will throw a theta,
thetaTest=np.random.rand(4*Nc,2)*np.array([np.pi/2,1]) #Generate 2 numbers- theta, random number for picking theta,
ct2=np.cos(thetaTest[:,0])**2

thetaArray=thetaTest[(np.where(ct2>thetaTest[:,1])),0][0]
print (thetaArray.shape, thetaArray)

x=np.linspace(0,np.pi/2,25)
plt.figure()
plt.hist(thetaArray,25)
plt.plot(x,np.cos(x)**2*1500)
plt.show()

xypt=zip(xyPhi,thetaArray[:Nc])
N3=0
for a in xypt:
    x0=np.array(a[0][0:3])
    theta=np.array(a[1])
    phi=a[0][3]
    n=np.array([np.sin(theta)*np.cos(phi),np.sin(theta)*np.sin(phi),np.cos(theta)])
    #find intersection with upper plane
    d=0.125/n[2]
    xt=x0+d*n
    if 0<xt[0]<1 and 0<xt[1]<0.125:  #passed through the upper plane
        x2=x0-d*n
        if 0<xt[0]<1 and 0<xt[1]<0.125:  #passed through the upper plane
            N3=N3+1

print('The efficiency is',N3/Nc)

```

# Fitting

A pulse of sound is emitted from a position (0,0), and travels with velocity $v=2000 \frac{\rm{m}}{\rm{s}}$.  The sound is reflected from a layer, which described by \begin{equation}(x,z)=(x,f(x))=(x,ax^3+bx^2+cx+d).\end{equation}  Here x is the horizontal position and z is the depth into the earth.

The time it takes the sound to move from (0,0) on the surface to a point on the layer $\vec{p}$ is:\begin{equation}
t_1=\frac{|\vec{p}|}{v}\end{equation}

To do the reflection, we set up coordinates with x along the boundary at point $\vec{p}=(x,f(x))$

\begin{align}
\hat{i}=\frac{(1,df/dx)}{|(1,df/dx)|}\\
\hat{k}=-\frac{\vec{p}-({\vec{p}\cdot\hat{i}})\hat{i}}{|\vec{p}-({\vec{p}\cdot\hat{i}})\hat{i}|}
\end{align}

In this coordinate system the reflected wave just continues in the $\hat{i}$ direction, but reflects in the $\hat{k}$ direction.   So the final direction  \begin{equation}\hat{n}=\frac{(\vec{p}\cdot\hat{i})\hat{i}-(\vec{p}\cdot\hat{k})\hat{k}}{|(\vec{p}\cdot\hat{i})\hat{i}-(\vec{p}\cdot\hat{k})\hat{k}|}.\end{equation}

To find the position $\vec{x_s}$ and time $t$ where the pulse returns to the surface we calculate
\begin{align} t_2=-\frac{f(x)}{v\cdot n_z}\\
\vec{x_s}=(x_s, z_s)=p+t_2v\hat{n}\\
t=t_1+t_2
\end{align}

Assume that the boundary is described by parameters $a-=0.0000002,b=0.00002, c=0.0001,d=-1000$.

1.  Calculate the time of arrival of the pulse as a function of position x (the position on the layer that is reflecting the wave).

2. Plot t as a function of x.

3. Plot the $x_s$ (the x component of the vector $\vec{x_s}$) as a function of x.

4.  Use the arrays you calculated in (2) and (3) to make a function that interpolates t as a function of $x_s$- and  then plot t as a function of $x_s$

```python

import numpy as np
import matplotlib.pyplot as plt
#d=np.poly1d([-0.0000002,.00002,0.0001,-1000])
d=np.poly1d([-0.00000010,-.00001,0.000,-1500])
deriv=np.polyder(d)
plt.figure()
x=np.linspace(-1000,1000,200)
plt.title('Position of boundary layer')
plt.plot(x,d(x))
v=2000 #m/s

def time(x):
#    import pdb
#    pdb.set_trace()
    p=np.array([x,d(x)])
    t1=np.linalg.norm(p)/v
    i=np.array([1,deriv(x)])

    i=i/np.linalg.norm(i)
    k=p-np.dot(p,i)*i
    knorm=np.linalg.norm(k)
    if knorm==0:
        k=np.array([0,1])
    else:
        k=k/knorm
    n=np.dot(p,i)*i-np.dot(p,k)*k
    n=n/np.linalg.norm(n)
    t2= -d(x)/(v*n[1])
    pf=p+v*t2*n
    return np.array((t1+t2,pf[0],pf[1]))

tvec=[]
xvec=[]
zvec=[]
for xx in x:
    t,xpos,zpos=time(xx)
    tvec.append(t)
    xvec.append(xpos)
    zvec.append(zpos)

from scipy import interpolate
ff=interpolate.interp1d(xvec,tvec)

plt.figure()
plt.title('Arrival time as a function of position on boundary layer')
plt.plot(x,tvec)
plt.show()

plt.figure()
plt.title('Time of arrival as a function of position on surface')
print(time(10))
plt.plot(xvec,tvec,'o')
plt.plot(x,ff(x))
plt.xlim(-1000,1000)
plt.ylim(1.4,1.8)

tsensor=[]
xsensor=[]
for a in range(-1000,1000,200):
    xsensor.append(a)
    tsensor.append(ff(a)+np.random.normal(0,0.0005))
    print(('|%f|%6.4f|'%(xsensor[-1],tsensor[-1])))

print(' '.join(['%6.0f,'%a for a in xsensor]))
print(' '.join(['%6.4f,'%a for a in tsensor]))
    #for aa in zip(xvec,tvec):
#    print(aa[0],aa[1])

```

Now we consider a layer with different, unknown polynomial coefficients.
Sensors are placed every 200 meters along the surface of the ground, which measure the time of arrival of the pulse with an accuracy of 0.0005 seconds.

The data returned are:

|sensor x<br>position[m]|Time[s]|
|----|----|
|-1000.000000|1.5646|
|-800.000000|1.5456|
|-600.000000|1.5274|
|-400.000000|1.5124|
|-200.000000|1.5026|
|0.000000|1.5004|
|200.000000|1.5027|
|400.000000|1.5146|
|600.000000|1.5320|
|800.000000|1.5587|

Fit the 3rd order polynomial describing the boundary layer to these data, by defining a chisquare function and using scipy.optimize.minimize.

I have made arrays xsensor, tsensor which you may use to save cutting and pasting.

```python
xsensor=np.array([ -1000,   -800,   -600,   -400,   -200,      0,    200,    400,    600,    800])
tsensor=np.array([1.5646, 1.5456, 1.5274, 1.5124, 1.5026, 1.5004, 1.5027, 1.5146, 1.5320, 1.5587])

```

```python
v=2000 #m/s

from scipy import interpolate

def time(x):
#    import pdb
#    pdb.set_trace()
    p=np.array([x,d(x)])
    t1=np.linalg.norm(p)/v
    i=np.array([1,deriv(x)])
    i=i/np.linalg.norm(i)
    k=p-np.dot(p,i)*i
    knorm=np.linalg.norm(k)
    if knorm==0:
        k=np.array([0,1])
    else:
        k=k/knorm
    n=np.dot(p,i)*i-np.dot(p,k)*k
    n=n/np.linalg.norm(n)
    t2= -d(x)/(v*n[1])
    pf=p+v*t2*n
    return np.array((t1+t2,pf[0],pf[1]))

def chi2(pars):
    global d
    global deriv
#    import pdb
#    pdb.set_trace()
    d=np.poly1d(pars)
    deriv=np.polyder(d)

    tvec=[]
    xvec=[]
    zvec=[]
    for xx in x:
        t,xpos,zpos=time(xx)
        tvec.append(t)
        xvec.append(xpos)
        zvec.append(zpos)
    ff=interpolate.interp1d(xvec,tvec,fill_value='extrapolate')
    resid=(tsensor-ff(xsensor))**2
    return np.sum(resid)

params=np.array([-0.0000002,.00002,0.0001,-1450])
import scipy.optimize
scipy.optimize.minimize(chi2,params,method='Powell').x
#[-0.00000010,-.00001,0.000,-1500]

```

    array([-2.53300215e-07, -1.96962300e-04, -2.67924833e-02, -1.49343298e+03])

```python
from scipy import poly1d

x = np.linspace(-1000, 1000)

plt.plot(x, poly1d(np.array([-1.01825371e-07, -1.39292796e-05, -9.25237786e-04, -1.49995821e+03]))(x))
```

## Baseball Trajectory

A baseball, with diameter 7.4 cm and mass 146 g, has been hit a distance of about 150 meters.  After the ball leaves the bat, the only forces are those due to gravity and air resistance (we ignore the possibility of lift forces due to the spin of the ball).  Assuming that there is no wind, and the density of the air is $\rho=1.225 \frac{\rm{kg}}/{\rm{m}^3}$, and the magnitude of the turbulent drag, $F_t$, is
\begin{equation}
F_t=\frac{1}{2}\rho v^2AC_D
\end{equation}
where $A=\pi r^2$ is the cross sectional area of the sphere, $C_D$ is the drag coefficient.

The drag force is in the opposite direction to the velocity.

Use the following table to get the drag force as a function of Reynolds Number and interpolate.

The viscosity of air, $\eta=1.827\times 10^{-5}\rm{Pa-s}$ and Reynolds Number, $R=\frac{2\rho v r}{\eta}.$

|Reynolds Number|Drag Coefficient|
|---------------|----------------|
|1|24|
|4|8|
|10|4|
|40|1.7|
|100|1.1|
|400|0.6|
|1000|0.45|
|2000|0.4|
|4000|0.35|
|10,000|0.4|
|40,000|0.45|
|100,000|0.42|
|280,000|0.39|
|400,000|0.08|
|500,000|0.08|

In a coordinate system where x is along the direction of the ball, and z is up, we can integrate Newton's second law:
\begin{equation}
\vec{F}=mg(0,0,-1)-F_t \frac{\vec{v}}{|\vec{v}|}
\end{equation}

using odeint to determine the trajectory of the ball.

Plot where the ball hits the ground (ie. has $z=0$) as a function of the initial speed $v$ and angle $\theta$,$\vec{v_0}=v(\cos\theta,0,\sin\theta)$; vary the range of these parameters to estimate the lowest necessary velocity for a range of 150 meters.

I have put the constants into the code block below to start you off.

```python
airDensity=1.225 # kg/m**3
airPressure=101.325 #kPa
radius=0.037
airViscosity=1.827e-5   #Pa-s

RN=np.array([1,4,10,40,100,400,1000,2000,4000,10000,40000,100000,280000,400000,500000])
DC=np.array([24,8,4,1.7,1.1,0.6,0.45,0.4,0.35,0.4,0.45,0.42,0.39,0.08,0.08])

```

```python
import scipy.interpolate
from scipy.integrate import odeint

mass=0.146 # kg

logRN=np.log(RN)  #interpolation in log ReynoldsNumber will be more sensible
plotX=np.logspace(np.log10(RN[0]),np.log10(RN[-1]),100)
linearInterpolation=scipy.interpolate.interp1d(logRN,DC) #out x-axis is log Reynold's Number
splineInterpolation=scipy.interpolate.interp1d(logRN,DC,kind='cubic')
def DragCoefficientLinear(reynoldsNumber):
    return linearInterpolation(np.log(reynoldsNumber))
def DragCoefficientSpline(reynoldsNumber):
    return splineInterpolation(np.log(reynoldsNumber))

def ReynoldsNumber(velocity):
    '''Calculates Reynolds number for a sphere.  Because it is used
    in an logarithmic interpolation, we impose 1 as a lower cutoff'''
    R=airDensity*velocity*radius*2/airViscosity
    return np.maximum(R,1)

plt.figure()
plt.title("Drag Coefficient versus Reynolds Number")
plt.ylabel(r'$C_D$, drag coefficient')
plt.xlabel("Reynolds Number")
plt.semilogx(RN,DC,'o',color='blue',label='Data Points')
plt.plot(plotX,DragCoefficientLinear(plotX),color='red',label="Linear Interpolation")
plt.plot(plotX,DragCoefficientSpline(plotX),color='violet',label="Cubic Spline Interpolation")
plt.legend();

area=np.pi*radius**2
velocityAtR1=airViscosity/(2*airDensity*radius)
ceta= 6*np.pi*airViscosity*radius
ct=.5*airDensity*area
def fdrag(velocity):
    v=abs(velocity)
    if v<velocityAtR1:
        force=ceta*v
    else:
        force=ct*v**2*DragCoefficientSpline(ReynoldsNumber(v))
    return force

drag=np.vectorize(fdrag)  #so that drag can accept either a scalar or an array
print(drag([0,90,100]))

def accel(y,t):
    x,z,vx,vz=y
    pos=np.array([x,0,z])
    v=np.array([vx,0,vz])
    vmag=np.sqrt(np.dot(v,v))
    F=np.array([0,0,-mass*9.8])-fdrag(vmag)*v/vmag
    dydt=np.array([vx,vz,F[0]/mass,F[2]/mass])
#    theta, omega,energy = y
#    dydt = np.array([omega, - m*g*p.lcm/p.I*np.sin(theta)-drag(p.l*omega)/(p.m*p.l)*np.sign(omega),drag(p.l*omega)*p.l*np.abs(omega)])
    return dydt

t=np.linspace(0,15,100)  #  long enough so that it always works

def range(vinit,theta0):
    global ii
    y0 = np.array([0, 0.0,vinit*np.cos(theta0),vinit*np.sin(theta0)])
    sol = odeint(accel, y0, t)
    ii=np.where(sol[:,1]<0)[0][0]
#  Now we interpolate from x1,y1 to x2, y2  y=y1+(y2-y1)/(x2-x1)*(x-x1)=0
# x=-y1*(x2-x1)/(y2-y1)+x1
    ground=-sol[ii-1,1]*(sol[ii,0]-sol[ii-1,0])/(sol[ii,1]-sol[ii-1,1])+sol[ii-1,0]
    return ground

vinit=100
theta0=1/np.sqrt(2)
gr=range(vinit,theta0)
ix=ii
print(ix,sol[ix-1],sol[ix],gr)

plt.figure()
plt.plot(t,sol[:,1])
plt.figure()
plt.plot(t,sol[:,0])

v0=np.linspace(10,100,10)
r=[]
for vinit in v0:
    r.append(range(vinit,theta0))
plt.figure()
plt.plot(v0,r)

t0=np.linspace(0.55,0.7,10)
r=[]
for tinit in t0:
    r.append(range(67.7,tinit))
plt.figure()
plt.plot(t0,r)

print(67.7*3.6,ReynoldsNumber(67.7))
```

## Statistics and Monte Carlo

A beam of particles is incident on a target and produces radioactive atoms.  In typical operation it produces R atoms/second.  The way in which the atoms are produced is *independent*- that is, one atom is produced at a time and there is no connection between the rate at which atoms are produced doesn't depend on how many atoms are there already.

This problem involves writing various Monte Carlo calculations that simulate the statistics of this situation.

A simple way to approximate this is to break the time interval into very small time steps, so that in each step only $\lambda<<1$ atoms are produced.   We throw a random number for each time step, and generate an atom if that number is less than $\lambda$.

Consider the case when R=10 atoms/second.  Pick $\lambda=0.05,$ so the time interval will be $\delta=\lambda/R=0.005 \text{s}.$  Calculate (by MC) the number of atoms created in a second.  Run the 10000 experiments, and plot the distribution of the number of atoms created in a second interval.

```python
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
import matplotlib as mpl
mpl.rc('figure',dpi=250)

R=10
lam=0.05
delta=lam/R
NperSec=int(1/delta)
Nexperiments=10000
nexp=np.random.rand(Nexperiments*NperSec) # We run the experiments together- one second is one experiment
tbirth=np.where(nexp<lam)[0]*delta #array containing times at which atoms are created
nexp=np.histogram(tbirth,Nexperiments,(0,Nexperiments))
plt.figure()
aa=plt.hist(nexp[0],25,(0,25))
plt.show()
print(aa)
```

Repeat with $\lambda=0.005$.

For this second distribution, make a log plot which shows the number of events in a 0.005 second interval.  (The first simulation, by definition, always has 0 or 1 event in such an interval)

```python
lam=0.005
delta=lam/R
NperSec=int(1/delta)
Nexperiments=10000
nexp=np.random.rand(Nexperiments*NperSec) # We run the experiments together- one second is one experiment
tbirth=np.where(nexp<lam)[0]*delta #array containing times at which atoms are created
nexp=np.histogram(tbirth,Nexperiments,(0,Nexperiments))
plt.figure()
aa=plt.hist(nexp[0],25,(0,25))
plt.show()
print(aa)
```

```python
# to plot the number of atoms in an 0.005 second interval we histogram tbirth in 0.005 second bins
nexp2=np.histogram(tbirth,Nexperiments*200,(0,Nexperiments))
plt.figure()
plt.yscale('log')
aa=plt.hist(nexp2[0],10,(0,10))
plt.show()
print(aa)
```

To do the problem exactly, we generate an expontial time separation between adjacent events.  That is\begin{equation}P(t+dt)=P(t)(1-dt*R)\rightarrow\frac{dP}{dt}=-RP\rightarrow P=e^{-Rt}\end{equation}

Thus, the probability distribution for time intervals between events is $P=e^{-Rt}.$  Generate 10000 1 second experiments with this distribution, and plot the number of events in a second.  Also plot the number of events in a 100,000 experiments with a 0.005 second interval.

```python
#We do the same as before, and will generate intervals, then sum them to get the time, #
# and then histogram them in one second bins.
scale=1.0/R
t0=np.random.exponential(scale, int(1.1*Nexperiments*NperSec)) # We run the experiments together- one second is one experiment
tbirth=np.cumsum(t0) #array containing times at which atoms are created
nexp=np.histogram(tbirth,Nexperiments,(0,Nexperiments))
plt.figure()
aa=plt.hist(nexp[0],25,(0,25))
plt.show()
print(aa)

```

```python
# to plot the number of atoms in an 0.005 second interval we histogram tbirth in 0.005 second bins
Nexperiments=100000
nexp2=np.histogram(tbirth,Nexperiments,(0,int(Nexperiments/200)))
plt.figure()
plt.yscale('log')
aa=plt.hist(nexp2[0],10,(0,10))
plt.show()
print(aa)
```

```python

```

```python

```

## Upload the exam

When you are finished, upload your completed exam onto eClass.  Double check that the uploaded exam contains your latest work!


# Transformada de Fourier de alguns sinais

Neste notebook avaliaremos a transformada de Fourier de alguns sinais. A TF é definida por

\begin{equation}
X(\mathrm{j} \omega)=\int^{\infty}_{-\infty}x(t)e^{-\mathrm{j}\omega t}\mathrm{d}t
\tag{1}
\end{equation}

E a TF inversa por:

\begin{equation}
x(t)=\frac{1}{2 \pi}\int^{\infty}_{-\infty}X(\mathrm{j} \omega)e^{\mathrm{j}\omega t}\mathrm{d}\omega
\tag{2}
\end{equation}

```python
# importar as bibliotecas necessárias
import numpy as np # arrays
import matplotlib.pyplot as plt # plots
plt.rcParams.update({'font.size': 14})
```

### Exemplo 1

$x(t) = \delta(t)$

neste caso

$X(\mathrm{j} \omega)=1$

```python
freq = np.linspace(-50, 50, 1000)
w = 2*np.pi*freq

Xjw = np.ones(len(w))

plt.figure(figsize=(15, 5))
plt.subplot(1,2,1)
plt.plot(freq, np.abs(Xjw), '-b', linewidth = 2)
plt.grid(linestyle = '--', which='both')
plt.xlabel('Frequência [Hz]')
plt.ylabel(r'$|X(\mathrm{j}\omega)|$ [-]')
plt.xticks(np.arange(freq[0], freq[-1]+10, 10))
plt.xlim((freq[0], freq[-1]))

plt.subplot(1,2,2)
plt.plot(freq, np.angle(Xjw), '-b', linewidth = 2)
plt.grid(linestyle = '--', which='both')
plt.xlabel('Frequência [Hz]')
plt.ylabel(r'$\angle X(\mathrm{j}\omega)$ [rad]')
plt.xticks(np.arange(freq[0], freq[-1]+10, 10))
plt.xlim((freq[0], freq[-1]))
plt.tight_layout()
plt.show()
```

### Exemplo 2

$x(t) = \mathrm{e}^{-at}u(t)$, $a>0$

neste caso

$X(\mathrm{j} \omega)=\frac{1}{a+\mathrm{j}\omega}$

```python
time = np.linspace(-1, 1, 1000)
xt = np.zeros(len(time))
a = 500
xt[time>=0] = np.exp(-a*time[time>=0])

# Time domain
plt.figure(figsize=(20, 2.5))
plt.plot(time, xt, '-b', linewidth = 2)
plt.grid(linestyle = '--', which='both')
plt.xlabel('Tempo [s]')
plt.ylabel(r'$x(t)$ [-]')
plt.xticks(np.arange(time[0], time[-1]+0.1, 0.1))
plt.xlim((time[0], time[-1]))

# Freq domain
freq = np.linspace(-5, 5, 10000)
w = 2*np.pi*freq

Xjw = 1/(a+1j*w)

plt.figure(figsize=(15, 5))
plt.subplot(1,2,1)
plt.plot(freq, np.abs(Xjw)/np.amax(np.abs(Xjw)), '-b', linewidth = 2)
plt.grid(linestyle = '--', which='both')
plt.xlabel('Frequência [Hz]')
plt.ylabel(r'$|X(\mathrm{j}\omega)|$ [-]')
plt.xticks(np.arange(freq[0], freq[-1]+1, 1))
plt.xlim((freq[0], freq[-1]))
plt.ylim((0, 1.2))

plt.subplot(1,2,2)
plt.plot(freq, np.angle(Xjw), '-b', linewidth = 2)
plt.grid(linestyle = '--', which='both')
plt.xlabel('Frequência [Hz]')
plt.ylabel(r'$\angle X(\mathrm{j}\omega)$ [rad]')
plt.xticks(np.arange(freq[0], freq[-1]+1, 1))
plt.xlim((freq[0], freq[-1]))
plt.tight_layout()
plt.show()
```

# Exercício

```python
time = np.linspace(-5, 5, 1000)
xt = np.ones(len(time))
Tp = 5
xt[time<=-Tp/2] = 0
xt[time>=Tp/2] = 0

# Time domain
plt.figure(figsize=(20, 2.5))
plt.plot(time, xt, '-b', linewidth = 2)
plt.grid(linestyle = '--', which='both')
plt.xlabel('Tempo [s]')
plt.ylabel(r'$x(t)$ [-]')
plt.xticks(np.arange(time[0], time[-1]+1, 1))
plt.xlim((time[0], time[-1]))
```

# Dualidade

```python
time = np.linspace(-5, 5, 1000)
xt1 = np.ones(len(time))
Tp = 2
xt1[time<=-Tp/2] = 0
xt1[time>=Tp/2] = 0

freq = np.linspace(-5, 5, 1000)
w=2*np.pi*freq
Xw1 = 2*np.sin(w*Tp/2)/(w*Tp/2)

w0 = 5
xt2 = 2*np.sin((w0/2)*time)/((w0/2)*time) #np.sinc(time)

Xw2 = np.ones(len(freq))
Xw2[freq<=-w0/2] = 0
Xw2[freq>=w0/2] = 0

plt.figure(figsize=(15, 10))
plt.subplot(2,2,1)
plt.plot(time, xt1, '-b', linewidth = 2)
plt.grid(linestyle = '--', which='both')
plt.xlabel('Tempo [s]')
plt.ylabel(r'$x(t)$ [-]')
plt.xticks(np.arange(time[0], time[-1]+1, 1))
plt.xlim((time[0], time[-1]))

plt.subplot(2,2,2)
plt.plot(freq, np.abs(Xw1)/len(Xw1), '-b', linewidth = 2)
plt.grid(linestyle = '--', which='both')
plt.xlabel('Frequência [Hz]')
plt.ylabel(r'$|X(\mathrm{j}\omega)|$ [-]')
plt.xticks(np.arange(freq[0], freq[-1]+1, 1))
plt.xlim((freq[0], freq[-1]))

plt.subplot(2,2,3)
plt.plot(time, xt2, '-b', linewidth = 2)
plt.grid(linestyle = '--', which='both')
plt.xlabel('Tempo [s]')
plt.ylabel(r'$x(t)$ [-]')
plt.xticks(np.arange(time[0], time[-1]+1, 1))
plt.xlim((time[0], time[-1]))

plt.subplot(2,2,4)
plt.plot(freq, np.abs(Xw2), '-b', linewidth = 2)
plt.grid(linestyle = '--', which='both')
plt.xlabel('Frequência [Hz]')
plt.ylabel(r'$|X(\mathrm{j}\omega)|$ [-]')
plt.xticks(np.arange(freq[0], freq[-1]+1, 1))
plt.xlim((freq[0], freq[-1]))
plt.tight_layout()
```

```python
time = np.linspace(-5, 5, 1000)
xt1 = np.ones(len(time))
Tp = 2
xt1[time<=-Tp/2] = 0
xt1[time>=Tp/2] = 0

freq = np.linspace(-5000, 5000, 10000)
w=2*np.pi*freq
Xw1 = 2*np.sin(w*Tp/2)/(w*Tp/2)

w0 = 1000
xt2 = 2*np.sin((w0/2)*time)/((w0/2)*time) #np.sinc(time)

Xw2 = np.ones(len(freq))
Xw2[freq<=-w0/2] = 0
Xw2[freq>=w0/2] = 0

plt.figure(figsize=(15, 5))
plt.subplot(1,2,1)
plt.plot(time, xt2, '-b', linewidth = 2)
plt.grid(linestyle = '--', which='both')
plt.xlabel('Tempo [s]')
plt.ylabel(r'$x(t)$ [-]')
plt.xticks(np.arange(time[0], time[-1]+1, 1))
plt.xlim((time[0], time[-1]))

plt.subplot(1,2,2)
plt.plot(freq, np.abs(Xw2), '-b', linewidth = 2)
plt.grid(linestyle = '--', which='both')
plt.xlabel('Frequência [Hz]')
plt.ylabel(r'$|X(\mathrm{j}\omega)|$ [-]')
plt.xticks(np.arange(freq[0], freq[-1]+1, 1))
plt.xlim((freq[0], freq[-1]))
plt.tight_layout()
```

Aula 24 - TF de alguns sinais/TF alguns espectros.ipynb


# Mobility and collapse height input weights for importance sampling

This notebook runs through the process of generating the weight factors associated with each energy cone simulation ($W_k$). This notebook outputs a file ('input_weights.csv') into the 'weighting_data' folder, which is used for importance sampling in the accompanying 'Importance_sampling_notebook'.

This notebook requires the file:

- 'initial_input_weights.csv' <-- containing the $\phi$-$H_0$ parameter pairs used for energy cone simulations. Provided in the 'source_files' folder

As the energy cone model requires two main input parameters: PDC mobility $\phi$, and Collapse height above the vent $H_0$, the weight factor of each parameter is combined to create an overall weight factor. This notebook first runs through the process of calculating the weight associated with $\phi$, and then the weight associated with $H_0$. Finally, it combines these to generate the overall weight $W_k$, for each energy cone simulation.

### Notebook Author
Ben Clarke

https://github.com/GeoBen93

https://www.researchgate.net/profile/Ben_Clarke6

https://twitter.com/PyroclasticBen

### Visual overview of what this notebook does

\begin{equation}
    W_k = \frac{f(H_k) \times f(\phi_k)}{g(H_k) \times g(\phi_k)}
\end{equation}

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

```python
import matplotlib.pyplot as plt
import csv
import numpy as np
import matplotlib.mlab as mlab
import pandas as pd
from scipy import stats
from scipy.stats import norm, truncnorm
from numpy import random
```

## PDC mobility weight

The first functions here define the target and uniform PDFs for PDC mobility. The parameters of the target PDF and limits of the uniform PDF are defined here as:

- phimin <-- Minimum $\phi$ value
- phimax <-- Maximum $\phi$ value
- phimean <-- Mean (or $\mu$) of $\phi$
- phistd <-- Standard deviation (or $\Sigma$) of $\phi$

In this case, these values are based on the distribution of a filtered analogue PDC data set (FlowDat, Ogburn (2012)).

```python
phimin = 2
phimax = 30
phimean = 15.18
phistd = 6.3

def truncnormpdf_phi(x):
    a, b = (phimin - phimean) / phistd, (phimax - phimean) / phistd
    y = truncnorm.pdf(x, a, b, loc=phimean, scale=phistd)
    return y

def uniformpdf_phi(x):
    if x <= phimin:
        y = 0
    elif phimin <= x <= phimax:
        y = 1 / (phimax-phimin)
    elif x >= phimax:
        y = 0
    return y
```

We can use these to calculate the weight factor associated with the PDC mobility for each simulation ($W(\phi_k)$). Using the following equation:

\begin{equation}
W(\phi_k) = \frac{f(\phi_k)}{g(\phi_k)}
\end{equation}

where $f(\phi_k)$ is the infinitesimal probability of the target PDF at $\phi$ for simulation $k$. And $g(\phi_k)$ is the infinitesimal probability of the uniform PDF at $\phi$ for simulation $k$

```python
df = pd.read_csv('source_files/initial_input_weights.csv')
df['phi_weight'] = df['phi'].apply((lambda x: truncnormpdf_phi(x)/uniformpdf_phi(x)))
```

## Collapse Height Weight

The next job is to create the target PDF for collapse height ($H_0$) for PDCs at Aluto. Recent work from Clarke (2020, in prep.), has shown that the PlumeRise model (Woodhouse, 2013) allows for the estimation of the height of the gas thrust region (assumed to equal $H_0$), given a vent radius ($r$, (m)) and water content of the magma ($H_2O$, (wt%)):

\begin{equation}
H_0 = a \cdot H_2O^{b} \cdot r^{c}
\end{equation}

By maximum-likelihood estimation, Clarke (2020, in prep.) found that for peralkaline rhyolite eruptions at Aluto: $a = 12.78$, $b = -0.61$ and $c = 1.1$.

```python
a = 12.78
b = -0.61
c = 1.1

def plume_func(radius, water):
    h0 = a * (water**b) * (radius**c)
    return h0
```

To create a target PDF (or in this case a normalised histogram), we can evaluate the equation above for the range and distribution of $H_2O$ and $r$ expected at the volcano. At Aluto, these have been estimated as:

- $H_2O$ <-- uniform distribution between 4% and 8%
- $r$ <-- triangular distribution (left = 10m, mode = 10m, right = 100m)

(see Clarke et al. (in prep.), and Clarke (PhD thesis - 2020) for reasoning)

We evaluate this equation $2 \times 10^6$ times, each with a randomly sampled $H_2O$ and $r$, and plot a histogram of the resulting $H_0$ to see the distribution. This takes a few seconds.

```python
h0_list = []

counter = 0
while counter < 2000000:
    # call random gas content and vent radius within range at Aluto
    radius = random.triangular(left = 10, mode = 10, right = 100)
    water = random.uniform(4, 8)

    # perform calculations based on these
    h0 = plume_func(radius, water)

    # append results to a list
    h0_list.append(h0)
    # add to the counter
    counter += 1

fig = plt.figure('Collapse Height')

ax1 = fig.add_subplot(111)

bins = np.linspace(0, np.max(h0_list) + 100 ,1000)
h0_hist = ax1.hist(h0_list, bins, color='darkgray', density=True)
ax1.set_yticks([])
ax1.set_xlabel('$H_0$ (m)')
ax1.set_ylabel('Probability')

plt.show()
```

The next step is to take this normalised histogram (a sort of 'pseudo-PDF'), and use it to calculate the weight factor associated with the $H_0$ for each simulation ($W(H_k)$). This is done by the following equation:

\begin{equation}
W(H_k) = \frac{f(H_k)}{g(H_k)}
\end{equation}

where $f(H_k)$ is the infinitesimal probability of the target PDF at $H_0$ for simulation $k$. And $g(H_k)$ is the infinitesimal probability of the uniform PDF at $H_0$ for simulation $k$.

For each simulation, we need to find $f(H_k)$: the value of the normalised histogram at the value of $H_0$ in the simulation. As the histogram is not a continuous PDF, we find the central value of the histogram bin containing the continuous value of $H_0$. Due to the very large number of iterations used to generate this histogram, and the narrow bin width, this closely approximates a continuous function.

```python
# calculate uniform PDF
tallest = plume_func(100, 4)
shortest = plume_func(10, 8)
uniform_h0 = 1 / (tallest - shortest)

#next find the probability density of the target pdf at each input collapse height and using this and the above to calculate the weight factor
def find_factor(lookup_hist, lookup):
    #find index of item in list with closest value
    closest_index = min(range(len(lookup_hist[1])), key=lambda i: abs(lookup_hist[1][i]-lookup))
    #find value of PDF at this index
    factor = lookup_hist[0][closest_index]
    return factor

df['h0_weight'] = df['hc'].apply((lambda x: find_factor(h0_hist, x)/uniform_h0 ))
```

Finally, we need to calculate the overall input weight factor for each simulation ($W_k$), where:

\begin{equation}
W_k = W(\phi_k) \cdot W(H_k)
\end{equation}

We can then visualise the results, and save them to a .csv named 'input_weights.csv', in the 'notebook_output_files' folder. This file, is imported and used in the accompanying importance sampling notebook.

```python
df['input_weight'] = df['phi_weight'] * df['h0_weight']

plt.scatter(df['hc'], df['phi'], c=df['input_weight'], s=1)
plt.xlabel('Collapse Height $H_0$ (m)')
plt.ylabel('Mobility $\phi$ ($^o$)')
plt.colorbar(label='Input parameter weight $W$')
plt.show()

df.to_csv('notebook_output_files/input_weights.csv')
```


# Corrugated Shells geometry

## Init symbols for *sympy*

```python
from sympy import *
from geom_util import *
from sympy.vector import CoordSys3D
import matplotlib.pyplot as plt
import sys
sys.path.append("../")

%matplotlib inline

%reload_ext autoreload
%autoreload 2
%aimport geom_util
```

```python
# Any tweaks that normally go in .matplotlibrc, etc., should explicitly go here
%config InlineBackend.figure_format='retina'
plt.rcParams['figure.figsize'] = (12, 12)

plt.rc('text', usetex=True)

plt.rc('font', family='serif')
# SMALL_SIZE = 42
# MEDIUM_SIZE = 42
# BIGGER_SIZE = 42

# plt.rc('font', size=SMALL_SIZE)          # controls default text sizes
# plt.rc('axes', titlesize=SMALL_SIZE)     # fontsize of the axes title
# plt.rc('axes', labelsize=MEDIUM_SIZE)    # fontsize of the x and y labels
# plt.rc('xtick', labelsize=SMALL_SIZE)    # fontsize of the tick labels
# plt.rc('ytick', labelsize=SMALL_SIZE)    # fontsize of the tick labels
# plt.rc('legend', fontsize=SMALL_SIZE)    # legend fontsize
# plt.rc('figure', titlesize=BIGGER_SIZE)  # fontsize of the figure title

init_printing()
```

```python
N = CoordSys3D('N')
alpha1, alpha2, alpha3 = symbols("alpha_1 alpha_2 alpha_3", real = True, positive=True)
```

## Cylindrical coordinates

```python
R, L, ga, gv = symbols("R L g_a g_v", real = True, positive=True)
```

```python
a1 = pi / 2 + (L / 2 - alpha1)/R

a2 = 2 * pi * alpha1 / L

x1 = (R + ga * cos(gv * a2)) * cos(a1)
x2 = alpha2
x3 = (R + ga * cos(gv * a2)) * sin(a1)

r = x1*N.i + x2*N.j + x3*N.k

r1=r.diff(alpha1)
r1
```

```python
z = 2*ga*gv*pi/L*sin(gv*a2)
w = 1 + ga/R*cos(gv*a2)

dr1x=w*sin(a1) - z*cos(a1)
dr1z=-w*cos(a1) - z*sin(a1)

r1 = dr1x*N.i + dr1z*N.k
r2 =N.j

mag=sqrt((w)**2+(z)**2)

nx = -dr1z/mag
nz = dr1x/mag

n = nx*N.i+nz*N.k

dnx=nx.diff(alpha1)
dnz=nz.diff(alpha1)

dn= dnx*N.i+dnz*N.k
```

```python
Ralpha = r+alpha3*n

R1=r1+alpha3*dn
R2=Ralpha.diff(alpha2)
R3=n

Ralpha
```

```python
R1
```

```python
R2
```

```python
R3
```

### Draw

```python
import plot

%aimport plot

# x1 = (R + alpha3 + ga * cos(gv * a2)) * cos(a1)
# x2 = alpha2
# x3 = (R + alpha3 + ga * cos(gv * a2)) * sin(a1)

x1 = Ralpha.dot(N.i)
x3 = Ralpha.dot(N.k)

alpha1_x = lambdify([R, L, ga, gv, alpha1, alpha3], x1, "numpy")
alpha3_z = lambdify([R, L, ga, gv, alpha1, alpha3], x3, "numpy")

R_num = 1/0.8
L_num = 2
h_num = 0.01
ga_num = 0.01
gv_num = 20

x1_start = 0
x1_end = L_num
x3_start = -h_num/2
x3_end = h_num/2

def alpha_to_x(a1, a2, a3):
    x=alpha1_x(R_num, L_num, ga_num, gv_num, a1, a3)
    z=alpha3_z(R_num, L_num, ga_num, gv_num, a1, a3)
    return x, 0, z

plot.plot_init_geometry_2(x1_start, x1_end, x3_start, x3_end, alpha_to_x)
```

```python
%aimport plot

R3_1=R3.dot(N.i)
R3_3=R3.dot(N.k)

R3_1_x = lambdify([R, L, ga, gv, alpha1, alpha3], R3_1, "numpy")
R3_3_z = lambdify([R, L, ga, gv, alpha1, alpha3], R3_3, "numpy")

def R3_to_x(a1, a2, a3):
    x=R3_1_x(R_num, L_num, ga_num, gv_num, a1, a3)
    z=R3_3_z(R_num, L_num, ga_num, gv_num, a1, a3)
    return x, 0, z

plot.plot_vectors(x1_start, x1_end, 0, alpha_to_x, R3_to_x)
```

```python
%aimport plot

R1_1=r1.dot(N.i)
R1_3=r1.dot(N.k)

R1_1_x = lambdify([R, L, ga, gv, alpha1, alpha3], R1_1, "numpy")
R1_3_z = lambdify([R, L, ga, gv, alpha1, alpha3], R1_3, "numpy")

def R1_to_x(a1, a2, a3):
    x=R1_1_x(R_num, L_num, ga_num, gv_num, a1, a3)
    z=R1_3_z(R_num, L_num, ga_num, gv_num, a1, a3)
    return x, 0, z

plot.plot_vectors(x1_start, x1_end, 0, alpha_to_x, R1_to_x)
```

### Lame params

```python
A=mag
q=w/R+ga*(2*pi*gv/L)**2*cos(gv*a2)
K=(q*w+2*z*z/R)/(mag**3)

H1 = A*(1+alpha3*K)
H2=S(1)
H3=S(1)

H=[H1, H2, H3]
DIM=3
dH = zeros(DIM,DIM)
for i in range(DIM):
    dH[i,0]=H[i].diff(alpha1)
    dH[i,1]=H[i].diff(alpha2)
    dH[i,2]=H[i].diff(alpha3)

trigsimp(H1)

```


# Asyptopic Notation

Asyptopic notation describes how algorithums running time grows with the size of its input.

## Big-O

Big-O describes the upper bound of the growth rate of a function. $f = O(g)$ means the function $f$ has a growth rate no larger than function $g$. The definition for Big-O is as follows, for functions $f, g : \mathbb{N} \rightarrow \mathbb{R}$, $f$ is $O(g)$ iff there is an $n_0 \in \mathbb{N}$ and $c > 0$ in $\mathbb{R}$ such that for all $n \geq n_0$:

$$
0 \leq f(n) \leq cg(n)
$$

Asyptopic notation describes the long term behaviour of a function, thus $n_0$ allows us to discard the initial aytipical behavipur of the function. The constant $c$ is nessisary because multiplying a function by a constant doesnt change its growth rate. Even though for functions $f_1(n) = n$ and $f_2(n) = 2n$, $f_1(n) < f_2(n)$ for all $n > 0$, they still grow at a linear rate.

```python
import matplotlib.pyplot as plt
import numpy as np

n = np.linspace(0, 10, 100)
f1 = [n for n in n]
f2 = [2*n for n in n]

plt.plot(n, f1)
plt.plot(n, f2)

plt.title('Growth rate of n vs 2n')
plt.show()
```

### Example

$$
3n^3 + 8 = O(n^3)
$$

To prove this, we need a $c$ and $n_0$ such that
$$
0 \leq 3n^3 + 8 \leq cn^3 \quad \text{for all $n_0 \geq n$}
$$

If we divide through by $n^3$ we get:
$$
3n^3 + 8 \leq cn^3 \iff 3 + \frac{8}{n^3} \leq c \quad \text{where $n > 0$}
$$

Now notice how $\frac{8}{n^3}$ is decreasing, and its maximum value is $8$, this tells us
$$
3 + \frac{8}{n^3} \leq 11 \quad \text{where $n > 0$}
$$

Thus $c = 11$ and $n = 1$, alternativly we could have picked $c = 4$ and $n = 2$

```python
import matplotlib.pyplot as plt
import numpy as np

n = np.linspace(0, 2.5, 100)
f1 = lambda n: 3*n**3+8
f2 = lambda n: 11*n**3
f3 = lambda n: 4*n**3

plt.plot(n, [f3(n) for n in n], label="$4n^3$")
plt.plot(2, f3(2), marker='o', markersize=5)
plt.plot(n, [f2(n) for n in n], label="$11n^3$")
plt.plot(1, f2(1), marker='o', markersize=5)
plt.plot(n, [f1(n) for n in n], label="$3n^3 + 8$")

plt.title('Growth rate of $3n^3 + 8$ vs $11n^3$')
plt.legend()
plt.show()
```

## Laws of Big-O

The proof above is a proof by first principles, but their are some general laws which can make our proofs easier.

1. For any constant $a > 0$ in $\mathbb{R}$: $f = O(g) \implies a f = O(g)$
2. $f_1 = O(g_1)$ and $f_2 = O(g_2) \implies f_1 + f_2 = O(g_1 + g_2)$
3. $f_1 = O(g_1)$ and $f_2 = O(g_2) \implies f_1 f_2 = O(g_1 g_2)$
4. $f = O(g_1)$ and $g_1 = O(g_2) \implies f = O(g_2)$
5. For any $d \in \mathbb{N}$: if $f_1$ is a polynomial of degree $d$ with strictly positive leading coeffient then $f = O(n^d)$
6. For any constants $a > 0$ and $b > 1$: $n^a = O(b^n)$
7. For any constant $a > 0$ in $\mathbb{R}$: $ln(n^a) = O(lg(n))$
8. For any constants $a, b > 0$ in $\mathbb{R}$: $lg^a(n) = O(n^b)$

### Example

$$
\begin{align}
871n^3 + 13n^2 lg^5(n) + 18n + 566 &= 871n^3 + 13n^2 O(n) + 18n + 566 && \text{by Theorem 8} \\
&= 871n^3 + O(n^3) + 18n + 566 && \text{by Theorem 3}\\
&= (871n^3 + 18n + 566) + O(n^3)\\
&= O(n^3) + O(n^3)  && \text{by Theorem 5}\\
&= O(n^3)  && \text{by Theorem 2}\\
\end{align}
$$

## Big-$\Omega$

Big-$\Omega$ allows us to make statements about a lower bound such as "$f$ grows as least as fast as $g$". The definition is as follows: for functions $f, g: \mathbb{N} \rightarrow \mathbb{R}$, $f$ is $\Omega(g)$ is there is an $n_0 \in \mathbb{N}$ and $c > 0$ in $\mathbb{R}$ such that for all $n \geq n_0$:

$$
f(n) \geq cg(n) \geq 0
$$

## Laws of Big-$\Omega$

Just as with Big-O, we have simular laws for Big-$\Omega$.

1. For any constant $a > 0$ in $\mathbb{R}$: $f = \Omega(g) \implies af = \Omega(g)$
2. $f_1 = \Omega(g_1)$ and $f_2 = \Omega(g_2) \implies f_1 + f_2 = \Omega(g_1 + g_2)$
3. $f_1 = \Omega(g_1)$ and $f_2 = \Omega(g_2) \implies f_1 f_2 = \Omega(g_1 g_2)$
4. $f_1 = \Omega(g_1)$ and $g_1 = \Omega(g_2) \implies f_1 = \Omega(g_2)$
5. For any $d \in \mathbb{N}$: if $f$ is a polynomial of degree $d$ with strictly positive leading coefficient $f = \Omega(n^d)$
6. For any constant $a > 0$ in $\mathbb{R}$: $lg^a(n) = \Omega(lg(n))$

## Big-$\Theta$

Finnally Big-$\Theta$ defines a lower and upper bound, more formally if $f = O(g)$ and $f = \Omega(g)$ then $f = \Theta(g)$


# Ray Tracing for Tilted Flat Mirrors

This notebook goes through the formulation of the ray-tracing equations used at the LCLS Skywalker project to automate alignment of the *hard x-ray offset mirrors* (HOMS) to points on two imagers downstream of the mirrors. Below is a schematic of the mirror systems in the *front end enclosure* (FEE), including the HOMS as well as the *soft x-ray offset mirrors* (SOMS).

Of these mirrors only M1H and M2H will be used in the derivation but in principle should be applicable to any flat two mirror system including the SOMS. In addition to the mirrors, equations for the beam position at various imagers will be derived as well and are located right before M1H, between M1H and M2H and two after M2H. The coordinate system used in this notebook will be the same as shown in the diagram, with the $z$-axis going along the beamline, the $x$-axis running in the horizontal direction parallel to the ground and the $y$ axis running vertically off the ground. For all the calulations made, the small angle approximation ($tan(x) \approx x$) was used as the range of the mirrors is only several microradians from the nominal position of 0.0014 radians. One final assumption made is that the imager normals run parallel to $z$-axis

To the first order, the beam, mirrors, and imagers can be represented as lines in space where the point of reflection/contact would just be the intersection point of these lines. Knowing this, one can go through the beamline, component by component and calculate the expected $x$ position, $z$ position and angle of the beam at the point of contact or any point in between. To actually do the derivation, the python package [sympy](http://www.sympy.org/en/index.html) was used. It is a package to perform symbolic mathematics within the python langauge.

We begin by first importing sympy and enabling nice printing.

```python
import sympy as sp
sp.init_printing()
```

Start by initializing the symbols pertaining to the beamline components.

```python
dp1hx, dp1hz      = sp.symbols("D^x_P1H D^z_P1H")      # X and Z position at the center of P1H
dm1hx, dm1hz      = sp.symbols("D^x_M1H D^z_M1H")      # X and Z position at the center of M1H
dp2hx, dp2hz      = sp.symbols("D^x_P2H D^z_P2H")      # X and Z position at the center of P2H
dm2hx, dm2hz      = sp.symbols("D^x_M2H D^z_M2H")      # X and Z position at the center of M2H
dp3hx, dp3hz      = sp.symbols("D^x_P3H D^z_P3H")      # X and Z position at the center of P3H
ddg3x, ddg3z      = sp.symbols("D^x_DG3 D^z_DG3")      # X and Z position at the center of DG3
a1, a2            = sp.symbols("alpha_M1H alpha_M2H")  # M1H and M2H angle wrt z-axis
```

And also the beam at all the various components. Most of the symbolic definitions in this next section aren't necessary because they will end up just being expressions using the variables above. They are defined only to make it explicit to the reader what these expressions will represent.

```python
x0, xp0           = sp.symbols("x_0 x^'_0")            # X and angle of the beam at the source
xpm1h, xpm2h      = sp.symbols("x^'_M1H x^'_M2H")      # Reflection angle after M1H and M2H
xp1h, zp1h, xpp1h = sp.symbols("x_P1H z_P2H x^'_P1H")  # X, Z and angle of the beam at P1H
xm1h, zm1h, xpm1h = sp.symbols("x_M1H z_M1H x^'_M1H")  # X, Z and angle of the beam at M1H
xp2h, zp2h, xpp2h = sp.symbols("x_P2H z_P2H x^'_P2H")  # X, Z and angle of the beam at P2H
xm2h, zm2h, xpm2h = sp.symbols("x_M2H z_M2H x^'_M2H")  # X, Z and angle of the beam at M2H
xp3h, zp3h, xpp3h = sp.symbols("x_P3H z_P3H x^'_P3H")  # X, Z and angle of the beam at P3H
xdg3, zdg3, xpdg3 = sp.symbols("x_DG3 z_DG3 x^'_DG3")  # X, Z and angle of the beam at DG3
```

**NOTE:** Sympy arranges symbols differently depending on the variable names and can result in different forms of the same equation.

## P1H

This imager isn't necessarily part of the alignment but is calculated anyway to show the equations representing the beam before hitting M1H.

```python
# Standard line equation
xp1h = x0 + dp1hx * xp0

# Position in z is just the position of the imager
zp1h = dp1hz

# Angle is the same as the initial angle of the beam
xpp1h = xp0
```

And then from here we can print the equations we get:

```python
# X Position of the beam at P1H
sp.relational.Eq(sp.symbols("x_P1H"), xp1h)
```

```python
# Z Position of the beam at P1H
sp.relational.Eq(sp.symbols("z_P1H"), zp1h)
```

```python
# Angle of the beam at P1H
sp.relational.Eq(sp.symbols("x^'_P1H"), xpp1h)
```

## M1H

To solve for M1H, we can characterize the surface of the mirror as being a line with a slope $\alpha_{M1H}$, and then find the intersection between the beam and this line. Starting with point slope form, move the terms to one side and then solve using sympy.

```python
# Declare generic x and z variables to solve for
x, z = sp.symbols("x z")

# Using point slope eq for a line to get line of M1H (point is dm1hz, dm1hx)
m1h_ln = a1 * (z - dm1hz) + dm1hx - x

# Solve for x and grab the only element in the returned solution list
m1h_ln_x = sp.solve(m1h_ln, x)[0]

# Use a standard line equation to characterize the beam
beam_ln = x0 + z * xp0

# Setting them equal to each other and move to one side then solve for z
zm1h = sp.solve(m1h_ln_x - beam_ln, z)[0]

# Plugging this z into the eq for the beam line to get x point of intersection then simplifying
xm1h = sp.simplify(x0 + zm1h * xp0)

# Define the new angle of reflection
xpm1h = 2*a1 - xp0
```

And then to view the resulting equations:

```python
# X Position of the beam at the point of reflection on M1H
sp.relational.Eq(sp.symbols("x_M1H"), xm1h)
```

```python
# Z Position of the beam at the point of reflection on M1H
sp.relational.Eq(sp.symbols("z_M1H"), zm1h)
```

```python
# New angle after reflection off M1H
sp.relational.Eq(sp.symbols("x^'_M1H"), xpm1h)
```

## P2H

The same method that was applied to get P1H is re-applied here but using the $x$, $z$ and angle $x'_{M1H}$ of the beam at the point of contact at M1H.

```python
# X position follows the line eq but using x position at m1h and new angle
xp2h_eq = xm1h + xpm1h * (dp2hz-zm1h)

# Simplify
xp2h = sp.simplify(xp2h_eq)

# Z position is just the defined z position of the imager
zp2h = dp2hz

# Angle of the beam isn't changing from M1H
xpp2h = xpm1h
```

And then viewing the results:

```python
# X Position of the beam as it crosses P2H
sp.relational.Eq(sp.symbols("x_P2H"), xp2h)
```

```python
# Z Position of the beam is just the z position of the imager
sp.relational.Eq(sp.symbols("z_P2H"), zp2h)
```

```python
# Angle of the beam is just the reflected angle off M1H
sp.relational.Eq(sp.symbols("x^'_P2H"), xpp2h)
```

## M2H

For the second mirror we re-apply the same method used for the first but this time we need to solve for the line equation of the beam in addition to the mirror. The resulting line equations are then set to be equal to find the point of intersection.

```python
# Declare generic x and z variables to solve for
x, z = sp.symbols("x z")

# Using point slope eq for a line to get line of M2H (point is dm2hz, m2hdx)
m2h_ln = a2 * (z - dm2hz) + dm2hx - x

# Solve for x and grab the only element in the solution
m2h_ln_x = sp.solve(m2h_ln, x)[0]

# Get the line eq for the beam using beam parameters from m1h in point slope
beam_ln =  xpm1h*(z - zm1h) + xm1h - x

# Solve for x and grab the only element in the returned solution list
beam_ln_x = sp.solve(beam_ln, x)[0]

# Set them equal to each other and move to one side then solve for z. Grab only resulting solution
zm2h = sp.solve(m2h_ln_x - beam_ln_x, z)[0]

# Substitute z intersection point into the eq for the beam line to get x point of intersection
m2h_sub = beam_ln.subs(z, zm2h)

# Solve for x and grab the only element
xm2h = sp.solve(m2h_sub, x)[0]

# Calculate the new reflection angle
xpm2h = 2*a2 - xpm1h
```

Let's see the resulting equations:

```python
# X Position of the beam at the point of reflection on M2H
sp.relational.Eq(sp.symbols("x_M2H"), xm2h)
```

```python
# Z Position of the beam at the point of reflection on M2H
sp.relational.Eq(sp.symbols("z_M2H"), zm2h)
```

```python
# New angle after reflection off M1H
sp.relational.Eq(sp.symbols("x^'_M2H"), xpm2h)
```

## P3H

Finding the equations for the beam after M2H is the same as for P2H just using the beam positions at M2H rather than M1H. From there the equation is just a difference of how far away you are in $z$.

```python
# X position follows the line eq but using x position at m2h and new angle
xp3h_eq = xm2h + xpm2h*(dp3hz-zm2h)

# Simplify
xp3h = sp.simplify(xp3h_eq)

# Z position is just the defined z position of the imager
zp3h = dp3hz

# Angle doesn't change
xpp3h = xpm2h
```

Printing the resulting equations we get:

```python
# X Position of the beam at P3H
sp.relational.Eq(sp.symbols("x_P3H"), xp3h)
```

```python
# Z Position of the beam is just the z position of the imager
sp.relational.Eq(sp.symbols("z_P3H"), zp3h)
```

```python
# Angle of the beam is just the reflected angle off M2H
sp.relational.Eq(sp.symbols("x^'_P3H"), xpp3h)
```

## DG3

The method and resulting equations for DG3 are identical to P3H just substituted with the positions of DG3.

```python
# X position follows the line eq but using x position at m2h and new angle
xdg3_eq = xm2h + xpm2h*(ddg3z-zm2h)

# Simplify
xdg3 = sp.simplify(xdg3_eq)

# Z position is just the defined z position of the imager
zdg3 = ddg3z

# Angle doesn't change
xpdg3 = xpm2h
```

And the final resulting equations:

```python
# X Position of the beam at DG3
sp.relational.Eq(sp.symbols("x_DG3"), xdg3)
```

```python
# Z Position of the beam is just the z position of the imager
sp.relational.Eq(sp.symbols("z_DG3"), zdg3)
```

```python
# Angle of the beam is just the reflected angle off M2H
sp.relational.Eq(sp.symbols("x^'_DG3"), xpdg3)
```

# Walking the Beam

Being able to fully characterize the beam at all points on the beamline, we can use them to apply the iterative beam walking algorithm (shown [here](http://laser.physics.sunysb.edu/~simone/mini-project/) and [here](https://virtualetters.blogspot.com/2012/07/walking-beam.html)) by solving the equations above for the mirror angles at the respective imagers. The algorithm calls for aligning the angle of the first mirror to minimize the error on the first imager and then align the angle of the second mirror to minimize the error on the second imager. Repeating this sequence should converge to mirror angles that hit (or minimize the error) the desired two points.

So to do this is relatively straight forward. We solve $x_{P3H}$ for $\alpha_{M1H}$ and then solve $x_{DG3}$ for $\alpha_{M2H}$.

```python
# Declare generic x positions representing the positions we want to hit on the imagers
# These are not to be confused with the equations for xp3h and xdg3 we got earlier
p3hx, dg3x = sp.symbols("x_P3H x_DG3")

# Move everything to one side and solve p3h x position for alpha 1
p3h_alpha = sp.solve(xp3h - p3hx, a1)[0]

# Move everything to one side and solve dg3 x position for alpha 2
dg3_alpha = sp.solve(xdg3 - dg3x, a2)[0]
```

Printing the resulting equations we get:

```python
sp.relational.Eq(sp.symbols("alpha^P3H_M1H"), p3h_alpha)
```

```python
sp.relational.Eq(sp.symbols("alpha^DG3_M2H"), dg3_alpha)
```

# Solving Analytically

Using the equations obtained for alpha in the previous section, we can take them one step furthur and analytically solve for the angles that hit both targets. To do this, just substitute one equation into the other, solve for that alpha and then use the result to solve for the other.

```python
# Substitute the second equation into alpha_2 in the first
alpha_1_eq = p3h_alpha.subs(a2, dg3_alpha)

# Move alpha_1 over to the right and re-solve for alpha_1 with the substitutions
alpha_1 = sp.solve(alpha_1_eq - a1, a1)[0]
```

And then printing the new equation:

```python
sp.relational.Eq(sp.symbols("alpha_M1H"), alpha_1)
```

Then using the value for $\alpha_{M1H}$ we get there, just plug it into the same equation we had for $\alpha_{M2H}$.

```python
sp.relational.Eq(sp.symbols("alpha_M2H"), dg3_alpha)
```

notebooks/1. Ray Tracing for Titled Flat Mirrors.ipynb