# Simple Biochemical Reaction Networks

This tutorial has been adapted from the tutorials in https://github.com/mic-pan/BGT_BiochemicalNetworkTutorials

```julia
# Since BondGraphs is not yet in the package manager, we will need to include it directly from Github
# NOTE: You will need Julia >= 1.7
using Pkg; Pkg.add(url="https://github.com/jedforrest/BondGraphs.jl")
using BondGraphs
```

```julia
using Plots
using ModelingToolkit
using Catalyst
```

## A simple biochemical example
$$
A + B \rightleftharpoons C
$$
BondGraphs can convert Catalyst reaction networks into a BondGraph

```julia
abc = @reaction_network ABC begin
    1, A + B --> C
end
```

\begin{align}
\require{mhchem}
\ce{ A + B &->[1] C}
\end{align}

```julia
bg_abc = BondGraph(abc)
```

    BondGraph ABC (5 Nodes, 4 Bonds)

```julia
plot(bg_abc, fontsize=12)
```

```julia
re, one, A, B, C, = nodes(bg_abc)
```

    5-element Vector{AbstractNode}:
     Re:R1
     𝟏_2
     Ce:A
     Ce:B
     Ce:C

```julia
constitutive_relations(A)
```

\begin{align}
0 =&  - E_1\left( t \right) + R T \log\left( K q\left( t \right) \right) \\
\frac{dq(t)}{dt} =& F_1\left( t \right)
\end{align}

```julia
constitutive_relations(re)
```

\begin{align}
0 =& F_1\left( t \right) + F_2\left( t \right) \\
0 =&  - r \left(  - e^{\frac{E_2\left( t \right)}{R T}} + e^{\frac{E_1\left( t \right)}{R T}} \right) + F_1\left( t \right)
\end{align}

```julia
eqs = constitutive_relations(bg_abc)
```

\begin{align}
\frac{dA_{+}q(t)}{dt} =&  - R1_{+}r \left(  - C_{+}K C_{+q}\left( t \right) + A_{+}K B_{+}K A_{+q}\left( t \right) B_{+q}\left( t \right) \right) \\
\frac{dB_{+}q(t)}{dt} =&  - R1_{+}r \left(  - C_{+}K C_{+q}\left( t \right) + A_{+}K B_{+}K A_{+q}\left( t \right) B_{+q}\left( t \right) \right) \\
\frac{dC_{+}q(t)}{dt} =& R1_{+}r \left(  - C_{+}K C_{+q}\left( t \right) + A_{+}K B_{+}K A_{+q}\left( t \right) B_{+q}\left( t \right) \right)
\end{align}

```julia
eqs = constitutive_relations(bg_abc; sub_defaults=true)
```

\begin{align}
\frac{dA_{+}q(t)}{dt} =&  - \left(  - C_{+q}\left( t \right) + A_{+q}\left( t \right) B_{+q}\left( t \right) \right) \\
\frac{dB_{+}q(t)}{dt} =&  - \left(  - C_{+q}\left( t \right) + A_{+q}\left( t \right) B_{+q}\left( t \right) \right) \\
\frac{dC_{+}q(t)}{dt} =&  - C_{+q}\left( t \right) + A_{+q}\left( t \right) B_{+q}\left( t \right)
\end{align}

```julia
tspan = (0., 3.)
u0 = [1, 2, 3]
sol = simulate(bg_abc, tspan; u0)
plot(sol, lw=2)
```

## Stoichiometric Example
$$
A + 2B \rightleftharpoons 3C
$$

```julia
a2b2c = @reaction_network A2B2C begin
    1, A + 2B --> 3C
end
```

\begin{align}
\require{mhchem}
\ce{ A + 2 B &->[1] 3 C}
\end{align}

```julia
bg_a2b2c = BondGraph(a2b2c)
plot(bg_a2b2c, fontsize=12)
```

```julia
eqs = constitutive_relations(bg_a2b2c; sub_defaults=true)
```

\begin{align}
\frac{dA_{+}q(t)}{dt} =&  - \left(  - \left( C_{+q}\left( t \right) \right)^{3} + \left( B_{+q}\left( t \right) \right)^{2} A_{+q}\left( t \right) \right) \\
\frac{dB_{+}q(t)}{dt} =&  - 2.0 \left(  - \left( C_{+q}\left( t \right) \right)^{3} + \left( B_{+q}\left( t \right) \right)^{2} A_{+q}\left( t \right) \right) \\
\frac{dC_{+}q(t)}{dt} =& 3 \left(  - \left( C_{+q}\left( t \right) \right)^{3} + \left( B_{+q}\left( t \right) \right)^{2} A_{+q}\left( t \right) \right)
\end{align}

```julia
tspan = (0., 1.)
u0 = [2, 1, 2]
sol = simulate(bg_a2b2c, tspan; u0)
plot(sol, lw=2)
```

## The reversible Michaelis-Menten model
$$
E + S \rightleftharpoons C \\
C \rightleftharpoons E + P
$$

```julia
rn_mm = @reaction_network MM_reversible begin
    (1, 1), E + S <--> C
    (1, 1), C <--> E + P
end
```

\begin{align}
\require{mhchem}
\ce{ E + S &<=>[1][1] C}\\
\ce{ C &<=>[1][1] E + P}
\end{align}

Chemostats are chemical species that are held constant throught the reaction (`Se` in bond graph terms)

```julia
bg_mm = BondGraph(rn_mm; chemostats=["S", "P"])
```

    BondGraph MM_reversible (10 Nodes, 10 Bonds)

```julia
plot(bg_mm, fontsize=12)
```

```julia
# Update default concentration for the substrate
bg_mm.S.xs = t -> 2
```

    #1 (generic function with 1 method)

```julia
constitutive_relations(bg_mm)
```

\begin{align}
\frac{dE_{+}q(t)}{dt} =& R2_{+}r \left( C_{+}K C_{+q}\left( t \right) - E_{+}K P_{+}K E_{+q}\left( t \right) \right) - R1_{+}r \left(  - C_{+}K C_{+q}\left( t \right) + 2 E_{+}K S_{+}K E_{+q}\left( t \right) \right) \\
\frac{dC_{+}q(t)}{dt} =& R1_{+}r \left(  - C_{+}K C_{+q}\left( t \right) + 2 E_{+}K S_{+}K E_{+q}\left( t \right) \right) - R2_{+}r \left( C_{+}K C_{+q}\left( t \right) - E_{+}K P_{+}K E_{+q}\left( t \right) \right)
\end{align}

```julia
tspan = (0., 3.)
u0 = [1,2]
sol = simulate(bg_mm, tspan; u0)
plot(sol, lw=2)
```

```julia
# Rerun simulation with a substrate which increases over time
bg_mm.S.xs = t -> 1 + t

tspan = (0., 10.)
u0 = [1,2]
sol = simulate(bg_mm, tspan; u0)
plot(sol, lw=2)
```


```python
import numpy as np
import matplotlib.pyplot as plt
import variance_solvers
import equations
import solvers

%matplotlib inline
```

**Grid refinement**

**Variances**

```python
eq = lambda L_x, L_y: equations.equation_2(L_x, L_y)
```

```python
split_it = []
stripes_it = []
thick_it = []
BP_it = []
BP_it_p = []
N = []
for J in [3, 4, 5, 6]:
    h = 1/(1 + 2**J)
    A, b, exact = equations.construct_matrix(eq, 'Dirichlet', 1, 1, 2**J, 2**J)
    A = A.toarray()

    _, E = variance_solvers.split_BP_solver(A, b, tol=h**2, write=True)
    _, EE = variance_solvers.stripes_BP_solver(A, b, h**2, write=True)
    _, EEE = variance_solvers.thick_stripes_BP_solver(A, b, h**2, write=True)
    _, EEEE = variance_solvers.GaBP(A, b, tol=h**2, write=True)
    _, EEEE = variance_solvers.GaBP(A, b, tol=h**2, type='Parallel', write=True)
    split_it.append(len(E))
    stripes_it.append(len(EE))
    thick_it.append(len(EEE))
    BP_it.append(len(EEEE))
    BP_it_p.append(len(EEEE))
    N.append(2**J)
```

```python
plt.plot(2*np.log10(N), split_it, color='black', label=f'split')
plt.plot(2*np.log10(N), thick_it, dashes=[2, 2, 10, 2], color='black', label=f'thick stripes')
plt.plot(2*np.log10(N), stripes_it, color='black', label=f'stripes')
plt.plot(2*np.log10(N), BP_it, dashes=[2, 2, 2, 2], color='black', label=f'sequential GaBP')
plt.plot(2*np.log10(N), BP_it_p, dashes=[2, 2, 2, 2], color='black', label=f'parallel GaBP')
plt.legend(loc = (0.02, 0.55))
#plt.grid(1)
plt.ylabel('iterations till convergence')
plt.xlabel('$\\log_{10}$ inner points');
plt.savefig('variance_vs_grid_points.eps', format='eps', pad_inches=0.01)
```

```python
split_it = []
stripes_it = []
thick_it = []
BP_it = []
BP_it_p = []
N = []
for J in [3, 4, 5, 6, 7]:
    h = 1/(1 + 2**J)
    A, b, exact = equations.construct_matrix(eq, 'Dirichlet', 1, 1, 2**J, 2**J)
    A = A.toarray()

    exact = np.diag(np.linalg.inv(A))

    #s1, E = split_BP_solver(A, b, tol=h**2, write=True)
    s1, _ = variance_solvers.stripes_BP_solver(A, b, h**2, write=True)
    s2, _ = variance_solvers.thick_stripes_BP_solver(A, b, h**2, write=True)
    s3, _ = variance_solvers.GaBP(A, b, tol=h**2, write=True)
    s4, _ = variance_solvers.GaBP(A, b, tol=h**2, type='Parallel', write=True)

    EE = np.linalg.norm(s1-exact, ord=np.inf)
    EEE = np.linalg.norm(s2-exact, ord=np.inf)
    EEEE = np.linalg.norm(s3-exact, ord=np.inf)
    EEEEE = np.linalg.norm(s4-exact, ord=np.inf)
    #split_it.append(len(E))
    stripes_it.append(EE)
    thick_it.append(EEE)
    BP_it.append(EEEE)
    BP_it_p.append(EEEEE)
    N.append(h)
```

```python
plt.plot(np.log10(N), np.log10(thick_it), dashes=[2, 2, 10, 2], color='black', label=f'thick stripes')
plt.plot(np.log10(N), np.log10(stripes_it), color='black', label=f'stripes')
plt.plot(np.log10(N), np.log10(BP_it), dashes=[2, 2, 2, 2], color='black', label=f'sequential GaBP')
plt.plot(np.log10(N), np.log10(BP_it_p), '.', dashes=[2, 0, 2, 0], color='black', label=f'parallel GaBP')
plt.legend()
plt.grid(1)
plt.ylabel('$\\log_{10}$ error for variances')
plt.xlabel('$\\log_{10} h$');
plt.savefig('variance_convergence_grid.eps', format='eps', pad_inches=0.01)
```

**The whole scheme**

```python
eq = lambda L_x, L_y: equations.equation_2(L_x, L_y)
```

```python
split_it = []
stripes_it = []
thick_it = []
BP_it = []
BP_it_p = []
N = []
for J in [2, 3, 4, 5]:
    h = 1/(1 + 2**J)
    A, b, exact = equations.construct_matrix(eq, 'Dirichlet', 1, 1, 2**J, 2**J)
    A = A.toarray()

    _, E = solvers.split_BP_solver(A, b, tol=h**2, write=True)
    _, EE = solvers.stripes_BP_solver(A, b, h**2, write=True)
    _, EEE = solvers.thick_stripes_BP_solver(A, b, h**2, write=True)
    _, EEEE = solvers.GaBP(A, b, tol=h**2, write=True)
    _, EEEEE = solvers.GaBP(A, b, tol=h**2, type='Parallel', write=True)

    split_it.append(len(E))
    stripes_it.append(len(EE))
    thick_it.append(len(EEE))
    BP_it.append(len(EEEE))
    BP_it_p.append(len(EEEEE))
    N.append(2**J)
```

```python
AL = []
for a, name in zip([np.log10(split_it), np.log10(thick_it), np.log10(stripes_it), np.log10(BP_it), np.log10(BP_it_p)], ['split', 'thick stripes', 'stripes', 'sequential GaBP', 'parallel GaBP']):
    alpha = np.polyfit(2*np.log10(N), a, deg=1)[0]
    AL.append(alpha)
    print(f'Tangent coefficient for {name} scheme is {alpha:1.4}')
```

    Tangent coefficient for split scheme is 0.3199
    Tangent coefficient for thick stripes scheme is 0.6483
    Tangent coefficient for stripes scheme is 0.8361
    Tangent coefficient for sequential GaBP scheme is 0.8637
    Tangent coefficient for parallel GaBP scheme is 0.9097

```python
plt.plot(2*np.log10(N), np.log10(split_it), dashes=[5, 5, 5, 5], color='black', label=f'split ({AL[0]:1.2})')
plt.plot(2*np.log10(N), np.log10(thick_it), dashes=[2, 2, 10, 2], color='black', label=f'thick stripes ({AL[1]:1.2})')
plt.plot(2*np.log10(N), np.log10(stripes_it), dashes=[2, 2, 2, 2], color='black', label=f'stripes ({AL[2]:1.2})')
plt.plot(2*np.log10(N), np.log10(BP_it), color='black', label=f'sequential GaBP ({AL[3]:1.2})')
plt.plot(2*np.log10(N), np.log10(BP_it_p), '.', dashes=[2, 0, 2, 0], color='black', label=f'parallel GaBP ({AL[4]:1.2})')
plt.legend()
#plt.grid(1)
plt.ylabel('$\\log_{10}$ iterations till convergence')
plt.xlabel('$\\log_{10}$ inner points');
plt.savefig('Full_convergence_vs_grid_points_.eps', format='eps', pad_inches=0.01)
```

```python
split_it = []
stripes_it = []
thick_it = []
BP_it = []
BP_it_p = []
N = []
for J in [2, 3, 4, 5]:
    h = 1/(1 + 2**J)
    A, b, exact = equations.construct_matrix(eq, 'Dirichlet', 1, 1, 2**J, 2**J)
    A = A.toarray()

    s1, _ = solvers.split_BP_solver(A, b, tol=h**2, write=True)
    s2, _ = solvers.stripes_BP_solver(A, b, h**2, write=True)
    s3, _ = solvers.thick_stripes_BP_solver(A, b, h**2, write=True)
    s4, _ = solvers.GaBP(A, b, tol=h**2, write=True)
    s5, _ = solvers.GaBP(A, b, type='Parallel', tol=h**2, write=True)

    E = np.linalg.norm(s1 - exact, ord=np.inf)
    EE = np.linalg.norm(s2 - exact, ord=np.inf)
    EEE = np.linalg.norm(s3 - exact, ord=np.inf)
    EEEE = np.linalg.norm(s4 - exact, ord=np.inf)
    EEEEE = np.linalg.norm(s5 - exact, ord=np.inf)

    split_it.append(E)
    stripes_it.append(EE)
    thick_it.append(EEE)
    BP_it.append(EEEE)
    BP_it_p.append(EEEEE)
    N.append(h)
```

```python
plt.plot(np.log10(N), np.log10(split_it), dashes=[2, 2, 10, 2], color='black', label=f'split')
plt.plot(np.log10(N), np.log10(thick_it), dashes=[2, 2, 10, 2], color='black', label=f'thick stripes')
plt.plot(np.log10(N), np.log10(stripes_it), color='black', label=f'stripes')
plt.plot(np.log10(N), np.log10(BP_it), dashes=[2, 2, 2, 2], color='black', label=f'sequential GaBP')
plt.plot(np.log10(N), np.log10(BP_it_p), '.', dashes=[2, 0, 2, 0], color='black', label=f'parallel GaBP')
plt.legend()
plt.grid(1)
plt.ylabel('$\\log_{10}$ error for means')
plt.xlabel('$\\log_{10} h$');
plt.savefig('means_convergence_grid.eps', format='eps', pad_inches=0.01)
```

**Convergence rate**

Consider the operator

\begin{equation}
A = e^{2(x-y)}\partial_x^2 + e^{2(y-x)}\partial_y^2 + \alpha\cos(\pi(x+y))\partial_x
\end{equation}

\begin{equation}
u = \sin(2 \pi x)\sin(\pi y)
\end{equation}

Code below computes

\begin{equation}
\left\|[A]_{\text{grid}}[u]_{\text{grid}} - [Au]_{\text{grid}}\right\|_{\infty}
\end{equation}

where by grid we mean the finite difference discretization and the projection on the uniform grid.

```python
eq = lambda L_x, L_y: equations.equation_1(L_x, L_y, 0)
```

```python
J = 5
h = 1/(1 + 2**J)
A, b, exact = equations.construct_matrix(eq, 'Dirichlet', 1, 1, 2**J, 2**J)
A = A.toarray()

s1, E = solvers.split_BP_solver(A, b, tol=h**2, write=True)
s2, EE = solvers.stripes_BP_solver(A, b, h**2, write=True)
s3, EEE = solvers.thick_stripes_BP_solver(A, b, h**2, write=True)
s4, EEEE = solvers.GaBP(A, b, tol=h**2, write=True)
s7, EEEEEEE = solvers.GaBP(A, b, tol=h**2, type='Parallel', write=True)
s5, EEEEE = solvers.GS(A, b, tol=h**2, write=True)
s6, EEEEEE = solvers.BICGSTAB(A, b, write=True)
```

```python
fig, ax = plt.subplots()
plt.plot(np.log10(E), dashes=[5, 5, 5, 5], color='black', label=f'split')
plt.plot(np.log10(EE), dashes=[2, 2, 10, 2], color='black', label=f'stripes')
plt.plot(np.log10(EEE), dashes=[2, 2, 2, 2], color='black', label=f'thick stripes')
plt.plot(np.arange(400)[::20], np.log10(EEEEEEE)[:400][::20], '.', dashes=[10, 0, 10, 0], color='black', label=f'parallel GaBP')
plt.plot(np.arange(400)[::10], np.log10(EEEEE)[:400][::10], 'x', color='black', label=f'GS')
plt.plot(np.log10(EEEEEE)[(np.log10(EEEEEE)>np.log10(h**2))], color='black', label=f'BiCGSTAB')
plt.plot(np.arange(400)[::20], np.log10(EEEE)[:400][::20], 'x', dashes=[10, 0, 10, 0], color='black', label=f'sequential GaBP')
plt.legend()
plt.grid(1)
ax.set_ylim(-3.5, 6)
plt.ylabel('$\\log_{10}\\left\\|Ax - b\\right\\|_{\\infty}$')
plt.xlabel('iteration');
plt.savefig('convergence_alpha_zero.eps', format='eps', pad_inches=0.01)
```

```python
np.where(np.array(EEEEE)>1e-3)[0][-1]
```

    1134

```python
np.where(np.array(EEEE)>1e-3)[0][-1]
```

    485

```python
eq = lambda L_x, L_y: equations.equation_1(L_x, L_y, 50)
```

```python
J = 5
h = 1/(1 + 2**J)
A, b, exact = equations.construct_matrix(eq, 'Dirichlet', 1, 1, 2**J, 2**J)
A = A.toarray()

s1, E = solvers.split_BP_solver(A, b, tol=h**2, write=True)
s2, EE = solvers.stripes_BP_solver(A, b, h**2, write=True)
s3, EEE = solvers.thick_stripes_BP_solver(A, b, h**2, write=True)
s4, EEEE = solvers.GaBP(A, b, tol=h**2, write=True)
s7, EEEEEEE = solvers.GaBP(A, b, tol=h**2, type='Parallel', write=True)
s5, EEEEE = solvers.GS(A, b, tol=h**2, write=True)
s6, EEEEEE = solvers.BICGSTAB(A, b, write=True)
```

```python
fig, ax = plt.subplots()
plt.plot(np.log10(E), dashes=[5, 5, 5, 5], color='black', label=f'split')
plt.plot(np.log10(EE), dashes=[2, 2, 10, 2], color='black', label=f'stripes')
plt.plot(np.log10(EEE), dashes=[2, 2, 2, 2], color='black', label=f'thick stripes')
plt.plot(np.arange(300)[::20], np.log10(EEEEEEE)[:300][::20], '.', dashes=[10, 0, 10, 0], color='black', label=f'parallel GaBP')
plt.plot(np.arange(300)[::10], np.log10(EEEEE)[:300][::10], 'x', color='black', label=f'GS')
plt.plot(np.log10(EEEEEE)[(np.log10(EEEEEE)>np.log10(h**2))], color='black', label=f'BiCGSTAB')
plt.plot(np.arange(250)[::20], np.log10(EEEE)[:250][::20], 'x', dashes=[10, 0, 10, 0], color='black', label=f'sequential GaBP')
plt.legend(loc=1)
plt.grid(1)
ax.set_ylim(-3.5, 6)
plt.ylabel('$\\log_{10}\\left\\|Ax - b\\right\\|_{\\infty}$')
plt.xlabel('iteration');
plt.savefig('convergence_alpha_50.eps', format='eps', pad_inches=0.01)
```

```python
np.where(np.array(EEEEE)>1e-3)[0][-1]
```

    1497

```python
np.where(np.array(EEEE)>1e-3)[0][-1]
```

    265


# Laboratório 13: Modelo Predador-Presa

### Referente ao capítulo 22

Vamos estudar um simples modelo predador-presa com uma restrição isoperimétrica. Considere o modelo de Lotka-Volterra

\begin{gather}
    N_1'(t) = (\alpha_1 - \beta_1 N_2(t))N_1(t), N_1(0) = N_{10} \\
    N_2'(t) = (\beta_2 N_1(t) - \alpha_2)N_2(t), N_2(0) = N_{20},
\end{gather}

onde $N_1$ é a população presa e $N_2$ é a população predadora. Sejam $\alpha_1, \alpha_2, \beta_1, \beta_2 > 0$. Queremos estudar uma situação em que a presa age como peste e queremos reduzir essa população com uso de um agente químico ou biológico. Gostaríamos que o pesticida utilizado afetasse apenas as pestes, não deixasse resíduo e matasse dependente da densidade. Vamos supor os últimos dois itens, apesar de saber que são irrealistas.

Seja $u(t)$ a taxa de aplicação de um pesticida que mate peste e predador. Assim, se $d_1$ e $d_2$ os parâmetros de densidade,

\begin{gather}
    N_1'(t) = (\alpha_1 - \beta_1 N_2(t))N_1(t) - d_1N_1(t)u(t), N_1(0) = N_{10} > 0\\
    N_2'(t) = (\beta_2 N_1(t) - \alpha_2)N_2(t) - d_2N_2(t)u(t), N_2(0) = N_{20} > 0.
\end{gather}

Nosso objetivo é

$$
\min_u N_1(T) + \frac{A}{2}\int_0^T u(t)^2 dt,
$$

e que tenhamos as restrições

$$
0 \le u(t) \le M, \int_0^T u(t) dt = B,
$$

isto é, temos restrição ambiental ou econômica que limita o uso de pesticida.

Vamos supor $\alpha_1 = \alpha_2 = \beta_1 = \beta_2 = 1$ por simplicidade.

Note que devemos ter $B = \int_0 ^T u(t) dt \le \int_0^T M dt = MT$. Também observe que a integral de $u^2$ deve ser minimizada, enquanto a integral de $u$ é fixa. Este funcional objetivo penaliza valores excessivamente grandes e encoraja valores pequeno mais distribuídos. Além disso, por motivos práticos isso facilita as contas.

Para resolver o problema numericamente, seja a variável $z(t)$ com $z' = u, z(0) = 0, z(T) = B$. O método forward-backward sweep adaptado pode resolver esse tipo de problema, com apenas um valor fixado. A escolha dos valores iniciais $x_0$ e $x_1$ devem ser consideradas de forma a acelerar a convergência. Por isso, alguns testes são importantes.

```python
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp
from scipy.optimize import root_scalar
import sympy as sp

import sys
sys.path.insert(0, '../pyscripts/')

from optimal_control_class import OptimalControl
```

## Condições necessárias

Mais uma vez vamos utilizar a biblioteca simbólica para nos ajudar nos cálculos. Note que esse sistema é tão simples que de fato não seria muito necessário, mas vamos usar assim mesmo.

### Hamiltoniano

```python
N1, N2, z, u, l1, l2, l3, d1, d2, A = sp.symbols('N_1 N_2 z u lambda_1 lambda_2 lambda_3 d_1 d_2 A')
H = 0.5*A*u**2 + l1*((1 - N2)*N1 - d1*N1*u) + l2*((N1 - 1)*N2 - d2*N2*u) + l3*u
H
```

$\displaystyle 0.5 A u^{2} + \lambda_{1} \left(- N_{1} d_{1} u + N_{1} \left(1 - N_{2}\right)\right) + \lambda_{2} \left(- N_{2} d_{2} u + N_{2} \left(N_{1} - 1\right)\right) + \lambda_{3} u$

### Condições do Estado

```python
print(sp.diff(H,l1))
print(sp.diff(H,l2))
print(sp.diff(H,l3))
```

    -N_1*d_1*u + N_1*(1 - N_2)
    -N_2*d_2*u + N_2*(N_1 - 1)
    u

### Equaçãoes Adjuntas

```python
dl1 = (-1)*sp.diff(H,N1)
dl2 = (-1)*sp.diff(H,N2)
dl3 = (-1)*sp.diff(H,z)
print(dl1)
print(dl2)
print(dl3)
```

    -N_2*lambda_2 - lambda_1*(-N_2 - d_1*u + 1)
    N_1*lambda_1 - lambda_2*(N_1 - d_2*u - 1)
    0

### Transversalidade

$\lambda_1(T) = \phi_{N_1} = 1$

$\lambda_2(T) = \phi_{N_2} = 0$

$\lambda_3(T)$ livre

### Otimalidade

```python
psi = sp.diff(H,u)
psi
```

$\displaystyle 1.0 A u - N_{1} d_{1} \lambda_{1} - N_{2} d_{2} \lambda_{2} + \lambda_{3}$

Como temos um problema de minimização, temos que

$$
H_u > 0 \implies u^*(t) = 0 \implies \frac{1}{A}(N_1 d_1 \lambda_1 + N_2 d_2 \lambda_2 - \lambda_3) < 0
$$
$$
H_u < 0 \implies u^*(t) = M \implies \frac{1}{A}(N_1 d_1 \lambda_1 + N_2 d_2 \lambda_2 - \lambda_3) > M
$$
$$
H_u = 0 \implies 0 \le u^*(t) = \frac{1}{A}(N_1 d_1 \lambda_1 + N_2 d_2 \lambda_2 - \lambda_3) \le M
$$

Portanto $u^*(t) = \min\{M, \max\{0, \frac{1}{A}(N_1 d_1 \lambda_1 + N_2 d_2 \lambda_2 - \lambda_3) \}\}$

## Função de Plot

```python
def plotting(t, x, u, ax = None, label = ''):

    if not ax:
        fig, ax = plt.subplots(3,1, figsize = (10, 8), sharex = True)

        ax[0].set_ylabel('Presa')
        ax[1].set_ylabel('Predador')
        ax[2].set_ylabel('Controle')

        for i in range(3):
            ax[i].grid(alpha = 0.5)

    ax[0].plot(t, x[:,0], label = label)
    ax[1].plot(t, x[:,1], label = label)
    ax[2].plot(t, u, label = label)

    return fig, ax
```

## Aplicando a classe ao exemplo

Nesse exemplo temos uma dificuldade adicional. Temos um termo payoff linear, que pode ser lidado com o algoritmo até então, mas temos o tempo final de $z$ fixado. O algoritmo forward backward não lida com esse problema diretamente, então precisamos fazer a conexão com o método secante através da função $V$. Para isso vamos usar algumas facilitações que a classe nos dá. Podemos passar os valores de $theta$ através de uma lista de parâmetros e também podemos passar os estados com essas condições diferentes.

Primeiro definimos os parâmetros do problema. Como $\lambda_3(T)$ é livre, colocamos que ele é `np.nan`, mas na verdade temos que lidar com esse problema utilizando o parâmetro `theta_list`.

```python
parameters = {'d1': None, 'd2': None, 'M': None, 'A': None, 'B': None}

diff_state = lambda t, x, u, par: np.array([
    -x[0]*par['d1']*u[0] + x[0]*(1 - x[1]),
    -x[1]*par['d2']*u[0] + x[1]*(x[0] - 1),
    u[0]
])

diff_lambda = lambda t, x, u, lambda_, par: np.array([
    -x[1]*lambda_[1] - lambda_[0]*(-x[1] - par['d1']*u[0] + 1),
    x[0]*lambda_[0] - lambda_[1]*(x[0] - par['d2']*u[0] - 1),
    0
])

update_u = lambda t, x, l, par: np.minimum(par['M'], \
        np.maximum(0, (1/par['A'])*(x[0]*par['d1']*l[0] + x[1]*par['d2']*l[1] - l[2])))

diff_phi = lambda x, par: np.array([1, 0, np.nan])
```

Vamos definir nossa função $V$. Observe que `free_adj_final` é uma lista com as funções adjuntas sem valor final definido pelas condições necessárias.

```python
problem = OptimalControl(diff_state, diff_lambda, update_u,
                         n_controls = 1, n_states = 3, diff_phi = diff_phi,
                         free_adj_final = [2])

def V(theta, x0, T, par):
    _,x,_,_ = problem.solve(x0, T, par, bounds = [(0, par['M'])], theta_list = [theta])
    z = x[:,2]
    return z[-1] - par['B']
```

Por fim podemos definir a função que encontra as raízes da função $V$. Vamos definir $a$ e $b$ como os valores iniciais da sequência para o método de encontrar raízes.

```python
f_theta = lambda x0, T, par, a, b: root_scalar(f = V,
                                               args = (x0,T,par),
                                               method = 'secant',
                                               x0 = a,
                                               x1 = b,
                                               )
```

Com essas definições, podemos aplicar ao problema.

```python
x0 =  np.array([10, 1, 0])
T = 10
parameters['d1'] = 0.1
parameters['d2'] = 0.1
parameters['M'] = 1
parameters['A'] = 1
parameters['B'] = 5
a = -0.52
b = -0.5
```

```python
theta = f_theta(x0, T,parameters,a,b).root
t,x,u,_ = problem.solve(x0, T, parameters,
                              bounds = [(0, parameters['M'])], theta_list = [theta])
fig, ax = plotting(t, x, u)
```

Vemos que nessa simulação, presa e predator são praticamente eliminadas. Inicialmente a população de predador aumenta, mas conforme o regime de pesticida cresce, a população de presa cai, levando os predadores da mesma forma. Claro que nesse caso a taxa com que o pesticida mata predador e presa é a mesma ($d_1 = d_2$), o que não é realista. Assim seja:

```python
x0 =  np.array([5, 2, 0])
T = 5
parameters['d1'] = 0.1
parameters['d2'] = 0.01
parameters['M'] = 1
parameters['A'] = 1
parameters['B'] = 1
a = -0.2
b = -0.18

theta = f_theta(x0, T,parameters,a,b).root
t,x,u,_ = problem.solve(x0, T, parameters,
                              bounds = [(0, parameters['M'])], theta_list = [theta])
fig, ax = plotting(t, x, u)
```

Mesmo tendo mudado alguns valores, o resultado não mudou muito. O regime de pesticida é menor e ainda se torna praticamente constante ao longo do trajeto. Vamos agora variar o valor de $A$, o que pondera o efeito negativo do pesticida. Mudar o valor de $A$ não altera a quantidade de pesticida utilizado, mas sim sua distribuição. Isso faz com que

```python
parameters['A'] = 10
theta = f_theta(x0, T,parameters,a,b).root
t,x,u,_ = problem.solve(x0, T, parameters,
                              bounds = [(0, parameters['M'])], theta_list = [theta])
fig, ax = plotting(t, x, u)
```

Percebemos que o controle fica mais constante, enquanto as populações tem seus valores inalterados. Na verdade, variando outros parâmetros, continuamos com esse problema de variar apenas o controle, mas manter os estados essencialmente iguais. Vamos variar o valor de $T$ com essa combinação de parâmetros para encerrar. Percebemos que o uso de pesticida precisa ser mais espalhado no tempo, o que benificia um segundo pico da presa que leva consifo o pico do predador, que vem sempre após.

```python
T = 10
theta = f_theta(x0, T,parameters,a,b).root
t,x,u,_ = problem.solve(x0, T, parameters,
                              bounds = [(0, parameters['M'])], theta_list = [theta])
fig, ax = plotting(t, x, u)
```

### Este é o final do notebook


# 13 Linear Algebra: String Problem
## Motivating problem: Two masses on three strings
Two masses $M_1$ and $M_2$ are hung from a horizontal rod with length $L$ in such a way that a rope of length $L_1$ connects the left end of the rod to $M_1$, a rope of length $L_2$ connects $M_1$ and $M_2$, and a rope of length $L_3$ connects $M_2$ to the right end of the rod. The system is at rest (in equilibrium under gravity).

Find the angles that the ropes make with the rod and the tension forces in the ropes.

## Theoretical background
Treat $\sin\theta_i$ and $\cos\theta_i$ together with $T_i$, $1\leq i \leq 3$, as unknowns that have to simultaneously fulfill the nine equations
\begin{align}
-T_1 \cos\theta_1 + T_2\cos\theta_2 &= 0\\
 T_1 \sin\theta_1 - T_2\sin\theta_2 - W_1 &= 0\\
                   -T_2\cos\theta_2 + T_3\cos\theta_3 &= 0\\
                    T_2\sin\theta_2 + T_3\sin\theta_3 - W_2 &= 0\\
 L_1\cos\theta_1 + L_2\cos\theta_2 + L_3\cos\theta_3 - L &= 0\\
-L_1\sin\theta_1 -L_2\sin\theta_2 + L_3\sin\theta_3  &= 0\\
\sin^2\theta_1 + \cos^2\theta_1 - 1 &= 0\\
\sin^2\theta_2 + \cos^2\theta_2 - 1 &= 0\\
\sin^2\theta_3 + \cos^2\theta_3 - 1 &= 0
\end{align}

Consider the nine equations a vector function $\mathbf{f}$ that takes a 9-vector $\mathbf{x}$ of the unknowns as argument:
\begin{align}
\mathbf{f}(\mathbf{x}) &= 0\\
\mathbf{x} &= \left(\begin{array}{c}
x_0 \\ x_1 \\ x_2 \\
x_3 \\ x_4 \\ x_5 \\
x_6 \\ x_7 \\ x_8
\end{array}\right) =
\left(\begin{array}{c}
\sin\theta_1 \\ \sin\theta_2 \\ \sin\theta_3 \\
\cos\theta_1 \\ \cos\theta_2 \\ \cos\theta_3 \\
T_1 \\ T_2 \\ T_3
\end{array}\right) \\
\mathbf{L} &= \left(\begin{array}{c}
L \\ L_1 \\ L_2 \\ L_3
\end{array}\right), \quad
\mathbf{W} = \left(\begin{array}{c}
W_1 \\ W_2
\end{array}\right)
\end{align}

Using the unknowns from above, our system of 9 coupled equations is:

\begin{align}
-x_6 x_3 + x_7 x_4 &= 0\\
 x_6 x_0 - x_7 x_1 - W_1 &= 0\\
-x_7x_4 + x_8 x_5 &= 0\\
 x_7x_1 + x_8 x_2 - W_2 &= 0\\
 L_1x_3 + L_2 x_4 + L_3 x_5 - L &= 0\\
-L_1x_0 - L_2 x_1 + L_3 x_2  &= 0\\
x_{0}^{2} + x_{3}^{2} - 1 &= 0\\
x_{1}^{2} + x_{4}^{2} - 1 &= 0\\
x_{2}^{2} + x_{5}^{2} - 1 &= 0
\end{align}

Solve the root-finding problem $\mathbf{f}(\mathbf{x}) = 0$ with the **generalized Newton-Raphson** algorithm:

$$
\mathsf{J}(\mathbf{x}) \Delta\mathbf{x} = -\mathbf{f}(\mathbf{x})
$$

and

$$
\mathbf{x} \leftarrow \mathbf{x} + \Delta\mathbf{x}.
$$

## Problem setup
Set the problem parameters and the objective function $\mathbf{f}(\mathbf{x})$

```python
import numpy as np

# problem parameters
W = np.array([10, 20])
L = np.array([8, 3, 4, 4])

def f_2masses(x, L, W):
    return np.array([
            -x[6]*x[3] + x[7]*x[4],
             x[6]*x[0] - x[7]*x[1] - W[0],
            -x[7]*x[4] + x[8]*x[5],
             x[7]*x[1] + x[8]*x[2] - W[1],
             L[1]*x[3] + L[2]*x[4] + L[3]*x[5] - L[0],
            -L[1]*x[0] - L[2]*x[1] + L[3]*x[2],
            x[0]**2 + x[3]**2 - 1,
            x[1]**2 + x[4]**2 - 1,
            x[2]**2 + x[5]**2 - 1,
        ])

def fLW(x, L=L, W=W):
    return f_2masses(x, L, W)
```

### Initial values
Guess some initial values (they don't have to fullfil the equations!):

```python
# initial parameters
#theta0 = np.deg2rad([45, 45, 90])
#T0 = np.array([1, 1, 2])
#x0 = np.concatenate([np.sin(theta0), np.cos(theta0), T0])

x0 = np.array([1.5, 0.5, 0.5, 0.5, 0.5, 0.5, 1, 1, 1])
```

```python
x0
```

    array([1.5, 0.5, 0.5, 0.5, 0.5, 0.5, 1. , 1. , 1. ])

```python
f_2masses(x0, L, W)
```

    array([  0. ,  -9. ,   0. , -19. ,  -2.5,  -4.5,   1.5,  -0.5,  -0.5])

### Visualization
Plot the positions of the 2 masses and the 3 strings for any solution vector $\mathbf{x}$:

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

```python
def plot_2masses(x, L, W, **kwargs):
    """Plot 2 mass/3 string problem for parameter vector x and parameters L and W"""

    kwargs.setdefault('linestyle', '-')
    kwargs.setdefault('marker', 'o')
    kwargs.setdefault('linewidth', 1)

    ax = kwargs.pop('ax', None)
    if ax is None:
        ax = plt.subplot(111)

    r0 = np.array([0, 0])
    r1 = r0 + np.array([L[0], 0])
    rod = np.transpose([r0, r1])

    L1 = r0 + np.array([L[1]*x[3], -L[1]*x[0]])
    L2 = L1 + np.array([L[2]*x[4], -L[2]*x[1]])
    L3 = L2 + np.array([L[3]*x[5], L[3]*x[2]])
    strings = np.transpose([r0, L1, L2, L3])

    ax.plot(rod[0], rod[1], color="black", marker="d", linewidth=4)
    ax.plot(strings[0], strings[1], **kwargs)
    ax.set_aspect(1)
    return ax
```

What does the initial guess look like?

```python
plot_2masses(x0, L, W)
```

## Jacobian
Write a function `Jacobian(f, x, h=1e-5)` that computes the Jacobian matrix numerically (use the central difference algorithm).

\begin{align}
\mathbf{J} &= \frac{\partial \mathbf{f}}{\partial\mathbf{x}} \\
J_{ij} &= \frac{\partial f_i(x_1, \dots, x_j, \dots)}{\partial x_j} \\
       &\approx \frac{f_i(x_1, \dots, x_j + \frac{h}{2}, \dots) - f_i(x_1, \dots, x_j - \frac{h}{2}, \dots)}{h}
\end{align}

```python
def Jacobian(f, x, h=1e-5):
    """df_i/dx_j with central difference (fi(xj+h/2)-fi(xj-h/2))/h"""
    J = np.zeros((len(f(x)), len(x)), dtype=np.float64)
    hvec = np.zeros_like(x, dtype=np.float64)
    for j in range(len(x)):
        hvec *= 0
        hvec[j] = 0.5*h
        J[:, j] = (f(x + hvec) - f(x - hvec))/h
    return J
```

Test `Jacobian()` on
$$
\mathbf{g}(\mathbf{x}) = \begin{pmatrix}
  x_0^2 - x_1 \\ x_0
  \end{pmatrix}
$$
with analytical result

$$
\mathsf{J} = \left[\frac{\partial g_i}{\partial x_j}\right] =
\begin{pmatrix}
  \frac{\partial g_0}{\partial x_0} & \frac{\partial g_0}{\partial x_1}\\
  \frac{\partial g_1}{\partial x_0} & \frac{\partial g_1}{\partial x_1}
\end{pmatrix}
=
\begin{pmatrix}
  2 x_0 & -1\\
  1 & 0
\end{pmatrix}
$$

Given a test vector $\mathbf{x}_\text{test} = (1, 0)$, what is the numerical answer for $\mathsf{J}(\mathbf{x}_\text{test})$?

$$
\mathsf{J}(\mathbf{x}_\text{test}) = \begin{pmatrix}
  2 & -1\\
  1 & 0
\end{pmatrix}
$$

Test your `Jacobian()` function with $\mathbf{x}_\text{test}$ and check that you get the same answer:

```python
def g(x):
    return np.array([
            x[0]**2 - x[1],
            x[0]
        ])
x_test = np.array([1, 0])
J = Jacobian(g, x_test)
print(J)
```

    [[ 2. -1.]
     [ 1.  0.]]

Test that it also works for our starting vector:

```python
J0 = Jacobian(fLW, x0)
J0
```

    array([[ 0. ,  0. ,  0. , -1. ,  1. ,  0. , -0.5,  0.5,  0. ],
           [ 1. , -1. ,  0. ,  0. ,  0. ,  0. ,  1.5, -0.5,  0. ],
           [ 0. ,  0. ,  0. ,  0. , -1. ,  1. ,  0. , -0.5,  0.5],
           [ 0. ,  1. ,  1. ,  0. ,  0. ,  0. ,  0. ,  0.5,  0.5],
           [ 0. ,  0. ,  0. ,  3. ,  4. ,  4. ,  0. ,  0. ,  0. ],
           [-3. , -4. ,  4. ,  0. ,  0. ,  0. ,  0. ,  0. ,  0. ],
           [ 3. ,  0. ,  0. ,  1. ,  0. ,  0. ,  0. ,  0. ,  0. ],
           [ 0. ,  1. ,  0. ,  0. ,  1. ,  0. ,  0. ,  0. ,  0. ],
           [ 0. ,  0. ,  1. ,  0. ,  0. ,  1. ,  0. ,  0. ,  0. ]])

```python
J0.shape
```

    (9, 9)

## n-D Newton-Raphson Root Finding
Write a function `newton_raphson(f, x, Nmax=100, tol=1e-8, h=1e-5)` to find a root for a vector function `f(x)=0`. (See also [12 Root-finding by trial-and-error](https://py4phy.github.io/PHY432/modules/root_finding/Root_finding/) and the _1D Newton-Raphson algorithm_ in [12-Root-finding.ipynb](https://github.com/Py4Phy/PHY432-resources/blob/main/12_root_finding/12-Root-finding.ipynb).) As a convergence criterion we demand that the length of the vector `f(x)` (the norm --- see `np.linalg.norm`) be less than the tolerance.

```python
def newton_raphson(f, x, Nmax=100, tol=1e-8, h=1e-5):
    """n-D Newton-Raphson: solves f(x) = 0.

    Iterate until |f(x)| < tol or nmax steps.
    """
    x = x.copy()
    for istep in range(Nmax):
        fx = f(x)
        if np.linalg.norm(fx) < tol:
            break
        J = Jacobian(f, x, h=h)
        Delta_x = np.linalg.solve(J, -fx)
        x += Delta_x
    else:
        print("Newton-Raphson: no root found after {0} iterations (eps={1}); "
            "best guess is {2} with error {3}".format(Nmax, tol, x, fx))
    return x
```

### Solve 2 masses/3 strings string problem

#### Solution

```python
x = newton_raphson(fLW, x0)
print(x0)
print(x)
```

    [1.5 0.5 0.5 0.5 0.5 0.5 1.  1.  1. ]
    [ 0.76100269  0.26495381  0.83570583  0.64874872  0.9642611   0.54917735
     17.16020978 11.54527968 20.27152804]

Plot the starting configuration and the solution:

```python
ax = plot_2masses(x0, L, W)
ax = plot_2masses(x, L, W, ax=ax)
```

Pretty-print the solution (angles in degrees):

```python
def pretty_print(x):
    theta = np.rad2deg(np.arcsin(x[0:3]))
    tensions = x[6:]
    print("theta1 = {0[0]:.1f} \t theta2 = {0[1]:.1f} \t theta3 = {0[2]:.1f}".format(theta))
    print("T1     = {0[0]:.1f} \t T2     = {0[1]:.1f} \t T3     = {0[2]:.1f}".format(tensions))
```

```python
print("Starting values")
pretty_print(x0)
print()
print("Solution")
pretty_print(x)
```

    Starting values
    theta1 = nan 	 theta2 = 30.0 	 theta3 = 30.0
    T1     = 1.0 	 T2     = 1.0 	 T3     = 1.0

    Solution
    theta1 = 49.6 	 theta2 = 15.4 	 theta3 = 56.7
    T1     = 17.2 	 T2     = 11.5 	 T3     = 20.3

    /var/folders/sm/37rm_wm16tq9qsf4n5md98ph0000gp/T/ipykernel_97127/3554523550.py:2: RuntimeWarning: invalid value encountered in arcsin
      theta = np.rad2deg(np.arcsin(x[0:3]))

#### Show intermediate steps
Create a new function `newton_raphson_intermediates()` based on `newton_raphson()` that returns *all* trial `x` values including the last one.

```python
def newton_raphson_intermediates(f, x, Nmax=100, tol=1e-8, h=1e-5):
    """n-D Newton-Raphson: solves f(x) = 0.

    Iterate until |f(x)| < tol or nmax steps.

    Returns all intermediates.
    """
    intermediates = []
    x = x.copy()
    for istep in range(Nmax):
        fx = f(x)
        if np.linalg.norm(fx) < tol:
            break
        J = Jacobian(f, x, h=h)
        Delta_x = np.linalg.solve(J, -fx)
        intermediates.append(x.copy())
        x += Delta_x
    else:
        print("Newton-Raphson: no root found after {0} iterations (eps={1}); "
            "best guess is {2} with error {3}".format(Nmax, tol, x, fx))
    return np.array(intermediates)
```

Visualize the intermediate configurations:

```python
x_series = newton_raphson_intermediates(fLW, x0)
```

```python
ax = plt.subplot(111)
ax.set_prop_cycle("color", [plt.cm.viridis_r(i) for i in np.linspace(0, 1, len(x_series))])
for iteration, x in enumerate(x_series):
    plot_2masses(x, L, W, label=str(iteration), ax=ax)
ax.legend(loc="best");
```

It's convenient to turn the above plotting code into a function that we can reuse:

```python
def plot_series(x_series, L, W):
    """Plot all N masses/strings solution vectors in x_series (N, 9) array"""
    ax = plt.subplot(111)
    ax.set_prop_cycle("color", [plt.cm.viridis_r(i) for i in np.linspace(0, 1, len(x_series))])
    for iteration, x in enumerate(x_series):
        plot_2masses(x, L, W, label=str(iteration), ax=ax)
    ax.legend(loc="best")
    return ax
```

## Additional work
Try different masses, e.g. M1 = M2 = 10, or M1= 0 , M2 = 10.

Use nicer starting parameters that already fulfill the angle equations (7) - (9) (but it works with pretty much any guess):

```python
# initial parameters
theta0 = np.deg2rad([45, 45, 90])
T0 = np.array([1, 1, 2])
x0 = np.concatenate([np.sin(theta0), np.cos(theta0), T0])
```

### M1 = M2 = 10

```python
W_2 = np.array([10, 10])
def fLW_2(x, L=L, W=W_2):
    return f_2masses(x, L, W)
```

```python
x_series_2 = newton_raphson_intermediates(fLW_2, x0)
pretty_print(x_series_2[-1])
```

    theta1 = 57.9 	 theta2 = 8.8 	 theta3 = 52.1
    T1     = 13.1 	 T2     = 7.0 	 T3     = 11.3

```python
plot_series(x_series_2, L, W_2)
```

### M1 = 0, M2 = 10

```python
W_3 = np.array([0, 10])
def fLW_3(x):
    return f_2masses(x, L=L, W=W_3)
```

```python
x_series_3 = newton_raphson_intermediates(fLW_3, x0)
pretty_print(x_series_3[-1])
```

    theta1 = 30.0 	 theta2 = 30.0 	 theta3 = 61.0
    T1     = 4.8 	 T2     = 4.8 	 T3     = 8.7

```python
plot_series(x_series_3, L, W_3)
```


```python
import sympy as sp
```

```python
v0,l,x,y = sp.symbols('V_0 L x y')
```

```python
eq1 = sp.Eq(x**2/(l/(2*v0))**2+(y-v0)**2,v0**2)
eq1
```

$\displaystyle \left(- V_{0} + y\right)^{2} + \frac{4 V_{0}^{2} x^{2}}{L^{2}} = V_{0}^{2}$

```python
eq = sp.solve(eq1,y)[0]
sp.simplify(eq)
```

$\displaystyle \frac{V_{0} \left(L - \sqrt{L^{2} - 4 x^{2}}\right)}{L}$

```python

```


# We show the basic functionalities of the differential drive vehicle model

```python
from dymoesco.dynamics.diffdrive import DiffDrive
from dymoesco.control.misc_controllers import generate_smooth_control
from dymoesco.estimation.filters import EKF, calc_jacobian
import numpy as np
from autograd import jacobian
import matplotlib.pyplot as plt
import matplotlib
%load_ext autoreload
%autoreload 2
```

    The autoreload extension is already loaded. To reload it, use:
      %reload_ext autoreload

```python
# Parameters
t_span = (0,25)
dt = 0.1
t = np.arange(*t_span, dt)

umax = np.array([2,5])
umin = np.array([1,-5])
u_std = np.array([0.5, 0.25])

rng = np.random.default_rng(0)
```

### We first look at the model analytically

The continuous time dynamics are
\begin{align}
\dot{x} &= v\cos(\theta) \\
\dot{y} &= v\cos(\theta) \\
\dot{\theta} &= w/R
\end{align}

which evolve on SE(2), where $(x,y)$ is the robot's position and $\theta$ it's angle. The inputs are forward velocity $v$ and angular velocity $w$, and $R$ is the radius of the wheels.

We discretize the continuous time dynamics to get
\begin{align}
x_{k+1} &= x_k + v_k dt \cos\theta_k \\
y_{k+1} &= y_k + v_k dt \sin\theta_k \\
\theta_{k+1} &= \theta_k + w_k dt
\end{align}

For the EKF, we need to linearize these dynamics around $\hat{x}_k$, $\hat{y}_k$, $\hat{\theta}_k$:
\begin{align}
x_{k+1} &= x_k
             + v_k dt \left(\cos\hat{\theta}_k - \sin\hat{\theta}_k (\theta_k - \hat{\theta}_k) \right) \\
        &= x_k
             - v_k dt \sin(\hat{\theta}_k) \theta_k
             + \left( \cos\hat{\theta}_k - \hat{\theta}_k \sin\hat{\theta}_k  \right) dt \, v_k \\
y_{k+1} &= y_k dt
             + v_k \left(\sin\hat{\theta}_k + \cos\hat{\theta}_k(\theta_k - \hat{\theta}_k)\right) \\
        &= y_k
             + v_k dt \cos(\hat{\theta}_k) \theta_k
             + \left( \sin\hat{\theta}_k - \hat{\theta}_k \cos\hat{\theta}_k \right) dt \, v_k \\
\theta_{k+1} &= \theta_k + dt \, w_k
\end{align}

which we can write concisely as
$$
\begin{bmatrix} x_{k+1} \\ y_{k+1} \\ \theta_{k+1} \end{bmatrix}
=
\underbrace{\begin{bmatrix} 1 & & -v_k dt \sin \hat{\theta}_k \\
                  & 1 & v_k dt \cos \hat{\theta}_k \\
                  & & 1
\end{bmatrix}}_A
\begin{bmatrix} x_k \\ y_k \\ \theta_k \end{bmatrix}
+
\underbrace{\begin{bmatrix} \left(\cos\hat{\theta}_k - \hat{\theta}_k \sin\hat{\theta}_k \right) dt & 0 \\
                \left(\sin\hat{\theta}_k - \hat{\theta}_k \cos\hat{\theta}_k \right) dt & 0 \\
                0 & dt
\end{bmatrix}}_B
\begin{bmatrix} v_k \\ w_k \end{bmatrix}
$$

```python
# Differential Drive Model
dd = DiffDrive(radius=1)
ddd = dd.discretize(dt=0.05)
```

```python
# We can use autograd to calculate A and B numerically at specific x and u values
# EKF does this for us.
x = np.array([0.,0.,0.])
u = np.array([1.,0.])
print("A:")
print(jacobian(ddd._f)(x, u))
print("B:")
print(jacobian(ddd._f,1)(x, u))
```

    A:
    [[1.   0.   0.  ]
     [0.   1.   0.05]
     [0.   0.   1.  ]]
    B:
    [[0.05 0.  ]
     [0.   0.  ]
     [0.   0.05]]

Now assuming our controls are noisy,
$$v = \hat{v} + \epsilon_v \\
  w = \hat{w} + \epsilon_w$$
where $(\hat{v},\hat{w})$ are the nominal controls sent to the robot and $\epsilon_v \sim N(0, \sigma_v)$ and $\epsilon_w \sim N(0, \sigma_w)$.

The dynamics above now look like

\begin{align}
\begin{bmatrix} x_{k+1} \\ y_{k+1} \\ \theta_{k+1} \end{bmatrix}
&=
A
\begin{bmatrix} x_k \\ y_k \\ \theta_k \end{bmatrix}
+
B
\begin{bmatrix} \hat{v}_k + \epsilon_v \\ \hat{w}_k + \epsilon_w \end{bmatrix} \\
&=
A
\begin{bmatrix} x_k \\ y_k \\ \theta_k \end{bmatrix}
+
B
\begin{bmatrix} \hat{v}_k \\ \hat{w}_k \end{bmatrix} + \epsilon_k
\end{align}

where $\epsilon_k \sim N(0, Q)$ and
\begin{align}
Q &= B \begin{pmatrix} \sigma_v^2 & 0 \\ 0 & \sigma_w^2 \end{pmatrix} B^T \\
  & = \begin{pmatrix} \sigma_v^2 \left( \cos\hat\theta_k - \hat\theta_k \sin\hat{\theta}_k  \right)^2 dt^2  &
                   \sigma_v^2 \left( \cos\hat\theta_k - \hat\theta_k \sin\hat{\theta}_k  \right)
                   \left(\sin\hat{\theta}_k + \cos\hat{\theta}_k(\theta_k - \hat{\theta}_k)\right) dt^2 & 0 \\
       \sigma_v^2 \left( \cos\hat\theta_k - \hat\theta_k \sin\hat{\theta}_k  \right)
                   \left(\sin\hat{\theta}_k + \cos\hat{\theta}_k(\theta_k - \hat{\theta}_k)\right) dt^2 &
                   \sigma_v^2 \left(\sin\hat{\theta}_k + \cos\hat{\theta}_k(\theta_k - \hat{\theta}_k)\right)^2 dt^2 & 0 \\
        0 & 0 & \sigma_w^2 dt^2
      \end{pmatrix}
\end{align}

Normally we would have to enter this by hand, but once again autograd can compute B numerically and so compute this matrix (which changes at every timestep!) for us.

```python
# We can also compute the jacobian of the observation
jacobian(dd._g)(np.zeros(3))
```

    array([[nan, nan,  0.],
           [nan, nan,  0.]])

```python
import autograd.numpy as np
def _g(z):
    if np.linalg.norm(z[:2]) < 1:
        if np.linalg.norm(z[:2]) < 1e-10:
            range_ = 0.0
        else:
            range_ = np.linalg.norm(z[:2])
        bearing = np.arctan2(z[1],z[0])
        return np.array([range_, bearing])
    return None
calc_jacobian(_g, np.array([0.5,0.2,1]))
```

    array([[ 0.92860454,  0.37219054,  0.        ],
           [-0.68846805,  1.72294798,  0.        ]])

### And then generate a trajectory from it following a random control input

```python
# random control sequence
u = generate_smooth_control(umin, umax, t_span, dt, rng = rng)
# Generate trajectory
traj = dd.simulate(u, t_span, [0,0,0], np.arange(*t_span,dt))
```

```python
fig, (ax1, ax2) = plt.subplots(1,2)
dd.plotu(traj, ax=ax1)
dd.plotx(traj, ax=ax2)
```

```python
%matplotlib notebook
ani = dd.animate(traj)
```

    <IPython.core.display.Javascript object>

### And finally filter this trajectory using an EKF

```python
v_std = 0.5
w_std = 0.25
u_std = np.array([v_std, w_std])
y_std = 0.1
R = y_std * np.eye(3)
EKF = ddd.make_EKF(u_std, R)
```

```python
x0 = [0., 0., 0.]
P0 = np.eye(3)
u0 = [1., 0.]
EKF.predict(x0, P0, u0)
```

    (array([0.05, 0.  , 0.  ]),
     array([[1.000625  , 0.        , 0.        ],
            [0.        , 1.0025    , 0.05      ],
            [0.        , 0.05      , 1.00015625]]))

```python
np.arctan2(-3,0)
```

    -1.5707963267948966

```python

```

    0.7853981633974483

```python

```


# astGl - Uebung 4

## Aufgabe 1

```python
%matplotlib notebook
from sympy import *
import matplotlib.pyplot as plt
from IPython.display import display, Math, Latex
def disp(str):
    display(Latex(str))
```

```python
G1,G2,G = symbols('G1,G2,G')
eqg = [
    (G1, G),
    (G2, G)
]
eqg
```

    [(G1, G), (G2, G)]

```python
wu,wp2,s,A = symbols('wu,wp2,s,A')
eqop = [
    (wp2, wu/4),
    (A, wu/s*wp2/(s+wp2))
]
eqop
```

    [(wp2, wu/4), (A, wp2*wu/(s*(s + wp2)))]

```python
eqT = -G1*A*(G1+G2)/(1+G2*A*(G1+G2))
eqT
```

$\displaystyle - \frac{A G_{1} \left(G_{1} + G_{2}\right)}{A G_{2} \left(G_{1} + G_{2}\right) + 1}$

```python
eqT.subs(eqg)
```

$\displaystyle - \frac{2 A G^{2}}{2 A G^{2} + 1}$

```python
eqT.subs(eqop).simplify()
```

$\displaystyle - \frac{G_{1} \wp_{2} wu \left(G_{1} + G_{2}\right)}{G_{2} \wp_{2} wu \left(G_{1} + G_{2}\right) + s \left(s + \wp_{2}\right)}$

```python

```


# Linear Programming

$$\mathbf{x} = arg\;\underset{\mathbf{x}}{min}\;c^T\mathbf{x}$$
subject to
$$A\mathbf{x} = b$$
$$\mathbf{x} \succeq 0$$

**Programming**

$$(x, y)  = arg\;\underset{x,y}{max} \;5x + 3y$$
subject to
$$x + y \leq 10$$
$$2x + y \leq 16$$
$$x + 4y \leq 32$$
$$x,y \geq 0$$

The constraints can be re-written under formula: $Gx \preceq h$, where:
$$G=\begin{bmatrix}
1 & 1 \\
2 &  1 \\
1 & 4  \\
-1 & 0 \\
0 & -1 \\
\end{bmatrix}, \;h = \begin{bmatrix}
10 \\
16 \\
32 \\
0 \\
0
\end{bmatrix}$$

**CVXOPT Solution:**

```python
from cvxopt import matrix, solvers
c = matrix([-5., -3.])
G = matrix([[1., 2., 1., -1., 0.], [1., 1., 4., 0., -1.]])
h = matrix([10., 16., 32., 0., 0.])

solvers.options['show_progress'] = False
sol = solvers.lp(c, G, h)

print('Solution"')
print(sol['x'])
```

    Solution"
    [ 6.00e+00]
    [ 4.00e+00]

# Quadratic Programming (QP)

**General formula**

\begin{equation}
\mathbf{x} = arg \underset{\mathbf{x}}{min} \frac{1}{2}\mathbf{x}^T\mathbf{P}\mathbf{x}+\mathbf{q}^T\mathbf{x}+\mathbf{r}
\end{equation}
subject to
$$\mathbf{G}\mathbf{x} \preceq \mathbf{h}$$
$$\mathbf{A}\mathbf{x}=\mathbf{b}$$

where $P \in S_+^n$ (set of square matrices $\succeq 0$ with $n$ columns), $G \in \mathbb{R}^{m\times n}$, $A \in \mathbb{R}^{p\times n}$

### QP Example

$$(x, y) = arg \;\underset{x,y}{min} \;{(x-10)}^2 + {(y-10)}^2$$
subject to
$$\begin{bmatrix}
1 & 1 \\
2 &  1 \\
1 & 4  \\
-1 & 0 \\
0 & -1 \\
\end{bmatrix}\begin{bmatrix}
x\\
y
\end{bmatrix}
\preceq \begin{bmatrix}
10\\
16\\
32\\
0\\
0
\end{bmatrix}$$

_Example of distance from a point to a polyhedron_

```python
from cvxopt import matrix, solvers
P = matrix([[1., 0.], [0., 1.]])
q = matrix([-10., -10.])
G = matrix([[1., 2., 1., -1., 0.], [1., 1., 4., 0., -1.]])
h = matrix([10., 16., 32., 0., 0])

solvers.options['show_progress'] = False
sol = solvers.qp(P, q, G, h)

print('Solution:')
print(sol['x'])
```

    Solution:
    [ 5.00e+00]
    [ 5.00e+00]

# Geometric Programming

$$x = arg\;\underset{x}{min}\;f_0(x)$$
subject to
$$f_i(x)\leq1, \;i=1,2,...,m$$
$$h_j(x) = 1, \;j=1,2,...,p$$

where $f_0, f_1,.., f_m$ are posynomials and $h_1,...,h_p$ are monomials

**Monomial function**
$f:\Re^n \rightarrow \Re$ with definite set $dom\;f=\Re^n_{++}$ has formula:
$$f(x) = cx_1^{a_1}x_2^{a_2}...x_n^{a_n}$$
with $c > 0$ and $a_i\in\Re$

**Posynomial function**: sum of monomials
$$f(x) = \sum_{k=1}^{K}c_kx_1^{a_{1k}}x_2^{a_{2k}}...x_n^{a_{nk}}$$
with $c_k > 0$

```python
from cvxopt import matrix, solvers
from math import log, exp# gp
from numpy import array
import numpy as np

K = [4]
F = matrix([[-1., 1., 1., 0.],
            [-1., 1., 0., 1.],
            [-1., 0., 1., 1.]])
g = matrix([log(40.), log(2.), log(2.), log(2.)])
solvers.options['show_progress'] = False
sol = solvers.gp(K, F, g)

print('Solution:')
print(np.exp(np.array(sol['x'])))

print('\nchecking sol^5')
print(np.exp(np.array(sol['x']))**5)
```

    Solution:
    [[1.58489319]
     [1.58489319]
     [1.58489319]]

    checking sol^5
    [[9.9999998]
     [9.9999998]
     [9.9999998]]

```python

```


Водопьян А.О. Хабибуллин Р.А. 2019 г.

## Давление насыщения нефти газом

### Давление насыщения, корреляция Стендинга

Корреляция Стендинга для оценки давления насыщения нефти газом.

$$ P_b =  0.5197  \left( \frac{R_{sb}}{\gamma_g}\right)^{0.83} 10 ^{y_g} $$

где

$P_b$ - давление насыщения, $МПа$

$R_{sb}$ - газосодержание при давлении насыщения, $м^3/м^3 $

$\gamma_g$ - относительная плотность газа, безразмерная величина

$y_g$ - мольная доля газа, $ y_g = 1.225 +0.00164 T - \frac{ 1.769}{\gamma_o}$

$\gamma_o$ - относительная плотность нефти, безразмерная величина

$ T $ - температура, $ ^{\circ}\mathrm{K}$

Корреляции Standing базируются на 105 экспериментально определенных давлениях насыщения нефтяных систем Калифорнии. Диапазоны значений основных свойств, использованных для разработки данной корреляции, приведены в таблице ниже.

|
 Параметр  | Диапазон |
| :--- | :--- |
|
 давление насыщения,$P_b$ , $ МПа $  | 0.896…48.263  |
|
 температура, $^{\circ}\mathrm{K} $ | 310…400 |
|
 газосодержание при давлении насыщения, $R_{sb}$ , $м^3/м^3 $ | 3.6…254 |
|
 относительная плотность нефти по воде, $\gamma_o$ | 0.725…0.956  |
|
 относительная плотность газа, $\gamma_g$  | 0.59…0.95  |

ref "A Pressure-Volume-Temperature Correlation for Mixtures of California Oil and Gases", M.B. Standing, Drill. & Prod. Prac., API, 1947.

```python
import matplotlib.pyplot as plt
import matplotlib as mpl
import numpy as np
from sympy import *
init_printing()
import sys
sys.path.append('../')
import uniflocpy.uPVT.PVT_correlations as PVT
```

```python
# Корреляция Стендинга для давления насыщения реализована
# в виде функции unf_pb_Standing_MPaa в модуле PVT_correlations.
# Подробные данные по функции включая исходный код приведены ниже

PVT.unf_pb_Standing_MPaa??
```

в приведеном коде использована коррекция значений давления насыщения при низких значениях газосодержания при давлении насыщения для обеспечения выхода на значение $P_b = 1$ при $R{sb} = 0$

---
Построим пару графиков, используя приведенную функцию

```python
# параметры определяющие диапазоны значений для построения графиков
rsb_set=np.arange(1,300,10)
t_set=np.arange(273,380,30)
t_set_def=np.array([313])
gg_set=np.arange(0.6,1,0.1)
gg_set_def=np.array([0.8])
go_set=np.arange(0.8,1,0.05)
go_set_def=np.array([0.86])
# функция для автоматизации построения графиков по давлению насыщения
def prep_plot(func,tset,goset,ggset,plot_title,plot_xlab,plot_ylab):
    for t in tset:
        for gg in ggset:
            for go in goset:
                pb_set=[]
                for rsb in rsb_set:
                    pb_set.append(func(rsb,t_K = t,gamma_gas = gg,gamma_oil = go))
                plt.plot(rsb_set, pb_set, label='t = %1.0f $ ^{\circ}\mathrm{K}$'%t +
                                                 ' $\gamma_g$ = %1.2f'%gg +
                                                 ' $\gamma_o$ = %1.2f'%go)
    plt.title(plot_title)
    plt.ylabel(plot_ylab, color = 'black')
    plt.xlabel(plot_xlab, color = 'black')
    plt.legend()

```

```python
# код для построения графиков
plt.figure(figsize=(15,8))
f = PVT.unf_pb_Standing_MPaa
# рисуем первый график
plt.subplot(221)
prep_plot(f,t_set,go_set_def,gg_set_def,
              'Давление насыщения от газосодержания',
               '$R_{sb}, м^3/м^3$',
               '$P_b, MPa$')
plt.grid()
# рисуем второй график
plt.subplot(222)
prep_plot(f,t_set_def,go_set,gg_set_def,
              'Давление насыщения от газосодержания',
               '$R_{sb}, м^3/м^3$',
               '$P_b, MPa$')
plt.grid()
# рисуем третий график
plt.subplot(223)
prep_plot(f,t_set_def,go_set_def,gg_set,
              'Давление насыщения от газосодержания',
               '$R_{sb}, м^3/м^3$',
               '$P_b, MPa$')

plt.subplots_adjust(top=0.92, bottom=0.08, left=0.10, right=0.95, hspace=0.25, wspace=0.35)
# рисуем все
plt.grid()
plt.show()

```

---

### Давление насыщения, корреляция Valko McCain

Корреляция Valco McCain (2003) для оценки давления насыщения нефти газом разработана на основе банка данных нефтей со всего мира. На рисунке показаны источники данных, которые были использованы авторами для настройки корреляции.

Диапазоны значений основных свойств (1745 значений), использованных для разработки данной корреляции, приведены в таблице ниже.

|
 Параметр  | Мин|Среднее|Макс|
| :--- | :---: |:---:|:---:|
|
 давление насыщения,$P_b$ , $ МПа $  | 0.55  |15.0|45.5|
|
 температура, $^{\circ}\mathrm{С} $ | 15 |85|172|
|
 газосодержание при давлении насыщения, $R_{sb}$ , $м^3/м^3 $ | 2 |104|395|
|
 относительная плотность нефти по воде, $\gamma_o$ | 0.724  |0.846|1.02|
|
 относительная плотность газа на сепараторе, $\gamma_g$  | 0.555  |0.838|1.685|

По результатам сравнений с замеренными значениями абсолютная значение средней относительной ошибки (AARE) для корреляции составляет около 11%. Авторы отмечают, что полученная точность соответствует точности замеров использованных для построения корреляции и для построения более точных зависимостей потребуется сбор новых данных с повышенной точностью.

$$
ln P_b = 7.475 + 0.713 z + 0.0075 z^2
$$
где

$$
z = z_1+z_2+z_3+z_4
$$
$$
z_1 = -5.48 - 0.0375\cdot ln R_{sb}+0.281\cdot (ln R_{sb})^2 - 0.0206\cdot (ln R_{sb})^3
$$
$$
z_2 = 1.27 - 0.0449\cdot API +4.36 \cdot 10^{-4} API^2 -4.76 \cdot 10^{-6} API^3
$$
$$
z_3 = 4.51 - 10.84 \cdot \gamma_{gSP} +8.39\cdot \gamma_{gSP}^2 -2.34\cdot \gamma_{gSP}^3
$$
$$
z_4 = -0.7835 + 6.23 \cdot 10^{-3} \cdot T_R - 1.22 \cdot 10^{-5} \cdot T_R^2+ 1.03 \cdot 10^{-8} \cdot T_R^3
$$

где

* $p_b$ - давление насыщения, $psia$
* $R_{sb}$ - газосодержание при давлении насыщения, ${scf}/{STB}$
* $\gamma_{gSP}$ - удельная плотность газа, отобранного на сепараторе, безразмерная величина
* $T_R$ - пластовая температура, $F$

ref Reservoir oil bubblepoint pressures revisited; solution gas-oil ratios and surface gas specific gravities. P.P.Valko, W.D.McCain Jr. Journal of petroleum science and engineering 37(2003) 153-169

---
#### Пребразование единиц измерения для корреляции Валко Маккейна

```python
# объявления переменных необходимых для преобразования единиц в вырожении
rsb_scfSTB, rsb_m3m3 = symbols('R_sb[scfSTB] R_sb[m3m3]')
API, gamma_o = symbols('API gamma_o')
gamma_gSP = symbols('gamma_gSP')
T_RF,T_RK = symbols('T_R[F] T_R[K]')
z,z1,z2,z3,z4 = symbols('z,z1,z2,z3,z4')
p_bpsia, p_bMPaa = symbols('p_b[psia],p_b[MPaa]')
```

```python
# определение алгоритма расчета в американских промысловых единицах
eq1 = Eq(z,z1+z2+z3+z4)
eq2 = Eq(z1,  -5.48 - 0.03758 * ln(rsb_scfSTB)+ 0.281*  ln(rsb_scfSTB)**2 - 0.0206* ln(rsb_scfSTB)**3)
eq3 = Eq(z2,  1.27 - 0.0449* API +4.36 * 10**-4 *API**2 -4.76 * 10**-6 *API**3)
eq4 = Eq(z3,  4.51- 10.84 *gamma_gSP +8.39*gamma_gSP**2 -2.34*gamma_gSP**3 )
eq5 = Eq(z4,  -0.7835 + 6.23 * 10**-3 * T_RF - 1.22 * 10**-5 * T_RF**2+ 1.03 * 10**-8 * T_RF**3)
eq6 =Eq(ln(p_bpsia),(7.475 + 0.713 * z + 0.0075 * z**2))
# покажем выражения в печатном виде
display(eq6)
display(eq1)
display(eq2)
display(eq3)
display(eq4)
display(eq5)
```

```python
# выражения для преобразования единиц измерения из американских промысловых в практические метрические
scfSTB_to_m3m3 = rsb_m3m3/0.178107606679035
API_to_gamma_o = 141.5/gamma_o-131.5
F_to_K = T_RK*9/5-459.67
psi_to_MPa = p_bMPaa * 14.6959 * 10.1325
# покажем выражения в печатном виде
display(Eq(rsb_scfSTB , scfSTB_to_m3m3))
display(Eq(API,API_to_gamma_o))
display(Eq(T_RF,F_to_K))
display(Eq(p_bpsia,psi_to_MPa))
```

```python
# преобразование алгоритма в метрические единицы с использованием символьных вычислений
eq2_m=simplify(eq2.subs(rsb_scfSTB,scfSTB_to_m3m3))
eq3_m=simplify(eq3.subs(API,API_to_gamma_o))
eq5_m=simplify(eq5.subs(T_RF,F_to_K))
eq6_m=eq6.subs(p_bpsia, psi_to_MPa)
eq8=solve(eq6_m,p_bMPaa)
eq9=Eq(p_bMPaa, eq8[0])
# вывод результатов преобразований
display(eq9)
display(eq1)
display(eq2_m)
display(eq3_m)
display(eq4)
display(eq5_m)
```

```python
# расчет реализован в функции  unf_pb_Valko_MPaa
PVT.unf_pb_Valko_MPaa??
```

```python
plt.figure(figsize=(15,8))
f = PVT.unf_pb_Valko_MPaa
# рисуем первый график
plt.subplot(221)
prep_plot(f,t_set,go_set_def,gg_set_def,
              'Давление насыщения от газосодержания',
               '$R_{sb}, м^3/м^3$',
               '$P_b, MPa$')
plt.grid()
# рисуем второй график
plt.subplot(222)
prep_plot(f,t_set_def,go_set,gg_set_def,
              'Давление насыщения от газосодержания',
               '$R_{sb}, м^3/м^3$',
               '$P_b, MPa$')
plt.grid()
# рисуем третий график
plt.subplot(223)
prep_plot(f,t_set_def,go_set_def,gg_set,
              'Давление насыщения от газосодержания',
               '$R_{sb}, м^3/м^3$',
               '$P_b, MPa$')

plt.subplots_adjust(top=0.92, bottom=0.08, left=0.10, right=0.95, hspace=0.25, wspace=0.35)
# рисуем все
plt.grid()
plt.show()
```

в приведеном коде использована коррекция значений давления насыщения при низких значениях газосодержания при давлении насыщения для обеспечения выхода на значение $P_b = 1$ при $R{sb} = 0$ и при больших значениях газосодержания

следует отметить, что в отличии от корреляций типа Стендинга корреляция Валко Макейна хорошо описывает исходный набор данных в пределах области применимости, но дает нефизичные результаты за пределами диапазона применимости. Приведенная в коде корректировке может частично сгладить экспраполированные значения, но лучше при проведении расчетов контролировать, чтобы корреляция применялась в пределах диапазона примемости.

```python
PVT.unf_pb_AlMarhoun_MPaa??
```

```python

```

notebooks/uPVT. Bubble point pressure.ipynb


# Mean-Variance Optimization

MPT solves for the optimal portfolio weights to minimize volatility for a given expected return, or maximize returns for a given level of volatility. The key requisite input are expected asset returns, standard deviations, and the covariance matrix.

Diversification works because the variance of portfolio returns depends on the covariance of the assets and can be reduced below the weighted average of the asset variances by including assets with less than perfect correlation. In particular, given a vector, ω, of portfolio weights and the covariance matrix, $\Sigma$, the portfolio variance, $\sigma_{\text{PF}}$ is defined as:
$$\sigma_{\text{PF}}=\omega^T\Sigma\omega$$

Markowitz showed that the problem of maximizing the expected portfolio return subject to a target risk has an equivalent dual representation of minimizing portfolio risk subject to a target expected return level, $μ_{PF}$. Hence, the optimization problem becomes:
$$
\begin{align}
\min_\omega & \quad\quad\sigma^2_{\text{PF}}= \omega^T\Sigma\omega\\
\text{s.t.} &\quad\quad \mu_{\text{PF}}= \omega^T\mu\\
&\quad\quad \lVert\omega\rVert =1
\end{align}
$$

We can calculate an efficient frontier using `scipy.optimize.minimize` and the historical estimates for asset returns, standard deviations, and the covariance matrix.

## Imports & Settings

```python
import warnings
warnings.filterwarnings('ignore')
```

```python
%matplotlib inline

import pandas as pd
import numpy as np
from numpy.random import random, uniform, dirichlet, choice
from numpy.linalg import inv

from scipy.optimize import minimize

import pandas_datareader.data as web
import matplotlib.pyplot as plt
from matplotlib.ticker import FuncFormatter
import seaborn as sns
```

```python
sns.set_style('whitegrid')
np.random.seed(42)
```

```python
cmap = sns.diverging_palette(10, 240, n=9, as_cmap=True)
```

## Prepare Data

We select historical data for tickers included in the S&P500 (according to Wikipedia) from 1998-2017.

```python
with pd.HDFStore('../data/assets.h5') as store:
    sp500_stocks = store['sp500/stocks']
```

```python
sp500_stocks.head()
```

      name
      gics_sector
      gics_sub_industry
      location
      first_added
      cik
      founded

      ticker

      MMM
      3M Company
      Industrials
      Industrial Conglomerates
      St. Paul, Minnesota
      1976-08-09
      66740
      1902

      ABT
      Abbott Laboratories
      Health Care
      Health Care Equipment
      North Chicago, Illinois
      1964-03-31
      1800
      1888

      ABBV
      AbbVie Inc.
      Health Care
      Pharmaceuticals
      North Chicago, Illinois
      2012-12-31
      1551152
      2013 (1888)

      ABMD
      ABIOMED Inc
      Health Care
      Health Care Equipment
      Danvers, Massachusetts
      2018-05-31
      815094
      1981

      ACN
      Accenture plc
      Information Technology
      IT Consulting & Other Services
      Dublin, Ireland
      2011-07-06
      1467373
      1989

```python
with pd.HDFStore('../data/assets.h5') as store:
    prices = (store['quandl/wiki/prices']
              .adj_close
              .unstack('ticker')
              .filter(sp500_stocks.index)
              .sample(n=30, axis=1))
```

## Compute Inputs

### Compute Returns

```python
start = 2008
end = 2017
```

Create month-end monthly returns and drop dates that have no observations:

```python
weekly_returns = prices.loc[f'{start}':f'{end}'].resample('W').last().pct_change().dropna(how='all')
weekly_returns = weekly_returns.dropna(axis=1)
weekly_returns.info()
```

    <class 'pandas.core.frame.DataFrame'>
    DatetimeIndex: 521 entries, 2008-01-13 to 2017-12-31
    Freq: W-SUN
    Data columns (total 25 columns):
     #   Column  Non-Null Count  Dtype
    ---  ------  --------------  -----
     0   WYNN    521 non-null    float64
     1   FIS     521 non-null    float64
     2   VLO     521 non-null    float64
     3   MRK     521 non-null    float64
     4   DTE     521 non-null    float64
     5   REGN    521 non-null    float64
     6   TEL     521 non-null    float64
     7   YUM     521 non-null    float64
     8   HST     521 non-null    float64
     9   AES     521 non-null    float64
     10  XOM     521 non-null    float64
     11  NEE     521 non-null    float64
     12  HAL     521 non-null    float64
     13  TJX     521 non-null    float64
     14  AXP     521 non-null    float64
     15  IR      521 non-null    float64
     16  CERN    521 non-null    float64
     17  IVZ     521 non-null    float64
     18  XLNX    521 non-null    float64
     19  LOW     521 non-null    float64
     20  WEC     521 non-null    float64
     21  ETFC    521 non-null    float64
     22  XEL     521 non-null    float64
     23  CDNS    521 non-null    float64
     24  BXP     521 non-null    float64
    dtypes: float64(25)
    memory usage: 105.8 KB

### Set  Parameters

```python
stocks = weekly_returns.columns
```

```python
n_obs, n_assets = weekly_returns.shape
n_assets, n_obs
```

    (25, 521)

```python
NUM_PF = 100000 # no of portfolios to simulate
```

```python
x0 = uniform(0, 1, n_assets)
x0 /= np.sum(np.abs(x0))
```

### Annualization Factor

```python
periods_per_year = round(weekly_returns.resample('A').size().mean())
periods_per_year
```

    52

### Compute Mean Returns, Covariance and Precision Matrix

```python
mean_returns = weekly_returns.mean()
cov_matrix = weekly_returns.cov()
```

The precision matrix is the inverse of the covariance matrix:

```python
precision_matrix = pd.DataFrame(inv(cov_matrix), index=stocks, columns=stocks)
```

### Risk-Free Rate

Load historical 10-year Treasury rate:

```python
treasury_10yr_monthly = (web.DataReader('DGS10', 'fred', start, end)
                         .resample('M')
                         .last()
                         .div(periods_per_year)
                         .div(100)
                         .squeeze())
```

```python
rf_rate = treasury_10yr_monthly.mean()
```

## Simulate Random Portfolios

The simulation generates random weights using the Dirichlet distribution, and computes the mean, standard deviation, and SR for each sample portfolio using the historical return data:

```python
def simulate_portfolios(mean_ret, cov, rf_rate=rf_rate, short=True):
    alpha = np.full(shape=n_assets, fill_value=.05)
    weights = dirichlet(alpha=alpha, size=NUM_PF)
    if short:
        weights *= choice([-1, 1], size=weights.shape)

    returns = weights @ mean_ret.values + 1
    returns = returns ** periods_per_year - 1
    std = (weights @ weekly_returns.T).std(1)
    std *= np.sqrt(periods_per_year)
    sharpe = (returns - rf_rate) / std
    return pd.DataFrame({'Annualized Standard Deviation': std,
                         'Annualized Returns': returns,
                         'Sharpe Ratio': sharpe}), weights
```

```python
simul_perf, simul_wt = simulate_portfolios(mean_returns, cov_matrix, short=False)
```

```python
df = pd.DataFrame(simul_wt)
df.describe()
```

      0
      1
      2
      3
      4
      5
      6
      7
      8
      9
      ...
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24

      count
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      ...
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05
      1.000000e+05

      mean
      4.014051e-02
      4.021212e-02
      3.990815e-02
      3.998121e-02
      4.091333e-02
      4.010946e-02
      3.994396e-02
      4.008751e-02
      4.040184e-02
      3.962200e-02
      ...
      3.931306e-02
      3.911847e-02
      3.958634e-02
      4.031997e-02
      4.065440e-02
      3.994213e-02
      4.017737e-02
      4.007840e-02
      4.026256e-02
      3.993907e-02

      std
      1.312552e-01
      1.311073e-01
      1.303379e-01
      1.306601e-01
      1.329938e-01
      1.307786e-01
      1.301776e-01
      1.307051e-01
      1.321454e-01
      1.293530e-01
      ...
      1.296803e-01
      1.282788e-01
      1.301828e-01
      1.316890e-01
      1.317836e-01
      1.309682e-01
      1.313345e-01
      1.307637e-01
      1.315626e-01
      1.302771e-01

      min
      5.487810e-97
      6.391219e-103
      2.537639e-96
      3.103047e-92
      1.394197e-95
      2.796315e-119
      2.366047e-102
      8.830544e-107
      4.507542e-97
      1.620766e-93
      ...
      1.696634e-103
      1.842655e-111
      2.948403e-110
      3.165806e-102
      1.177348e-109
      2.898130e-93
      3.038647e-99
      8.175665e-100
      1.990480e-122
      3.521496e-97

      25%
      6.548585e-13
      7.526395e-13
      7.532093e-13
      7.098138e-13
      6.967077e-13
      8.502324e-13
      6.950384e-13
      6.402276e-13
      6.214023e-13
      6.271992e-13
      ...
      5.260179e-13
      6.504203e-13
      6.463332e-13
      5.624521e-13
      6.814244e-13
      8.556783e-13
      8.633643e-13
      8.498675e-13
      6.467348e-13
      6.834165e-13

      50%
      6.616167e-07
      7.502422e-07
      7.797426e-07
      7.198818e-07
      7.358429e-07
      7.421117e-07
      6.911534e-07
      6.738274e-07
      6.997784e-07
      7.061397e-07
      ...
      6.020512e-07
      7.166774e-07
      7.237275e-07
      7.098441e-07
      8.050248e-07
      8.248063e-07
      7.306568e-07
      7.889467e-07
      6.695123e-07
      7.623604e-07

      75%
      2.277156e-03
      2.395975e-03
      2.408796e-03
      2.264291e-03
      2.452694e-03
      2.467333e-03
      2.558821e-03
      2.387084e-03
      2.386800e-03
      2.457362e-03
      ...
      2.144058e-03
      2.313919e-03
      2.412220e-03
      2.323442e-03
      2.513800e-03
      2.377078e-03
      2.345158e-03
      2.410579e-03
      2.369465e-03
      2.459960e-03

      max
      9.977419e-01
      9.979261e-01
      9.993984e-01
      9.998279e-01
      9.997830e-01
      9.990782e-01
      9.984043e-01
      9.987972e-01
      9.992743e-01
      9.999805e-01
      ...
      9.995104e-01
      9.997510e-01
      9.979907e-01
      9.991659e-01
      9.982628e-01
      9.984974e-01
      9.992195e-01
      9.986744e-01
      9.990703e-01
      9.993066e-01

8 rows × 25 columns

### Plot Simulated Portfolios

```python
ax = simul_perf.plot.scatter(x=0, y=1, c=2, cmap='Blues',
                             alpha=0.5, figsize=(14, 9), colorbar=True,
                             title=f'{NUM_PF:,d} Simulated Portfolios')

max_sharpe_idx = simul_perf.iloc[:, 2].idxmax()
sd, r = simul_perf.iloc[max_sharpe_idx, :2].values
print(f'Max Sharpe: {sd:.2%}, {r:.2%}')
ax.scatter(sd, r, marker='*', color='darkblue', s=500, label='Max. Sharpe Ratio')

min_vol_idx = simul_perf.iloc[:, 0].idxmin()
sd, r = simul_perf.iloc[min_vol_idx, :2].values
ax.scatter(sd, r, marker='*', color='green', s=500, label='Min Volatility')
plt.legend(labelspacing=1, loc='upper left')
plt.tight_layout()
```

## Compute Annualize PF Performance

Now we'll set up the quadratic optimization problem to solve for the minimum standard deviation for a given return or the maximum SR.

To this end, define the functions that measure the key metrics:

```python
def portfolio_std(wt, rt=None, cov=None):
    """Annualized PF standard deviation"""
    return np.sqrt(wt @ cov @ wt * periods_per_year)
```

```python
def portfolio_returns(wt, rt=None, cov=None):
    """Annualized PF returns"""
    return (wt @ rt + 1) ** periods_per_year - 1
```

```python
def portfolio_performance(wt, rt, cov):
    """Annualized PF returns & standard deviation"""
    r = portfolio_returns(wt, rt=rt)
    sd = portfolio_std(wt, cov=cov)
    return r, sd
```

## Max Sharpe PF

Define a target function that represents the negative SR for scipy's minimize function to optimize, given the constraints that the weights are bounded by [-1, 1], if short trading is permitted, and [0, 1] otherwise, and sum to one in absolute terms.

```python
def neg_sharpe_ratio(weights, mean_ret, cov):
    r, sd = portfolio_performance(weights, mean_ret, cov)
    return -(r - rf_rate) / sd
```

```python
weight_constraint = {'type': 'eq',
                     'fun': lambda x: np.sum(np.abs(x))-1}
```

```python
def max_sharpe_ratio(mean_ret, cov, short=False):
    return minimize(fun=neg_sharpe_ratio,
                    x0=x0,
                    args=(mean_ret, cov),
                    method='SLSQP',
                    bounds=((-1 if short else 0, 1),) * n_assets,
                    constraints=weight_constraint,
                    options={'tol':1e-10, 'maxiter':1e4})
```

## Compute Efficient Frontier

The solution requires iterating over ranges of acceptable values to identify optimal risk-return combinations

```python
def min_vol_target(mean_ret, cov, target, short=False):

    def ret_(wt):
        return portfolio_returns(wt, mean_ret)

    constraints = [{'type': 'eq',
                    'fun': lambda x: ret_(x) - target},
                   weight_constraint]

    bounds = ((-1 if short else 0, 1),) * n_assets
    return minimize(portfolio_std,
                    x0=x0,
                    args=(mean_ret, cov),
                    method='SLSQP',
                    bounds=bounds,
                    constraints=constraints,
                    options={'tol': 1e-10, 'maxiter': 1e4})
```

The mean-variance frontier relies on in-sample, backward-looking optimization. In practice, portfolio optimization requires forward-looking input. Unfortunately, expected returns are notoriously difficult to estimate accurately.

The covariance matrix can be estimated somewhat more reliably, which has given rise to several alternative approaches. However, covariance matrices with correlated assets pose computational challenges since the optimization problem requires inverting the matrix. The high condition number induces numerical instability, which in turn gives rise to Markovitz curse: the more diversification is required (by correlated investment opportunities), the more unreliable the weights produced by the algorithm.

## Min Volatility Portfolio

```python
def min_vol(mean_ret, cov, short=False):
    bounds = ((-1 if short else 0, 1),) * n_assets

    return minimize(fun=portfolio_std,
                    x0=x0,
                    args=(mean_ret, cov),
                    method='SLSQP',
                    bounds=bounds,
                    constraints=weight_constraint,
                    options={'tol': 1e-10, 'maxiter': 1e4})
```

```python
def efficient_frontier(mean_ret, cov, ret_range, short=False):
    return [min_vol_target(mean_ret, cov, ret) for ret in ret_range]
```

## Run Calculation

### Get random PF

```python
simul_perf, simul_wt = simulate_portfolios(mean_returns, cov_matrix, short=False)
```

```python
print(simul_perf.describe())
```

           Annualized Standard Deviation  Annualized Returns   Sharpe Ratio
    count                  100000.000000       100000.000000  100000.000000
    mean                        0.272889            0.177186       0.675622
    std                         0.076975            0.055340       0.213748
    min                         0.139966            0.019720       0.055767
    25%                         0.217795            0.148334       0.511378
    50%                         0.260579            0.173843       0.665910
    75%                         0.314480            0.202062       0.834405
    max                         0.654543            0.466036       1.332866

```python
simul_max_sharpe = simul_perf.iloc[:, 2].idxmax()
simul_perf.iloc[simul_max_sharpe]
```

    Annualized Standard Deviation    0.166130
    Annualized Returns               0.221928
    Sharpe Ratio                     1.332866
    Name: 67110, dtype: float64

### Get Max Sharpe PF

```python
max_sharpe_pf = max_sharpe_ratio(mean_returns, cov_matrix, short=False)
max_sharpe_perf = portfolio_performance(max_sharpe_pf.x, mean_returns, cov_matrix)
```

```python
r, sd = max_sharpe_perf
pd.Series({'ret': r, 'sd': sd, 'sr': (r-rf_rate)/sd})
```

    ret    0.228716
    sd     0.166360
    sr     1.371822
    dtype: float64

From simulated pf data

### Get Min Vol PF

```python
min_vol_pf = min_vol(mean_returns, cov_matrix, short=False)
min_vol_perf = portfolio_performance(min_vol_pf.x, mean_returns, cov_matrix)
```

### Get Efficent PFs

```python
ret_range = np.linspace(simul_perf.iloc[:, 1].min(), simul_perf.iloc[:, 1].max(), 50)
eff_pf = efficient_frontier(mean_returns, cov_matrix, ret_range, short=True)
eff_pf = pd.Series(dict(zip([p['fun'] for p in eff_pf], ret_range)))
```

### Plot Result

The simulation yields a subset of the feasible portfolios, and the efficient frontier identifies the optimal in-sample return-risk combinations that were achievable given historic data.

The below figure shows the result including the minimum variance portfolio and the portfolio that maximizes the SR and several portfolios produce by alternative optimization strategies. The efficient frontier

```python
fig, ax = plt.subplots()
simul_perf.plot.scatter(x=0, y=1, c=2, ax=ax, cmap='Blues',alpha=0.25,
                        figsize=(14, 9), colorbar=True)

eff_pf[eff_pf.index.min():].plot(linestyle='--', lw=2, ax=ax, c='k',
                                 label='Efficient Frontier')

r, sd = max_sharpe_perf
ax.scatter(sd, r, marker='*', color='k', s=500, label='Max Sharpe Ratio PF')

r, sd = min_vol_perf
ax.scatter(sd, r, marker='v', color='k', s=200, label='Min Volatility PF')

kelly_wt = precision_matrix.dot(mean_returns).clip(lower=0).values
kelly_wt /= np.sum(np.abs(kelly_wt))
r, sd = portfolio_performance(kelly_wt, mean_returns, cov_matrix)
ax.scatter(sd, r, marker='D', color='k', s=150, label='Kelly PF')

std = weekly_returns.std()
std /= std.sum()
r, sd = portfolio_performance(std, mean_returns, cov_matrix)
ax.scatter(sd, r, marker='X', color='k', s=250, label='Risk Parity PF')

r, sd = portfolio_performance(np.full(n_assets, 1/n_assets), mean_returns, cov_matrix)
ax.scatter(sd, r, marker='o', color='k', s=200, label='1/n PF')

ax.legend(labelspacing=0.8)
ax.set_xlim(0, eff_pf.max()+.4)
ax.set_title('Mean-Variance Efficient Frontier', fontsize=16)
ax.yaxis.set_major_formatter(FuncFormatter(lambda y, _: '{:.0%}'.format(y)))
ax.xaxis.set_major_formatter(FuncFormatter(lambda y, _: '{:.0%}'.format(y)))
sns.despine()
fig.tight_layout();
```


```python
import numpy as np
import matplotlib.pylab as plt
import pandas as pd
```

# Numeric Solution of Logistic Growth Problem

$$\frac{d\, N}{d\, t}=r\, N \left(1-\frac{N}{K}\right)$$

 - population $N$ grows (or shrinks) with *constant rate* $r$
 - only a finite maximum population (*carrying capacity* $K$) can be sustained: limited by resources or other forms of competition among population members

The analytic solution is the logistic function with initial population size $N(t=0)=N_0$:

$$N(t) = \frac{N_0\, K}{(K-N_0)\exp(-r\,t)+N_0}=\frac{N_0\, K\exp(r\,t)}{K+ N_0\,(\exp(r\,t)-1)}$$

## Numeric Integration

### Define computational function for ODE

Define a python function that implements the ODE:

  $$f(t,y)= f(y) = \frac{d\, y}{d\, t}=r\, y \left(1-\frac{y}{K}\right)$$

The function should accept input *arguments* `t`, `y`.
For now, 'hard-code' parameters `r`, `K` in the function body.

```python
def ode_logistic_growth(t, y):
    K = 1
    r = 1
    return r*y-r*y*y/K
```

### Solver

```python
def solve_euler(f, t, y_0):
    """
    Uses explicit euler method to solve ODE: y'=f(t, y)
    with initial value y(t_0)=y_0.

    Args:
    - f: ODE-defining function object, expected signature f(t, y),
         where t is evaluation point and y is function value
    - t: array of evaluation points
    - y_0: initial value, i.e. y(t[0])
    Returns:
    - array containing approximated function values y(t[i])
    """
    y = np.zeros(t.shape)
    y[0] = y_0
    for i in range(0, len(t)-1):
        y[i+1] = y[i] + (t[i+1]-t[i]) * f(t[i],y[i])
    return y
```

### Solve ODE

- Define array `t` that discretizes time interval $[t_0, t_{\text{max}}]$ in $n$ subintervals of length $\Delta t$.
- Apply numerical ODE solver to your ODE-defining function, providing
    - initial value $N_0$
    - array of integration time points $t_i$: `t`
    - other parameters of your ODE as keyword arguments
  Use: $t_0=0\, h$, $t_{\text{max}}=120\, h$, $\Delta t = 0.1$, $N_0=10^3$, $r=0.12 / h$, $K=10^6$

- Plot solution.

- Check if initial condition is fulfilled and if solution shows the expected limit behavior.

```python
# ODE with specific parameters
def ode_logistic_growth_with_params(t, y):
    K = 1E6
    r = 0.12
    return r*y-r*y*y/K

# time discretization
t = np.arange(0, 120, 0.1)
# solve ODE
y_numeric = solve_euler(ode_logistic_growth_with_params, t, y_0=1E3)
```

```python
plt.plot(t, y_numeric, label="numeric solution")
plt.legend()
```

## Comparison to Analytic Solution

Testing a new numeric method or implementation on simple test problems with known solutions is a good way to identify problems and gain confidence in the results it produces.

We will compare the numerical solution of the ODE to its analytical solution.

### Compute analytic solution

Write a function that implements the analytic solution of the logistic growth problem:

$$N(t) = \frac{N_0\, K}{(K-N_0)\exp(-r\,t)+N_0}=\frac{N_0\, K\exp(r\,t)}{K+ N_0\,(\exp(r\,t)-1)}\, .$$

```python
def logistic_growth(N0, t, r, K):
    enum = N0*K*np.exp(r*t)
    denom = K + N0*np.exp(r*t) - N0
    return enum/denom
```

```python
y_analytic = logistic_growth(1E3, t, r=0.12, K=1E6)
plt.plot(t, y_analytic, label="analytic solution")
plt.legend()
```

### Compare analytic and numeric solution

- Qualitatively compare (plot !) the numeric solutions to the analytic solution for different integration step sizes:
     1. $\Delta t = 0.1$
     2. $\Delta t = 1.0$
     3. $\Delta t = 5.0$

- Repeat this for $r=0.5$

```python
# ODE with specific parameters
def ode_logistic_growth_with_params(t, y):
    K = 1E6
    r = 0.12
    return r*y-r*y*y/K

for delta_t in [5.0, 1.0, 0.1]:
    t = np.arange(0, 120, delta_t)
    y_numeric = solve_euler(ode_logistic_growth_with_params, t, y_0=1E3)
    plt.plot(t, y_numeric, label="numeric solution: $\Delta t = %.2f$"%delta_t)

t_analytic = np.arange(0, 120, 0.1)
y_analytic = logistic_growth(1E3, t, 0.12, 1E6)
plt.plot(t, y_analytic, label="analytic solution")
plt.legend()
```

## Logistic Growth with non-constant Growth Rate

So far we have assumed that the growth rate $r$ is constant in time, but more generally $r=r(t)$ may be a function of time.

If an analytic expression is known for the functional form of the time dependency, the time-dependency can be included directly into the ODE-defining function and the time-dependent problem can be solved in the same way as the time-independent problem.

Consider the following example of a time dependent growth rate (from lecture notes, section 3.4):

\begin{equation}
    r(t)=\frac{r_{in}+r_{fin}}{2} + \frac{r_{fin}-r_{in}}{2}\tanh(t-D),
\end{equation}

with parameters $r_{in}$, $r_{fin}$, and $D$.

```python
def ode_log_growth_time_dep(t, y):
    rin  = -1
    rfin = 1
    K = 3
    D = 2
    r = 0.5*(rin+rfin)+0.5*(rfin-rin)*np.tanh(t-D)
    return r*y-r*y*y/K

t = np.arange(0, 20, 0.1)
y_numeric = solve_euler(ode_log_growth_time_dep, t, y_0=0.5)

plt.plot(t, y_numeric, label="numeric solution")
plt.legend()
```

# Standard Solver Interface

The `scipy.integrate` package provides multiple ODE solvers, most of them can be accessed via the
[`scipy.integrate.solve_ivp`](https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.solve_ivp.html#scipy.integrate.solve_ivp) interface.
By default, it uses the [`scipy.integrate.RK45`](https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.RK45.html#scipy.integrate.RK45) solver, an explicit *adaptive* Runge-Kutta method.

When using our own solver implementation we could decide how to call the ODE-defining function from within the solver, i.e. which arguments the ODE-defining function accepts and their order.
Now, to work with `solve_ivp`, the ODE-defining function *must* have the signature `f(t, y)` where `t` is the current evaluation point and `y` the current function value.

The `solve_ivp` interface returns not only an array of estimated function values (as our implementation of the euler method) but instead a results object that contains various types of information.
You can inspect this object by printing or using `dir()`.

```python
from scipy.integrate import solve_ivp

def ode_logistic_growth_with_params(t, y):
    K = 1E6
    r = 0.12
    return r*y-r*y*y/K

t_0 = 0
t_N = 120
N0 = 1E3

sol = solve_ivp(ode_logistic_growth_with_params, [t_0, t_N], [N0])
#print("results object: ",sol)
```

```python
plt.plot(sol.t, sol.y[0]) # note that the result y is returned as nested array
```

## Adaptive Solver Settings

`solve_ivp` uses an *adaptive solver*, i.e. the stepsize $\Delta t$ is controlled by estimating the local error and a target accuracy. See 'Adaptive Stepsize' in the [ODE notebook](https://github.com/cohmathonc/biosci670/blob/master/IntroductionComputationalMethods/05_IntroCompMethods_SolvingODEs.ipynb).

- target accuracy can be set by parameters `rtol`, `atol`.
- no limit on maximum stepsize by default, can be imposed via `max_step`

```python
# specific ODE function version for selected parameter sets
def ode_logistic_growth_with_params(t, y):
    K = 1E6
    r = 0.12
    return r*y-r*y*y/K

sol = solve_ivp(fun=ode_logistic_growth_with_params,
                t_span=[t_0, t_N], y0=[N0],
                #max_step=5,
                rtol=1E-6
                )
plt.plot(sol.t, sol.y[0])
plt.plot(sol.t, sol.y[0], 'xr')
```

## Function Evaluation at Specific (Time-)Points

The `t_eval` argument allows to obtain integration results at specific evaluation points.

In contrast to the constant-stepsize solver that we implemented ourselves, `solve_ivp` will integrate the ODE in the entire domain and according to the specified accuracy.
It will only return those function values that correspond to the evaluation points specified by `t_eval`.

```python
sol_selected_points = solve_ivp(fun=ode_logistic_growth_with_params,
                                 t_span=[t_0, t_N], y0=[N0],
                                 t_eval=[0, 20, 50, 62.34567, 70, 100],
                                 rtol=1E-3)
plt.plot(sol.t, sol.y[0])
plt.plot(sol_selected_points.t, sol_selected_points.y[0], 'sg')
```

# Optional function arguments: Parameters `K`, `r`

We are often interested in investigating the solution of an ODE in function of model parameters, such as `K` and `r` in the case of the logistic growth model.

Until now, we 'hardcoded' these parameters in the definition of the ODE function, and have redefined this function (of defined a new function with distinct name) for each model parametrization:

```python
#  'Hard-code' parameters in the ODE-defining function
def ode_logistic_growth_hardcoded_params(t, y):
    K = 1E6
    r = 0.12
    return r*y-r*y*y/K

sol = solve_ivp(fun=ode_logistic_growth_hardcoded_params,
                t_span=[t_0, t_N], y0=[N0])
```

This approach is straightforward for a small number of parameter sets but becomes inconvenient if more than a few parameter combinations have to be explored manually and is not suited for automated exploration of the parameter space (e.g. for data fitting).

In the following sections we will explore a few alternative approaches that enable us to pass parameters through the ODE solver to the ODE-defining function.
For all of those approaches, we first need a more general ODE function that accepts the parameters of interest as arguments to its function call.

```python
# a general ODE-defining function with parameters as arguments
def ode_logistic_growth_args(t, y, r, K):
    return r*y-r*y*y/K
```

As we know, the ODE solver makes repeated calls to the ODE function during the iterative solution process.
Usually, the ODE solver passes the current time `t` and function value `y` to the ODE function.

We now need to find a way to pass these additional parameters (`r`, `K`) to the ODE function whenever it is called by the ODE solver.

## Case 1: Your own solver -- full control over expected function signature

If you use a solver that you implemented yourself, you can control how the ODE function is called from within the solver.
This allows you to adapt your solver in such a way that it passes any specified model parameters to the ODE function.

### Passing each parameter separately through solver function to ODE

For example you could write a specific version of the solver that can solve logistic growth problems with parameters `r` and `K` by adding these parameters as arguments in the solver function definition (`param_r`, `param_K`), and passing these argument to the ODE-defining function when it is called.

```python
def ode_log_growth(t, y, r, K):
    return r*y-r*y*y/K

def solve_euler_with_params(f, t, y_0, param_r, param_K):
    y = np.zeros(t.shape)
    y[0] = y_0
    for i in range(0, len(t)-1):
        y[i+1] = y[i] + (t[i+1]-t[i]) * f(t[i],y[i], param_r, param_K)
    return y

t = np.arange(0,100,1)
y = solve_euler_with_params(ode_log_growth, t, 0.1, 0.12, 1E6)
```

This approach implies that a new dedicated solver function needs to be defined for ODEs with different numbers of parameters, i.e. a `solve_euler_1param(f, t, y_0, param1)` for ODEs with one parameter, a `solve_euler_2param(f, t, y_0, param1, param2)` for ODEs with two parameters,  a `solve_euler_3param(f, t, y_0, param1, param2, param3)` for ODEs with three parameters, etc.

### Passing an arbitrary number of parameters through solver function to ODE

Instead of passing single parameters separately, we could collect parameters in a *container*, e.g. a `dict` or `list` object, and pass this object to the ODE function.

```python
# 1) parameters as list

def ode_log_growth(t, y, param_list):
    r = param_list[0]
    K = param_list[1]
    return r*y-r*y*y/K

def solve_euler_with_param_list(f, t, y_0, parameters):
    y = np.zeros(t.shape)
    y[0] = y_0
    for i in range(0, len(t)-1):
        y[i+1] = y[i] + (t[i+1]-t[i]) * f(t[i],y[i], parameters)
    return y

t = np.arange(0,100,1)

parameter_list = [0.12, 1E6]
y = solve_euler_with_param_list(ode_log_growth, t, 0.1, parameter_list)
```

```python
# 2) parameters as dictionary

def ode_log_growth(t, y, param_dict):
    r = param_dict['r']
    K = param_dict['K']
    return r*y-r*y*y/K

def solve_euler_with_param_dict(f, t, y_0, parameters):
    y = np.zeros(t.shape)
    y[0] = y_0
    for i in range(0, len(t)-1):
        y[i+1] = y[i] + (t[i+1]-t[i]) * f(t[i],y[i], parameters)
    return y

t = np.arange(0,100,1)

parameters_dict = {'r':0.12, 'K':1E6}
y = solve_euler_with_param_dict(ode_log_growth, t, 0.1, parameters_dict)
```

Note that in both cases (list, dict), the solver function is identical and can handle ODE-defining functions with any arbitrary number of parameters.

However, you must be sure that parameters passed through the solver function are correctly interpreted by the respective ODE-defining function:
- If you have designed the ODE function to expect a parameter *dictionary* with certain keys, then the parameter object passed to the solver function must be of type dictionary and must contain *the keys expected* by the ODE function.
- Likewise, if you have designed the ODE function to expect a parameter *list* of certain length, then the parameter object passed to the solver function must be of type list and its content must be *ordered as expected* by the ODE function.

## Case 2: No control over function signatures

All of the above approaches required us to change the signature of the ODE function call in the definition of the solver function.

When using an 'external' function, such as `solve_ivp`, this is not easily possible.

Very often, functions that take other functions as arguments and call these functions internally are designed such that they allow additional parameters to be passed into this internal function call.
This is often implemented by exposing an (optional) argument that accepts a list of parameter values, exactly as we have discussed above in case 1.

Unfortunately, `solve_ivp` does not provide such a functionality; internally it calls the ODE-defining function with signature `f(t, y)` without the possibility to pass any other arguments to `f`.

The workaround in those cases exploits so-called [*lambda expressions*](https://docs.python.org/3/tutorial/controlflow.html#lambda-expressions), which provide a method to define anonymous functions in python.
( This is similar to the [@-handle](https://www.mathworks.com/help/matlab/matlab_prog/creating-a-function-handle.html) in Matlab)

Lambda expressions can be used for inline definition of simple functions (limited to a single expression); consider the following example:

```python
# 'lambda expressions' allow definition of single-expression functions
a=lambda x: 1+x
print(a(1))

#or even:
(lambda x: 1+x)(2)
```

We can exploit this mechanism to replace the ODE-defining function (with arguments `t`, `y`, and other paramers) by an anonymous 'wrapper' function (with arguments `t`, `y`) directly in the `solve_ivp` function call:

```python
def ode_log_growth(t, y, r, K):
    return r*y-r*y*y/K

# using a lambda expression, we can redefine the ODE function directly in the solve_ip function call
sol = solve_ivp(fun=lambda t, y: ode_log_growth(t, y, 0.12, 1E6),
                t_span=[0, 120], y0=[1E3], rtol=1E-6)

plt.plot(sol.t, sol.y[0])
```

This is the most flexible approach. It can be applied to any of the solvers that we used and for any model parameter specification method.

###### About
This notebook is part of the *biosci670* course on *Mathematical Modeling and Methods for Biomedical Science*.
See https://github.com/cohmathonc/biosci670 for more information and material.