Modelos Matemáticos de Ingeniería

Sesión 06 - Diferencias finitas

2021/02

MEDELLÍN - COLOMBIA

 Text provided under a Creative Commons Attribution license, CC-BY. All code is made available under the FSF-approved MIT license.(c) Carlos Alberto Alvarez Henao

***

***Docente:*** Carlos Alberto Álvarez Henao, I.C. D.Sc.

***e-mail:*** carlosalvarezh@gmail.com

***skype:*** carlos.alberto.alvarez.henao

***Linkedin:*** https://www.linkedin.com/in/carlosalvarez5/

***github:*** https://github.com/carlosalvarezh/Dinamica

***Herramienta:*** [Jupyter](http://jupyter.org/)

***Kernel:*** Python 3.9

***

Tabla de Contenidos

1  Diferencias Finitas

1.1  Series de Taylor

1.2  Esquemas para la primera derivada en 1D

1.2.1  Primer orden hacia adelante (forward)

1.2.2  Primer orden hacia atrás (backward)

1.2.3  Segundo orden (central)

1.2.4  Resumen esquemas para la primera derivada

1.3  Esquemas para la segunda derivada en 1D

1.4  Ejemplos de implmentación computacional

1.5  Esquemas en 2D

1.6  Término difusivo no uniforme en 1D

2  Molécula computacional de una aproximación en Diferencias Finitas

2.1  Ejemplo Ecuación de Laplace

3  Discretización de ecuaciones por el método de Diferencias Finitas

3.1  Estado estacionario

3.2  Ejemplo: ecuación de transporte en estado estacionario en 1D

3.3  Ecuaciones No estacionarias

3.3.1  Introducción

3.3.2  Discretización de ecuaciones no estacionarias

3.3.3  Discretización explícita

3.3.4  Discretización implícita

3.3.5  Resumen

3.4  Discretización multidimensional

4  Consistencia, Convergencia y Estabilidad

4.1  Introducción

4.2  Consistencia

4.3  Convergencia

4.4  Estabilidad

4.4.1  Teorema de Lax

4.4.2  Condición CFL

4.4.3  Relación Consistencia - Convergencia - Estabilidad

4.5  Esquema Upwind

4.6  Solución de la ecuación de convección-difusión en estado estacionario en 1D empleando el esquema Upwind

4.7  Esquema de Viscosidad artificial

4.8  Ecuación de convección difusión dependiente del tiempo

## Diferencias Finitas

En [análisis numérico](https://en.wikipedia.org/wiki/Numerical_analysis), los métodos de diferencias finitas (FDM) son una clase de técnicas numéricas para resolver ecuaciones diferenciales mediante la aproximación de derivadas con diferencias finitas. Tanto el dominio espacial como el intervalo de tiempo (si corresponde) se discretizan o se dividen en un número finito de pasos, y el valor de la solución en estos puntos discretos se aproxima resolviendo ecuaciones algebraicas que contienen diferencias finitas y valores de puntos cercanos.

Los métodos de diferencias finitas convierten las ecuaciones diferenciales ordinarias (EDO) o las ecuaciones diferenciales parciales (PDE), que pueden ser no lineales, en un sistema de ecuaciones lineales que pueden resolverse mediante técnicas de álgebra matricial. Las computadoras modernas pueden realizar estos cálculos de álgebra lineal de manera eficiente, lo que, junto con su relativa facilidad de implementación, ha llevado al uso generalizado de FDM en el análisis numérico moderno. Hoy en día, FDM es uno de los enfoques más comunes para la solución numérica de PDE, junto con los métodos de elementos finitos.

### Series de Taylor

La [serie de Taylor](https://en.wikipedia.org/wiki/Taylor_series) de una función es una suma infinita de términos que se expresan en términos de las derivadas de la función en un solo punto. Para las funciones más comunes, la función y la suma de su serie de Taylor son iguales cerca de este punto. Las series de Taylor llevan el nombre de [Brook Taylor](https://en.wikipedia.org/wiki/Brook_Taylor), quien las presentó en 1715.

La suma parcial formada por los primeros $n + 1$ términos de una serie de Taylor es un polinomio de grado $n$ que se llama el *$n$-ésimo polinomio de Taylor de la función*. Los polinomios de Taylor son aproximaciones de una función, que generalmente se vuelven mejores a medida que $n$ aumenta. El [teorema de Taylor](https://en.wikipedia.org/wiki/Taylor%27s_theorem) da estimaciones cuantitativas sobre el error introducido por el uso de tales aproximaciones. Si la serie de Taylor de una función es convergente, su suma es el límite de la secuencia infinita de los polinomios de Taylor.

### Esquemas para la primera derivada en 1D

De la [serie de Taylor](https://en.wikipedia.org/wiki/Taylor_series)

\begin{equation*}
f(x_{i \pm 1}) = f(x_i) \pm f'(x_i)h + \frac{f''(x_i)h^2}{2!} \pm \frac{f'''(x_i)h^3}{3!} + \ldots
\label{eq:Ec6_1} \tag{6.1}
\end{equation*}

con $h=\Delta x = x_{i+1}-x_i$ siendo el tamaño de paso.

Dada que la serie contiene infinitos términos, partir de la ecuación [(6.1)](#Ec6_1) se pueden obtener infinitos esquemas numéricos para determinar cada una de las infinitas derivadas de dicho polinomio.

#### Primer orden hacia adelante (forward)

De la ecuación [(6.1)](#Ec6_1) tomando los valores positivos, que involucran únicamente términos hacia adelante, se trunca la serie hasta la primera derivada y se realiza un despeje algebraico para llegar a:

\begin{equation*}
f'(x_i) = \frac{f(x_{i+1})-f(x_i)}{h} + \mathcal{O}(h)
\label{eq:Ec6_2} \tag{6.2}
\end{equation*}

se puede observar que el término $\mathcal{O}(h)$ indica que el error es de orden lineal, es decir, si se reduce el tamaño de paso, $h$, a la mitad, el error se reducirá a la mitad. Si se reduc el tamaño de paso a una cuarta parte, el error se reducirá, linealmente, una cuarta parte.

#### Primer orden hacia atrás (backward)

De la ecuación [(6.1)](#Ec6_1) tomando los valores negativos, que involucran únicamente términos hacia atrás (backward). Se trunca la serie hasta la primera derivada y se realiza un despeje algebraico para llegar a:

\begin{equation*}
f'(x_i) = \frac{f(x_{i})-f(x_{i-1})}{h} + \mathcal{O}(h)
\label{eq:Ec6_3} \tag{6.3}
\end{equation*}

se observa que se llega a una expresión similar a la de la ecuación [(6.2)](#Ec6_2), pero de esta vez, se tiene en cuenta es el valor anterior al punto $x_i$. También se observa que el error es de orden lineal, por lo que se mantiene un esquema de primer orden.

#### Segundo orden (central)

Una forma de aumentar el orden de estos esquemas, es realizar el truncamiento de la *serie de Taylor* hasta la segunda derivada, hacia adelante y hacia atras, y realizar su resta aritmética.

\begin{equation*}
\begin{split}
f(x_{i+1}) & = f(x_i) + f'(x_i)h + \frac{f''(x_i)h^2}{2!} \\
& - \\
f(x_{i-1}) & = f(x_i) - f'(x_i)h + \frac{f''(x_i)h^2}{2!} \\
\hline \\
f(x_{i+1}) - f(x_{i-1}) & = 2 f'(x_i)h
\end{split}
\label{eq:Ec6_4} \tag{6.4}
\end{equation*}

de la anterior ecuación, despejando el término que corresponde a la primera derivada queda:

\begin{equation*}
\begin{split}
f'(x_i) = \frac{f(x_{i+1}) - f(x_{i-1})}{2h} + \mathcal{O}(h^2)
\end{split}
\label{eq:Ec6_5} \tag{6.5}
\end{equation*}

se llega al esquema de diferencias finitas central para la primera derivada, que es de orden dos, es decir, si se disminuye el tamaño de paso, $h$, a la mitad, el error se disminuye una cuarta partes. En principio, esta es una mejor aproximación que los dos esquemas anteriores. La selección del esquema dependerá de la disponibilidad de puntos y del fenómeno físico a tratar.

#### Resumen esquemas para la primera derivada

Como la serie de Taylor es infinita, se podrían determinar infinitos esquemas de diferentes ordenes para la primera derivada. En la siguiente tabla se presentan algunos esquemas de diferencias finitas para la primera derivada de diferentes órdenes. Se deja al estudiante la consulta de otros esquemas.

|***Esquema***|***Función***|***Error***|
|:-----:|:-----:|:---:|
|***Forward***|$$f´(x_0)=\frac{f(x_0+h)-f(x_0)}{h}$$|$$\mathcal{O}(h)$$|
|       |$$f´(x_0)=\frac{-3f(x_0)+4f(x_0+h)-f(x_0+2h)}{2h}$$|$$\mathcal{O}(h^2)$$|
|***Central***|$$f´(x_0)=\frac{f(x_0+h)-f(x_0-h)}{2h}$$|$$\mathcal{O}(h^2)$$|
|       |$$f´(x_0)=\frac{f(x_0-2h)-8f(x_0-h)+8f(x_0+h)-f(x_0+2h)}{12h}$$|$$\mathcal{O}(h^4)$$|
|***Backward***|$$f´(x_0)=\frac{f(x_0)-f(x_0-h)}{h}$$|$$\mathcal{O}(h)$$|
|        |$$f´(x_0)=\frac{f(x_0-2h)-4f(x_0-h)+3f(x_0)}{2h}$$|$$\mathcal{O}(h^2)$$|

### Esquemas para la segunda derivada en 1D

Siguiendo con la misma forma de abordar el problema para la primera derivada, si se amplian los términos en la serie de Taylor hasta la tercera derivada tanto hacia adelante como hacia atrás, y se suman, se llega a:

\begin{equation*}
\begin{split}
f(x_{i+1}) & = f(x_i) + f'(x_i)h + \frac{f''(x_i)h^2}{2!} + \frac{f'''(x_i)h^3}{3!}\\
& + \\
f(x_{i-1}) & = f(x_i) - f'(x_i)h + \frac{f''(x_i)h^2}{2!} - \frac{f'''(x_i)h^3}{3!}\\
\hline \\
f(x_{i+1}) + f(x_{i-1}) & = 2 f(x_i) + 2f''(x_i)\frac{h^2}{2!} + \mathcal{O}(h^3)
\end{split}
\label{eq:Ec6_6} \tag{6.6}
\end{equation*}

Despejando para el término de la segunda derivada, se llega a:

\begin{equation*}
\begin{split}
f''(x_i) = \frac{f(x_{i+1}) - 2f(x_i) + f(x_{i-1})}{h^2} + \mathcal{O}(h^3)
\end{split}
\label{eq:Ec6_7} \tag{6.7}
\end{equation*}

Que corresponde a un esquema de diferencias finitas de segundo orden para la segunda derivada. A este esquema también se le llama "*molécula de tres puntos*"

Igual que para la primera derivada, se pueden determinar infinitos esquemas de diferentes órdenes para la segunda derivada, y derivadas superiores. A continuación se muestra un cuadro resumen de algunos esquemas de diferencias finitas para la segunda derivada. Se deja al estudiante la revisión de esquemas de mayor orden para la segunda derivada y derivadas superiores.

|***Esquema***|***Función***|***Error***|
|:-----:|:-----:|:---:|
|***Forward***|$$f''(x_0)=\frac{f(x_0)-2f(x_0+h)+f(x_0+2h)}{h^2}$$|$$\mathcal{O}(h)$$|
|       |$$f''(x_0)=\frac{2f(x_0)-5f(x_0+h)+4f(x_0+2h)-f(x_0+3h)}{h^2}$$|$$\mathcal{O}(h^2)$$|
|***Central***|$$f''(x_0)=\frac{f(x_0-h)-2f(x_0)+f(x_0+h)}{h^2}$$|$$\mathcal{O}(h^2)$$|
|       |$$f''(x_0)=\frac{-f(x_0-2h)+16f(x_0-h)-30f(x_0)+16f(x_0+h)-f(x_0+2h)}{12h^2}$$|$$\mathcal{O}(h^4)$$|
|***Backward***|$$f''(x_0)=\frac{f(x_0-2h)-2f(x_0-h)+f(x_0)}{h}$$|$$\mathcal{O}(h^2)$$|
|        |$$f''(x_0)=\frac{-f(x_0-3h)+4f(x_0-2h)-5f(x_0-h)+2f(x_0)}{h^2}$$|$$\mathcal{O}(h^2)$$|

### Ejemplos de implmentación computacional

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

sym.init_printing(use_latex='mathjax')
```

```python
def f(x):
    return x**2 - x + 1
```

```python
def d1forward(x0, h):
    return (f(x0 + h) - f(x0)) / h
```

```python
def d1backward(x0, h):
    return (f(x0) - f(x0 - h)) / h
```

```python
def d1central(x0, h):
    return (f(x0 + h) - f(x0 - h) ) / (2 * h)
```

```python
x = 3.5
h = 0.1
```

```python
d1f = d1forward(x, h)
print("El valor de la primera derivada en {0:4.2f} usando forward con un tamaño de paso {2:6.5f} es: {1:6.5f}".format(x,d1f,h))
d1b = d1backward(x, h)
print("El valor de la primera derivada en {0:4.2f} usando backward con un tamaño de paso {2:6.5f} es: {1:6.5f}".format(x,d1b,h))
d1c = d1central(x, h)
print("El valor de la primera derivada en {0:4.2f} usando central con un tamaño de paso {2:6.5f} es: {1:6.5f}".format(x,d1c,h))
```

### Esquemas en 2D

De la serie truncada de Taylor en 2D, sea $\phi=\phi(x,y)$

\begin{equation*}
\begin{split}
\phi(x_{i+1},y_{i+1})=\phi(x_i,y_i)+ \left.\Delta x \frac{\partial \phi}{\partial x}\right|_{i,j}+ \left.\Delta y \frac{\partial \phi}{\partial y}\right|_{i,j} + \left.\frac{(\Delta x)^2}{2!}\frac{\partial^2\phi}{\partial x^2}\right|_{i,j} + \left. 2\frac{(\Delta x)(\Delta y)}{2!}\frac{\partial^2 \phi}{\partial x \partial y} \right|_{i,j}+ \left.\frac{(\Delta y)^2}{2!}\frac{\partial^2\phi}{\partial y^2}\right|_{i,j} + R_N
\end{split}
\label{eq:Ec6_8} \tag{6.8}
\end{equation*}

De la anterior ecuación, el único término que faltaría por determinar es el correspondiente a la derivada mixta en $x$ y $y$, que está dada por:

\begin{equation*}
\begin{split}
\frac{\partial^2 \phi}{\partial x \partial y} = \frac{\phi_{i+1,j+1}-\phi_{i+1,j-1}-\phi_{i-1,j+1}+\phi_{i-1,j-1}}{4(\Delta x)(\Delta y)} + O \left[ (\Delta x)^2,(\Delta y)^2 \right ]
\end{split}
\label{eq:Ec6_9} \tag{6.9}
\end{equation*}

### Término difusivo no uniforme en 1D

\begin{equation*}
\begin{split}
\frac{\partial}{\partial x}\left( \Gamma \frac{\partial \phi}{\partial x} \right)
\end{split}
\label{eq:Ec6_10} \tag{6.10}
\end{equation*}

donde $\Gamma$ es el coeficiente de difusión o viscosidad

\begin{equation*}
\begin{split}
\left. \frac{\partial}{\partial x}\left( \Gamma \frac{\partial \phi}{\partial x} \right) \right |_{i}=\frac{\left(\Gamma \frac{\partial \phi}{\partial x}\right)_{i+1/2}-\left(\Gamma \frac{\partial \phi}{\partial x}\right)_{i-1/2}}{\Delta x}+O(\Delta x)^2
\end{split}
\label{eq:Ec6_11} \tag{6.11}
\end{equation*}

Se realiza entonces una aproximación en los puntos $x_{i \pm 1/2}$

\begin{equation*}
\begin{split}
\left. \frac{\partial}{\partial x}\left( \Gamma \frac{\partial \phi}{\partial x} \right) \right |_{i} = \frac{\Gamma_{i+1/2}\frac{\phi_{i+1}-\phi_i}{\Delta x}-\Gamma_{i-1/2}\frac{\phi_{i}-\phi_{i-1}}{\Delta x}}{\Delta x}+O(\Delta x)^2
\end{split}
\label{eq:Ec6_12} \tag{6.12}
\end{equation*}

simplificando,

\begin{equation*}
\begin{split}
\left. \frac{\partial}{\partial x}\left( \Gamma \frac{\partial \phi}{\partial x} \right) \right |_{i} = \Gamma_{i+1/2} \frac{\phi_{i+1}-\phi_i}{(\Delta x)^2}-\Gamma_{i-1/2}\frac{\phi_{i}-\phi_{i-1}}{(\Delta x)^2}+O(\Delta x)^2
\end{split}
\label{eq:Ec6_13} \tag{6.13}
\end{equation*}

Ahora, promediando los valores de $\Gamma_{i \pm1/2}$,

\begin{equation*}
\begin{split}
\Gamma_{i+1/2}=\frac{\Gamma_i+\Gamma_{i+1}}{2} \quad\quad \Gamma_{i-1/2}=\frac{\Gamma_i+\Gamma_{i-1}}{2}
\end{split}
\label{eq:Ec6_14} \tag{6.14}
\end{equation*}

## Molécula computacional de una aproximación en Diferencias Finitas

### Ejemplo Ecuación de Laplace

En cálculo vectorial, la [ecuación de Laplace](https://en.wikipedia.org/wiki/Laplace%27s_equation) es una ecuación en derivadas parciales de segundo orden de tipo elíptico, que recibe ese nombre en honor al físico y matemático Pierre-Simon Laplace. La ecuació de Laplace en dos dimensiones se expresa como:

\begin{equation*}
\begin{split}
\nabla^2 \phi & = 0 \\
& = \left. \frac{\partial^2 \phi}{\partial x^2} \right|_{i,j} + \left. \frac{\partial^2 \phi}{\partial y^2} \right|_{i,j}
\end{split}
\label{eq:Ec6_15} \tag{6.15}
\end{equation*}

Empleando un esquema de diferencias finitas central para la segunda derivada, se tiene la discretización espacial como:

\begin{equation*}
\begin{split}
\nabla^2 \phi & = \frac{\phi_{i+1,j}-2\phi_{i,j}+\phi_{i-1,j}}{(\Delta x)^2} + \frac{\phi_{i,j+1}-2\phi_{i,j}+\phi_{i,j-1}}{(\Delta y)^2} + O \left[ (\Delta x)^2,(\Delta y)^2 \right ]
\end{split}
\label{eq:Ec6_16} \tag{6.16}
\end{equation*}

El anterior esquema genera lo que se denomina como *molécula de cinco puntos* o *fórmula de cinco puntos*

 Fuente: Propio

## Discretización de ecuaciones por el método de Diferencias Finitas

### Estado estacionario

[Ecuaciones en estado estacionario](https://en.wikipedia.org/wiki/Steady_state), son ecuaciones que no dependel del tiempo. A manera de ejemplo, considérese la ecuación de Laplace en $2D$ discretizada por la fórmula de cinco puntos en una región $\Omega$ cuadrada con condiciones de contorno Dirichlet $\phi(x,y)=\Theta(x,y)$ en la frontera $\delta \Omega$

\begin{equation*}
\begin{split}
\nabla^2 \phi & = \frac{\partial^2 \phi}{\partial x^2} + \frac{\partial^2 \phi}{\partial y^2}=0
\end{split}
\label{eq:Ec6_17} \tag{6.17}
\end{equation*}

Discretizando esta ecuación con la ecuación [(6.16)](#6_16), y reordenando los términos, se llega a:

\begin{equation*}
\begin{split}
\phi_{i+1,j}+\phi_{i-1,j}-2(1+\beta^2)\phi_{i,j}+\beta^2(\phi_{i,j+1}+\phi_{i,j-1})=0
\end{split}
\label{eq:Ec6_18} \tag{6.18}
\end{equation*}

donde $\beta=\frac{\Delta x}{\Delta y}$.

Teniendo una discretización del dominio como se muestra en la figura:

 Fuente: Propio

La ecuación [(6.18)](#Ec6_18) será válida para todos los puntos discretos $(i,j)$ al interior de la región (nodos en color naranja). Dando valores a los subíndices $(i,j)$ desde $0$ hasta $4$ y reemplazando en la ecuación [(6.18)](#Ec6_18), se llega al siguente sistema de ecuaciones:

\begin{equation*}
\begin{split}
(1,1) : \phi_{2,1} + \phi_{0,1} - 2(1+\beta^2)\phi_{1,1} + \beta^2(\phi_{1,2} + \phi_{1,0})=0 \\
(2,1) : \phi_{3,1} + \phi_{1,1} - 2(1+\beta^2)\phi_{2,1} + \beta^2(\phi_{2,2} + \phi_{2,0})=0 \\
(3,1) : \phi_{4,1} + \phi_{2,1} - 2(1+\beta^2)\phi_{3,1} + \beta^2(\phi_{3,2} + \phi_{3,0})=0 \\
(1,2) : \phi_{2,2} + \phi_{0,2} - 2(1+\beta^2)\phi_{1,2} + \beta^2(\phi_{1,3} + \phi_{1,1})=0 \\
(2,2) : \phi_{3,2} + \phi_{1,2} - 2(1+\beta^2)\phi_{2,2} + \beta^2(\phi_{2,3} + \phi_{2,1})=0 \\
(3,2) : \phi_{4,2} + \phi_{2,2} - 2(1+\beta^2)\phi_{3,2} + \beta^2(\phi_{3,3} + \phi_{3,1})=0 \\
(1,3) : \phi_{2,3} + \phi_{0,3} - 2(1+\beta^2)\phi_{1,3} + \beta^2(\phi_{1,4} + \phi_{1,2})=0 \\
(2,3) : \phi_{3,3} + \phi_{1,3} - 2(1+\beta^2)\phi_{2,3} + \beta^2(\phi_{2,4} + \phi_{2,2})=0 \\
(3,3) : \phi_{4,3} + \phi_{2,3} - 2(1+\beta^2)\phi_{3,3} + \beta^2(\phi_{3,4} + \phi_{3,2})=0
\end{split}
\label{eq:Ec6_19} \tag{6.19}
\end{equation*}

Expresando el anterior sistema de ecuaciones lineales en forma matricial se llega a un sistema $Ax=b$

\begin{align*}
\left[\begin{array}{ccccccccc}
  \gamma &     1  &    0   &\beta^2 &    0    &    0    &    0    &    0    &    0    \\
  1      & \gamma &    1   &    0   & \beta^2 &    0    &    0    &    0    &    0    \\
  0      &     1  & \gamma &    0   &    0    & \beta^2 &    0    &    0    &    0    \\
  \beta^2&     0  &    0   & \gamma &    1    &    0    & \beta^2 &    0    &    0    \\
  0      & \beta^2&    0   &    1   & \gamma  &    1    &    0    & \beta^2 &    0    \\
  0      &     0  & \beta^2&    0   &    1    & \gamma  &    0    &    0    & \beta^2 \\
  0      &     0  &     0  & \beta^2&    0    &    0    & \gamma  &    1    &    0    \\
  0      &     0  &     0  &     0  & \beta^2 &    0    &    1    & \gamma  &    1    \\
  0      &     0  &     0  &     0  &     0   & \beta^2 &    0    &    1    & \gamma
\end{array}\right]
\begin{Bmatrix}
\phi_{1,1}\\
\phi_{2,1}\\
\phi_{3,1}\\
\phi_{1,2}\\
\phi_{2,2}\\
\phi_{3,2}\\
\phi_{1,3}\\
\phi_{2,3}\\
\phi_{3,3}
\end{Bmatrix}
= \begin{Bmatrix}
-\phi_{0,1}-\beta^2\phi_{1,0}\\
-\beta^2\phi_{2,0}\\
-\phi_{4,1}-\beta^2\phi_{3,0}\\
-\phi_{0,2}\\
0\\
-\phi_{4,2}\\
-\phi_{0,3}-\beta^2\phi_{1,4}\\
-\beta^2\phi_{2,4}\\
-\phi_{4,3}-\beta^2\phi_{3,4}
\end{Bmatrix}
\end{align*}

donde $\gamma=-2(1+\beta^2)$

### Ejemplo: ecuación de transporte en estado estacionario en 1D

Resuelva la ecuación de transporte de convección - difusión homogénea en estado estacionario en 1D dada por

$$\frac{d^2\phi}{dx^2}-\beta \frac{d\phi}{dx}=0$$

sometida a las condiciones de contorno

$$\phi(0)=0 \quad \quad \phi(1) = 1$$

Se va a resolver numéricamente esta ecuación utilizando  un esquema de diferencias finitas centrales (Leapfrog). Discretizando la ecuación dada por los esquemas indicados, se llega a:

$$-\frac{1}{h^2} \left[ \phi(x_{i-1})-2\phi(x_i)+\phi(x_{i+1})\right]+\frac{\beta}{2h}\left[ \phi(x_{i+1})-\phi(x_{i-1})\right]=0$$

organizando términos,

$$(2+\beta h)\phi(x_{i-1})-4\phi(x_i)+(2-\beta h)\phi(x_{i+1})=0$$

tomando como tamaño de paso inicialmente el valor de $h=0.1$ y por las condiciones de contorno dadas, se tiene la siguiente distribución de nodos y elementos ("malla"):

 Fuente: Propio

Dando valores a $i$ desde $1$ hasta $9$, se tiene el siguiente conjunto de ecuaciones:

\begin{align*}
\begin{array}{cccc}
i=1:& -4\phi(x_1)+(2-\beta h)\phi(x_2)&=-(2+\beta h)\phi(x_0) \\
i=2:& (2+\beta h)\phi(x_1)-4\phi(x_2)+(2-\beta h)\phi(x_3)&=0 \\
i=3:& (2+\beta h)\phi(x_2)-4\phi(x_3)+(2-\beta h)\phi(x_4)&=0 \\
i=4:& (2+\beta h)\phi(x_3)-4\phi(x_4)+(2-\beta h)\phi(x_5)&=0 \\
i=5:& (2+\beta h)\phi(x_4)-4\phi(x_5)+(2-\beta h)\phi(x_6)&=0 \\
i=6:& (2+\beta h)\phi(x_5)-4\phi(x_6)+(2-\beta h)\phi(x_7)&=0 \\
i=7:& (2+\beta h)\phi(x_6)-4\phi(x_7)+(2-\beta h)\phi(x_8)&=0 \\
i=8:& (2+\beta h)\phi(x_7)-4\phi(x_8)+(2-\beta h)\phi(x_9)&=0 \\
i=9:& (2+\beta h)\phi(x_8)-4\phi(x_9)&=-(2-\beta h)\phi(x_{10})
\end{array}
\end{align*}

los nodos $i=0$ e $i=10$ corresponderán al primer y último nodo de la malla, cuyos valores $\phi(x_0)$ y $\phi(x_{10})$ son dados por las condiciones de contorno

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

```python
# Establecer las CCs - Izquierda

x0 = 0
u0 = 0

# Establecer las CCs - Derecha

x1 = 1
u1 = 1

```

```python
# Ingreso de datos
n = int(input("Ingrese el número de nodos: "))
```

```python
beta = float(input("Ingrese el valor de la velocidad: "))
```

```python
# Inicialización de arreglos
A = np.zeros((n-1, n-1)) # Matriz de coeficientes
b = np.zeros((n-1, 1))   # Vector de térinos independientes
u_lf = np.zeros((n-1, 1))   # Vector de incógnitas
uex = np.zeros((n-1, 1)) # Vector con la solución exacta
```

```python
# cálculos iniciales
h = (x1 - x0) / n  # Tamaño de paso Dx
x = np.linspace(x0, x1, n + 1)
```

```python
# Ensamblaje de la matriz de coeficientes

for i in range(1, n-1):
    A[i-1, i]   = 2 - beta * h
    A[i-1, i-1] = -4
    A[i, i - 1] = 2 + beta * h
    b[i] = 0

A[n-2,n-2] = -4
b[0] = -(2 + beta * h) * u0
b[n-2] = -(2 - beta * h ) * u1
```

```python
# Solución del SEL Au=b
u_lf = np.linalg.solve(A,b)
```

```python
# Adicionando las CC en x0 y x1

u_lf = np.insert(u_lf, 0, u0)
u_lf = np.insert(u_lf, n, u1)
```

```python
# definición de la función exacta
def exacta(x):
    return (np.exp(beta * x) - 1) / (np.exp(beta) - 1)
```

```python
# Graficación
plt.xlabel("x")
plt.ylabel("u")
plt.title("Exacta vs Leapfrog")
plt.plot(x, u_lf, 'r', label="Leapfrog")
plt.plot(x, exacta(x), 'b', label=('Exacta'))
plt.legend(loc='center left')
plt.grid(True)
plt.show()
```

### Ecuaciones No estacionarias

#### Introducción

Las ecuaciones [no estacionarias](https://en.wikipedia.org/wiki/Transient_state) (transient, en inglés) o dependientes del tiempo, exigen el cálculo de la solución en intervalos sucesivos de tiempo

\begin{equation*}
\begin{split}
t_0+\Delta t, t_0 + 2\Delta t, t_0 + 3\Delta t, \ldots, t_f - \Delta t, t_f
\end{split}
\label{eq:Ec6_20} \tag{6.20}
\end{equation*}

Una de las ecuaciones más representativas de este tipo es la [ecuación de calor](https://en.wikipedia.org/wiki/Heat_equation), dada por

\begin{equation*}
\begin{split}
\frac{\partial T}{\partial t}=\alpha\frac{\partial^2T}{\partial x^2}
\end{split}
\label{eq:Ec6_21} \tag{6.21}
\end{equation*}

sometida a las condiciones auxiliares:

- ***Iniciales***

\begin{equation*}
\begin{split}
T_i^0=T(i\Delta x,t_0)=T_{inic}
\end{split}
\label{eq:Ec6_22} \tag{6.22}
\end{equation*}

- ***Contorno:*** tipo Dirichlet

\begin{equation*}
\begin{split}
T(0,t) &= T_0 \\
T(IM,t) &= T_{IM}
\end{split}
\label{eq:Ec6_23} \tag{6.23}
\end{equation*}

#### Discretización de ecuaciones no estacionarias

La ecuación [(6.21)](#Ec6_21) tiene dos variables independientes, $x$ y $t$. Se busca determinar un vector $T^{(n)}$ que contenga las temperaturas, para cada nivel de tiempo $n$, y para todos los nodos de la malla.

 Fuente: Propio

Filas del mismo color representan la discretización espacial en $1D$. La dirección vertical representa el tiempo.

Ahora, discretizando la ecuación [(6.21)](#Ec6_21) en la coordenada espacial en un punto $x_i$, empleando un [esquema de diferencias finitas central para la segunda derivada](#DFC_d2y) dado por la ecuación [(6.7)](#Ec6_7):

\begin{equation*}
\begin{split}
\left. \frac{\partial T}{\partial t} \right|_{i}=\alpha\frac{T_{i-1}-2T_i+T_{i+1}}{(\Delta x)^2} + \mathcal{O}(\Delta x)^2
\end{split}
\label{eq:Ec6_24} \tag{6.24}
\end{equation*}

Para discretizar la ecuación [(6.21)](#Ec6_21) en la coordenada temporal se emplearán dos enfoques diferentes: [explícita e implícita](https://en.wikipedia.org/wiki/Explicit_and_implicit_methods):

#### Discretización explícita

La ecuación [(6.24)](#Ec6_24) discretizada en un nivel de tiempo $n$ toma la forma:

\begin{equation*}
\begin{split}
\left. \frac{\partial T}{\partial t} \right|_{i}^{(n)}=\alpha\frac{T_{i-1}^{(n)}-2T_i^{(n)}+T_{i+1}^{(n)}}{(\Delta x)^2} + \mathcal{O}(\Delta x)^2
\end{split}
\label{eq:Ec6_25} \tag{6.25}
\end{equation*}

Relacionando los valores de $T_i^{(n+1)}$ con los de $T_i^{(n)}$, utilizando un esquema en Diferencias Finitas hacia adelante (Forward) para la primera derivada:

\begin{equation*}
\begin{split}
\left. \frac{\partial T}{\partial t} \right|_{i}^{(n)}=\frac{T_{i}^{(n+1)}-T_{i}^{(n)}}{\Delta t} + \mathcal{O}(\Delta t)
\end{split}
\label{eq:Ec6_26} \tag{6.26}
\end{equation*}

Igualando las ecuaciones [(6.25)](#Ec6_25) y [(6.26)](#Ec6_26), se llega al siguiente esquema:

\begin{equation*}
\begin{split}
\frac{T_{i}^{(n+1)}-T_{i}^{(n)}}{\Delta t} = \alpha\frac{T_{i-1}^{(n)}-2T_i^{(n)}+T_{i+1}^{(n)}}{(\Delta x)^2} + \mathcal{O} \left[ \Delta t, (\Delta x)^2 \right]
\end{split}
\label{eq:Ec6_27} \tag{6.27}
\end{equation*}

Por último, de la anterior ecuación se despeja la variable temporal en el tiempo siguiente, $T_i^{(n+1)}$ y se llega al esquema llamado *Método de Euler explícito*:

\begin{equation*}
\begin{split}
T_{i}^{(n+1)} = T_{i}^{(n)} + \alpha (\Delta t) \frac{T_{i-1}^{(n)}-2T_i^{(n)}+T_{i+1}^{(n)}}{(\Delta x)^2}
\end{split}
\label{eq:Ec6_28} \tag{6.28}
\end{equation*}

El lado derecho de la ecuación [(6.28)](#Ec6_28) solamente depende de los valores de $T$ en el nivel de tiempo $n$.

La molécula de diferencias finitas resultante se observa en la siguiente figura

 Fuente: Propio

***Observaciones:***

- Por las C.Aux. cuando $n=0$ y $x=0$, los valores de $T_i^{(0)}$ son conocidos

- Avanzando en el tiempo se obtiene un sistema de ecuaciones lineales, cuyas incógnitas son los valores de $T^{(n+1)}$.

- Solamente se puede calcular $T^{(n+1)}$ después de conocer los valores en $T^{(n)}$.

***Dificultades:***

- Excluyen valores de C.C. en el nivel de tiempo $n$, que tiene influencia sobre la solución del problema en el nivel de tiempo $n+1$.

 Fuente: Propio

- La solución en los nodos en amarillo no tiene efecto sobre la solución en el punto interno $(x,t)$ en el nivel de tiempo $n+2$.

- La solución en el punto $(x,t)$ es influenciada solamente por las C.C. del nivel de tiempo $n$, y no por el valor de la C.C. en el nivel de tiempo $n+2$.

- Efecto exclusivamente numérico, equivalente a un atraso en la propagación de las C.C. adentro del dominio.

- Físicamente incorrecto.

- Este fenómeno causa grandes errores cuando las C.C. presentan variaciones considerables entre instantes sucesivos de tiempo.

#### Discretización implícita

Proceso de Marcha en el tiempo (igual que en el Explícito). $T^{(1)}$ es calculado a partir de las condiciones iniciales. $T^{(2)}, T^{(3)}, \ldots$, son calculados secuencialmente, precisando de todos los valores del paso anterior. La diferencia está en la forma como es calculado $T^{(n+1)}$. La discretización espacial se realiza en el nivel de tiempo $n+1$.

\begin{equation*}
\begin{split}
\frac{T_{i}^{(n+1)}-T_{i}^{(n)}}{\Delta t} = \alpha\frac{T_{i-1}^{(n+1)}-2T_i^{(n+1)}+T_{i+1}^{(n+1)}}{(\Delta x)^2} + \mathcal{O} \left[ \Delta t, (\Delta x)^2 \right]
\end{split}
\label{eq:Ec6_29} \tag{6.29}
\end{equation*}

reorganizando la ecuación para dejar a un lado los términos evaluados en $T^{(n+1)}$ y al otro lado los términos evaluados en $T^{(n)}$, obteniéndose la expresión del *método de Euler implícito*.

\begin{equation*}
\begin{split}
-sT_{i-1}^{(n+1)}+(1+2s)T_{i}^{(n+1)}-sT_{i+1}^{(n+1)}=T_{i}^{(n)}
\end{split}
\label{eq:Ec6_30} \tag{6.30}
\end{equation*}

donde $s=\frac{\alpha (\Delta t)}{(\Delta x)^2}$

La molécula de diferencias finitas resultante para el método implicito es:

 Fuente: Propio

La ecuación [(6.30)](#Ec6_30) por sí sola no permite determinar el valor de $T_{i}^{(n+1)}$. A cada paso de tiempo aparecen tres incógnitas nuevas: $T_{i-1}^{(n+1)}, T_{i}^{(n+1)}$ y $T_{i+1}^{(n+1)}$. El valor $T_{i}^{(n+1)}$ está definido “implícitamente”.

Escribiendo las ecuaciones asociadas a cada nodo de la malla discretizada

\begin{equation*}
\begin{split}
-sT_{0}^{(n+1)}+(1+2s)T_{1}^{(n+1)}-sT_{2}^{(n+1)} &= T_{1}^{(n)}\\
-sT_{1}^{(n+1)}+(1+2s)T_{2}^{(n+1)}-sT_{3}^{(n+1)} &= T_{2}^{(n)}\\
-sT_{2}^{(n+1)}+(1+2s)T_{3}^{(n+1)}-sT_{4}^{(n+1)} &= T_{3}^{(n)}\\
& \vdots \\
-sT_{IM-3}^{(n+1)}+(1+2s)T_{IM-2}^{(n+1)}-sT_{IM-1}^{(n+1)} &= T_{IM-2}^{(n)}\\
-sT_{IM-2}^{(n+1)}+(1+2s)T_{IM-1}^{(n+1)}-sT_{IM}^{(n+1)} &= T_{IM-1}^{(n)}
\end{split}
\label{eq:Ec6_31} \tag{6.31}
\end{equation*}

aplicando las condiciones de contorno en la ecuación [(6.31)](#Ec6_31)

\begin{equation*}
\begin{split}
(1+2s)T_{1}^{(n+1)}-sT_{2}^{(n+1)} &= T_{1}^{(n)} + sT_{0}^{(n+1)}\\
-sT_{1}^{(n+1)}+(1+2s)T_{2}^{(n+1)}-sT_{3}^{(n+1)} &= T_{2}^{(n)}\\
-sT_{2}^{(n+1)}+(1+2s)T_{3}^{(n+1)}-sT_{4}^{(n+1)} &= T_{3}^{(n)}\\
& \vdots \\
-sT_{IM-3}^{(n+1)}+(1+2s)T_{IM-2}^{(n+1)}-sT_{IM-1}^{(n+1)} &= T_{IM-2}^{(n)}\\
-sT_{IM-2}^{(n+1)}+(1+2s)T_{IM-1}^{(n+1)} &= T_{IM-1}^{(n)} + sT_{IM}^{(n+1)}
\end{split}
\label{eq:Ec6_32} \tag{6.32}
\end{equation*}

representando la anterior ecuación en forma matricial, se tiene

\begin{align*}
\left[\begin{array}{ccccccccc}
  1 + 2s &   -s   &    0   &    0   & \ldots &    0    \\
    -s   & 1 + 2s &    -s  &    0   & \ldots &    0    \\
  0      &   -s   & 1 + 2s &    -s  & \ldots &    0    \\
         &        &        & \ddots &        &         \\
  0      & \ldots &    0   &   -s   & 1+2s   &    -s   \\
  0      & \ldots &    0   &    0   &   -s   &   1+2s
  \end{array}\right]
\begin{Bmatrix}
T_{1}^{(n+1)}\\
T_{2}^{(n+1)}\\
T_{3}^{(n+1)}\\
\ldots\\
T_{IM-2}^{(n+1)}\\
T_{IM-1}^{(n+1)}
\end{Bmatrix}
= \begin{Bmatrix}
T_{1}^{(n)}+ sT_{0}^{(n+1)}\\
T_{2}^{(n)}\\
T_{3}^{(n)}\\
\vdots\\
T_{IM-2}^{(n)}\\
T_{IM-1}^{(n)}+sT_{IM}^{(n+1)}
\end{Bmatrix}
\label{eq:Ec6_33} \tag{6.33}
\end{align*}

#### Resumen

***Métodos explícitos:***

- Las ecuaciones resultantes son independientes, por lo tanto, la solución es directa.

***Métodos implícitos:***

- Las ecuaciones resultantes son acopladas, por lo que se debe resolver un sistema de ecuaciones lineales en cada instante de tiempo!

### Discretización multidimensional

Como ejemplo, tomemos la ecuación de difusión no permanente en 2D

\begin{equation*}
\begin{split}
\frac{\partial \phi}{\partial t} = \alpha \nabla^2 \phi = \alpha \left( \frac{\partial^2 \phi}{\partial x^2} + \frac{\partial^2 \phi}{\partial y^2} \right)
\end{split}
\label{eq:Ec6_34} \tag{6.34}
\end{equation*}

un dominio computacional en 2D, discretizado por una serie de nodos y elementos, se observa en la siguiente figura.

 Fuente: Propio

- Empleando el método de Euler explícito y diferencias finitas centrales de segundo orden, se llega a

\begin{equation*}
\begin{split}
\frac{\phi_{i,j}^{(n+1)}-\phi_{i,j}^{(n)}}{\Delta t} = \alpha \frac{\phi_{i+1,j}^{(n)}-2\phi_{i,j}^{(n)}+\phi_{i-1,j}^{(n)}}{(\Delta x)^2} + \alpha \frac{\phi_{i,j+1}^{(n)}-2\phi_{i,j}^{(n)}+\phi_{i,j-1}^{(n)}}{(\Delta y)^2} + \mathcal{O} \left[(\Delta t),(\Delta x)^2,(\Delta y)^2\right]
\end{split}
\label{eq:Ec6_35} \tag{6.35}
\end{equation*}

organizando términos,

\begin{equation*}
\begin{split}
\phi_{i,j}^{(n+1)}=\phi_{i,j}^{(n)} + s_x \left( \phi_{i+1,j}^{(n)} - 2\phi_{i,j}^{(n)} + \phi_{i-1,j}^{(n)} \right) + s_y \left( \phi_{i,j+1}^{(n)} - 2\phi_{i,j}^{(n)} + \phi_{i,j-1}^{(n)} \right)
\end{split}
\label{eq:Ec6_36} \tag{6.36}
\end{equation*}

- Ahora empleando el método de Euler implícito y diferencias finitas centrales de segundo orden,

\begin{equation*}
\begin{split}
-s_x \left( \phi_{i+1,j}^{(n+1)} + \phi_{i-1,j}^{(n+1)} \right) + \left[1+2\left( s_x+s_y \right) \right] \phi_{i,j}^{(n+1)} - s_y \left( \phi_{i,j+1}^{(n+1)} + \phi_{i,j-1}^{(n+1)} \right) = \phi_{i,j}^{(n)}
\end{split}
\label{eq:Ec6_37} \tag{6.37}
\end{equation*}

donde $s_x=\frac{\alpha (\Delta t)}{(\Delta x)^2}$ y $s_y=\frac{\alpha (\Delta t)}{(\Delta y)^2}$

## Consistencia, Convergencia y Estabilidad

### Introducción

La solución calculada se aproxima, de alguna forma, a la solución “real” de la EDP?

El *Error Local de Truncamiento (ELT)* influencia en la calidad de la aproximación numérica. Cuándo y sobre qué condiciones la solución de las ecuaciones lineales es representativa de la solución real de la EDP?

Depende de principalmente de tres elementos: [Consistencia, Estabilidad y Convergencia](https://en.wikipedia.org/wiki/Numerical_methods_for_ordinary_differential_equations#Analysis)

- Consistencia de las ecuaciones de diferencias finitas.

- Estabilidad y convergencia del método numérico empleado.

### Consistencia

En la discretización, el $ELT \rightarrow 0$ cuando $\Delta x, \Delta t \rightarrow 0$, es decir, si

\begin{equation*}
\begin{split}
\lim \limits_{\Delta x \to 0}\frac{\phi_{i+1}-\phi_i}{\Delta x}=\frac{\partial \phi}{\partial x}
\end{split}
\label{eq:Ec6_38} \tag{6.38}
\end{equation*}

Verificando:

- Se substituye la expansión en series de Taylor en la E.D.F.

- Se hace que $\Delta x, \Delta t \rightarrow 0$

- $ELT \rightarrow 0$?, la discretización es consistente con la EDP.

- $ELT\nrightarrow 0$?, la discretización no es consistente con la EDP.

 Fuente: Propio

La *consistencia* es una condición necesaria para la *convergencia*.

### Convergencia

*Cómo afecta la discretización hecha a la EDP en un determinado número arbitrario de pasos de tiempo?*

Si la solución numérica se aproxima a la solución exacta de la EDP, conforme $\Delta x, \Delta t \rightarrow 0$, se dice que el método es convergente.

 Fuente: Propio

### Estabilidad

Un método numérico es estable si cualquier error o perturbación en la solución no se amplifican sin límites. Ese crecimiento es relativo al método numérico exclusivamente y no a la física del problema. La estabilidad trata sobre el crecimiento, o disminución, de los errores introducidos en los cálculos.

- En los métodos no–permanentes, la estabilidad garantiza que la solución numérica sea limitada.

- Para los métodos iterativos de la solución de sistemas lineales un método estable es aquel que no diverge conforme las iteraciones avanzan.

***Cuáles son estos errores?***

- Condiciones de contorno e iniciales aproximadas de forma incorrecta

- Acumulación de errores de redondeo durante los cálculos.

***Cómo se evitan?***

- Correcta discretización de las condiciones auxiliares.

- No puede ser evitado, pero sí controlado.

***Con relación a la estabilidad los métodos numéricos pueden clasificarse en:***

- ***Condicionalmente estables:*** Deben satisfacer alguna condición de estabilidad. Métodos explícitos.

- ***Incondicionalmente estables:*** No necesitan satisfacer condiciones de estabilidad. Métodos Implícitos.

- ***Incondicionalmente inestables:**** No existen valores de $\Delta t$ que permitan soluciones estables. Deben evitarse.

#### Teorema de Lax

El [Teorema de Lax](https://en.wikipedia.org/wiki/Lax_equivalence_theorem) (en honor al matemático [Peter Lax](https://en.wikipedia.org/wiki/Peter_Lax)) establece que:

> *Dado un problema de valores iniciales bien planteado matemáticamente y una aproximación en diferencias finitas que satisface la condición de Consistencia, entonces la Estabilidad es condición necesaria y suficiente para la Convergencia.*

#### Condición CFL

la condición de [Courant-Friedrichs-Lewy](https://en.wikipedia.org/wiki/Courant%E2%80%93Friedrichs%E2%80%93Lewy_condition) (condición CFL) es una condición de convergencia de ecuaciones diferenciales en derivadas parciales solucionadas mediante ciertos algortimos (no confundir con estabilidad numérica). Como consecuencia de esta condición, el paso de tiempo debe ser inferior a un cierto valor, si no, la simulación producirá resultados incorrectos. Está condición se llama así en honor a [Richard Courant](https://en.wikipedia.org/wiki/Richard_Courant), [Kurt Friedrichs](https://en.wikipedia.org/wiki/Kurt_Otto_Friedrichs) y [Hans Lewy](https://en.wikipedia.org/wiki/Hans_Lewy) que la describieron en un [artículo en 1928](https://web.stanford.edu/class/cme324/classics/courant-friedrichs-lewy.pdf).

La condición *CFL* se representa comúnmente para esquemas de advección puros (es decir ignorando los términos de difusión y reacción) como:

\begin{equation*}
\begin{split}
\frac{u\cdot \Delta t}{\Delta x} < C
\end{split}
\label{eq:Ec6_39} \tag{6.39}
\end{equation*}

En un caso bidimensional la ecuación anterior se transforma en:

\begin{equation*}
\begin{split}
\frac{u_{x}\cdot \Delta t}{\Delta x} + \frac{u_{y}\cdot \Delta t}{\Delta y} < C
\end{split}
\label{eq:Ec6_40} \tag{6.40}
\end{equation*}

La condición CFL puede ser muy limitante para el intervalo de tiempo $\Delta t$, hasta el punto que para ciertas ecuaciones diferenciales en derivadas parciales de cuarto orden es:

\begin{equation*}
\begin{split}
\frac{\Delta t}{\Delta x^4} < C
\end{split}
\label{eq:Ec6_41} \tag{6.41}
\end{equation*}

por lo que se utilizan mejor métodos implícitos para evitarla.

#### Relación Consistencia - Convergencia - Estabilidad

 Fuente: Propio

### Esquema Upwind

Con la intención de suavizar las oscilaciones presentes debido al término convectivo, se planteará un equema numérico tipo [upwind](https://en.wikipedia.org/wiki/Upwind_scheme), que consiste principalmente en adicionar una cierta cantidad de difusión extra al término difusivo teniendo en cuenta la dirección del flujo. El esquema de diferenciación [upwind](https://en.wikipedia.org/wiki/Upwind_differencing_scheme_for_convection) supera la incapacidad del esquema de diferenciación [central](#Ej01_TransportEq). Este esquema está desarrollado para flujos convectivos fuertes con efectos de difusión suprimidos.

Volviendo al enunciado del ejemplo visto en el numeral [3.2](#Ej01_TransportEq), considere la ecuación de transporte de convección - difusión homogénea en estado estacionario en 1D dada por

$$\frac{d^2\phi}{dx^2}-\beta \frac{d\phi}{dx}=0$$

sometida a las condiciones de contorno

$$\phi(0)=0 \quad \quad \phi(1) = 1$$

Empleando alguno de los esquemas de discretizacion para el término de la primera derivada (advectivo) vistos,

\begin{equation*}
\begin{split}
\text{Forward:} \quad\quad \phi'(x_i) = \frac{\phi(x_{i+1})-\phi(x_{i})}{h}
\end{split}
\label{eq:Ec6_42} \tag{6.42}
\end{equation*}

o

\begin{equation*}
\begin{split}
\text{Backward:} \quad\quad \phi'(x_i) = \frac{\phi(x_{i})-\phi(x_{i-1})}{h}
\end{split}
\label{eq:Ec6_43} \tag{6.43}
\end{equation*}

Si se aplica un [análisis de estabilidad de VonNeumann](https://en.wikipedia.org/wiki/Von_Neumann_stability_analysis) se encontrará que el esquema de discretización es inestable.

Una posible solución para intentar evitar las dificultades encontradas en tales formulaciones es el [esquema Upwind](https://en.wikipedia.org/wiki/Upwind_scheme). La Ec. [(6.42)](#Ec6_42) puede estabilizarse sustituyendo la discretización espacial hacia adelante adelante por un esquema de discretización hacia atrás, siempre que la velocidad $\beta>0$. Si $\beta<0$ es negativo, se debe utilizar un esquema de diferencias hacia adelante para asegurar la estabilidad. En esta formulación el término de difusión permanece sin cambios y solo el término convectivo (en forma conservadora) se calcula de la siguiente manera:

\begin{equation*}
\begin{split}
-\beta\frac{\phi(x_{i})-\phi(x_{i-1})}{h} + \text{Término viscoso, si } \beta > 0
\end{split}
\label{eq:Ec6_44} \tag{6.44}
\end{equation*}

o

\begin{equation*}
\begin{split}
-\beta\frac{\phi(x_{i+1})-\phi(x_{i})}{h} + \text{Término viscoso, si } \beta < 0
\end{split}
\label{eq:Ec6_45} \tag{6.45}
\end{equation*}

Una formulación en diferencias finitas de una ecuación de flujo posee la propiedad de transporte si el efecto de una perturbación se convecta (advecta) solo en la dirección de la velocidad.

### Solución de la ecuación de convección-difusión en estado estacionario en 1D empleando el esquema Upwind

En la sección [3.2](#Ej01_TransportEq) se presentó la ecuación de transporte de convección - difusión homoegénea en estado estacionario en 1D dada por

$$-\frac{d^2\phi}{dx^2}+\beta \frac{d\phi}{dx}=0$$

sometida a las condiciones de contorno

$$\phi(0)=0 \quad \quad \phi(1) = 1$$

y se observó que para valores de $\beta$ altos ($>50$ en el ejemplo) se presentaron oscilaciones debidas a la incapacidad del esquema de discretización empleado (de segundo orden tanto para el término convectivo como  para el término difusivo) de capturarlas. Ya con el conocimiento de los elementos de *estabilidad, consistencia* y *convergencia* dados en el numeral anterior, se pueden plantear esquemas de discretización que cumplan con dichas restricciones.

Ahora, se va a resolver numéricamente esta ecuación utilizando  un esquema de diferencias finitas centrales para el término difusivo y hacia atrás, forward, para el término advectivo (según lo indicado para el esquema *Upwind*). Discretizando la ecuación dada por los esquemas indicados, se llega a:

$$\frac{1}{h^2} \left[ \phi(x_{i-1})-2\phi(x_i)+\phi(x_{i+1})\right]-\frac{\beta}{h}\left[ \phi(x_{i})-\phi(x_{i-1})\right]=0$$

organizando términos,

$$(1+\beta h)\phi(x_{i-1})-(2+\beta h)\phi(x_i)+\phi(x_{i+1})=0$$

Se realiza el mismo procedimiento anterior para determinar el sistema de ecuaciones resultantes. A continuación se presenta una codificación del esquema *Upwind* propuesta por el autor.

```python
# Esquema Upwind

import numpy as np
import matplotlib.pyplot as plt

def exacta(x):
    return (np.exp(beta * x) - 1) / (np.exp(beta) - 1)

# establecimiento de las condiciones de contorno a la izquierda

a0 = 0
u0 = 0

# establecimiento de las condiciones de contorno a la derecha

a1 = 1
u1 = 1

# Ingreso de datos
n = int(input("Ingrese la cantidad de nodos: "))
beta = float(input("ingrese el valor de la velocidad, beta: "))

# Inicialización de los diferentes arreglos a emplear
A = np.zeros((n-1, n-1)) # matriz de coeficientes
b = np.zeros((n-1, 1))   # vector de términos independietes
u_upw = np.zeros((n-1, 1))   # vector de incógnitas
uex = np.zeros((n-1,1))  # vector de la solución exacta

h = (a1 - a0) / n            # establece el tamaño de paso Delta x
x = np.linspace(a0, a1, n+1)  # Genera n+1 puntos entre a0 y a1 igualmente espaciados

# ensamblaje de la matriz de coeficientes y establecimiento de las c.c.
for i in range(1,n-1):
    A[i-1, i] =  1               # phi{i+1}
    A[i-1, i-1] = -2 - beta * h  # phi{i}
    A[i, i-1] = 1 + beta * h     # phi{i-1}
    b[i] = 0

A[n-2,n-2] = -2 - beta * h
b[0]   = -(1 + beta * h) * u0
b[n-2] = -1 * u1

# Solución del SEL Au = b
u_upw = np.linalg.solve(A, b)

# Adicionando las CC
u_upw = np.insert(u_upw,0,u0) # c.c. a laizquierda
u_upw = np.insert(u_upw,n,u1) # c.c. a la derecha

plt.xlabel (r"$x$")
plt.ylabel (r"$\phi(x)$")
plt.title (r"Exacta vs Upwind")
plt.plot(x, u_upw,'r', label='Upwind')
plt.plot(x, exacta(x),'b', label='Exacta')
plt.legend(loc='center left')
plt.grid(True)
plt.show()
```

### Esquema de Viscosidad artificial

El esquema de [viscosidad artificial](https://nptel.ac.in/courses/112/104/112104030/) consiste de la inclusión, de forma consistente, de un término que adiciona difusión artificial a la ecuación, y está dado como:

\begin{equation*}
\begin{split}
-\beta\frac{d\phi}{dx}+\frac{d^2\phi}{dx^2}+\nu_{ex}\frac{d^2\phi}{dx^2}=0
\end{split}
\label{eq:Ec6_46} \tag{6.46}
\end{equation*}

juntando términos, queda

\begin{equation*}
\begin{split}
-\beta\frac{d\phi}{dx}+(1+\nu_{ex}) \frac{d^2\phi}{dx^2}=0
\end{split}
\label{eq:Ec6_47} \tag{6.47}
\end{equation*}

donde $\nu_{ex}=\frac{1}{2}\beta h$

Empleando un esquema de discretización de diferencias finitas central para la segunda derivada y backward para el término de la primera derivada, como en upwind, se llega al siguiente esquema iterativo

\begin{equation*}
\begin{split}
-(2+\beta h) \phi_{i+1}+4(1+\beta h) \phi_{i}-(2+3\beta h) \phi_{i-1}
\end{split}
\label{eq:Ec6_48} \tag{6.48}
\end{equation*}

```python
# Esquema Viscosidad artificial

import numpy as np
import matplotlib.pyplot as plt

def exacta(x):
    return (np.exp(beta * x) - 1) / (np.exp(beta) - 1)

# establecimiento de las condiciones de contorno a la izquierda

a0 = 0
u0 = 0

# establecimiento de las condiciones de contorno a la derecha

a1 = 1
u1 = 1

# Ingreso de datos
n = int(input("Ingrese la cantidad de nodos: "))
beta = float(input("ingrese el valor de la velocidad, beta: "))

# Inicialización de los diferentes arreglos a emplear
A = np.zeros((n-1, n-1)) # matriz de coeficientes
b = np.zeros((n-1, 1))   # vector de términos independietes
u_visc = np.zeros((n-1, 1))   # vector de incógnitas
uex = np.zeros((n-1,1))  # vector de la solución exacta

h = (a1 - a0) / n            # establece el tamaño de paso Delta x
x = np.linspace(a0, a1, n+1)  # Genera n+1 puntos entre a0 y a1 igualmente espaciados
bh = beta * h

# ensamblaje de la matriz de coeficientes y establecimiento de las c.c.
for i in range(1,n-1):
    A[i-1, i] =  -(2 + bh)      # phi{i+1}
    A[i-1, i-1] = 4 * (1 + bh)  # phi{i}
    A[i, i-1] = -(2 + 3 * bh)   # phi{i-1}
    b[i] = 0

A[n-2,n-2] = 4 * (1 + bh)
b[0]   = (2 + 3 * bh) * u0
b[n-2] = (2 + bh) * u1

# Solución del SEL Au = b
u_visc = np.linalg.solve(A, b)

# Adicionando las CC
u_visc = np.insert(u_visc,0,u0) # c.c. a laizquierda
u_visc = np.insert(u_visc,n,u1) # c.c. a la derecha

plt.xlabel (r"$x$")
plt.ylabel (r"$\phi(x)$")
plt.title (r"Exacta vs Viscosidad artificial")
plt.plot(x, u_visc,'r', label='Viscosidad artificial')
plt.plot(x, exacta(x),'b', label='Exacta')
plt.legend(loc='center left')
plt.grid(True)
plt.show()
```

Ahoroa, visualizando las tres soluciones en una única gráfica

```python
plt.xlabel (r"$x$")
plt.ylabel (r"$\phi(x)$")
plt.title (r"Exacta vs Esquemas de DFF")
plt.plot(x, exacta(x),'b', label='Exacta')
plt.plot(x, u_lf,'g', label='Leapfrog')
plt.plot(x, u_upw,'k', label='Upwind')
plt.plot(x, u_visc,'r', label='Viscosidad artificial')
plt.legend(loc='center left')
plt.grid(True)
plt.show()
```

### Ecuación de convección difusión dependiente del tiempo

Sea la ecuación de [Convección lineal](https://en.wikipedia.org/wiki/Advection) en 1D:

$$\frac{d\phi}{dt}+c \frac{d\phi}{dx}=0$$

Determine el esquema de diferencias finitas empleando:

- Euler Explícito

- Euler Implícito

y realice un código en python que resuelva dicha ecuación, sometida a las siguientes condiciones de contorno e iniciales, para $x\in [0,2]$:

$$\phi(x_i^{(0)})=2; \quad 0.5 \le x_i \le 1$$

y

$$\phi(x_i^{(0)})=1; \quad \text{ en el resto de puntos } x_i $$

Esta es la llamada función "sombrero", que gráficamente quedaría:

 Fuente: Propio

```python

```


# Overriding root-finding in the generated C++ code -- Chemical kinetics
This is an advanced notebook exploring how the user can override the root-finding routine in the native C++ code which is exported.

If you only want to do some simple modelling of chemical kinetics you may want look into [ChemPy](https://github.io/bjodah/chempy) or [Cantera](http://www.cantera.org/docs/sphinx/html/index.html) (Cantera is more advanced and is the preferred choice when extensive thermodynamic data is available for the reacting species).

```python
from operator import mul
from functools import reduce
import subprocess
import numpy as np
import sympy as sp
import matplotlib.pyplot as plt
from pyodesys.results import Result
from pyodesys.symbolic import SymbolicSys
from pyodesys.native import native_sys
from pyodesys.native.util import parse_standalone_output
from _chem_kinet import get_odesys
sp.init_printing()
%matplotlib inline
```

```python
# Fe+3 + SCN- <-> FeSCN+2
stoich_reac, stoich_prod = [(1, 1, 0), (0, 0, 1)], [(0, 0, 1), (1, 1, 0)]
kineticsys = get_odesys(stoich_reac, stoich_prod, 'Fe+3 SCN- FeSCN+2'.split(), SymbolicSys,
                        steady_state_root=True, latex_names=['%s' % s for s in 'Fe^{3+} SCN^- FeSCN^{2+}'.split()])
```

```python
def integrate_and_plot(odesys, plot=True, **kwargs):
    tend = 2
    result = odesys.integrate(tend, [1e-2, 2e-3, 0], [800, 8], integrator='cvode', **kwargs)
    if plot:
        fig, axes = plt.subplots(1, 2, figsize=(14, 4))
        if result.xout[-1] != tend:
            axes[0].axvline(result.xout[-1], linestyle='--', label='t = %.4f' % result.xout[-1])
        result.plot(ax=axes[0])
        result.plot(ax=axes[1], deriv=True)
        axes[1].set_yscale('symlog', linthreshy=1e-9)
        axes[1].axhline(1e-9, linestyle='--')
        axes[1].axhline(-1e-9, linestyle='--')
        for ax in axes:
            ax.set_xlim([0, tend])
    return result
```

```python
integrate_and_plot(kineticsys)
```

```python
integrate_and_plot(kineticsys, atol=1e-14, rtol=1e-14)
```

```python
integrate_and_plot(kineticsys, atol=1e-14, rtol=1e-14, return_on_root=True)
```

```python
kineticsys.roots
```

```python
native_override = {
    'p_nroots': """ return 1; """,
    'p_roots': """
    const indextype ny = get_ny();
    std::vector<realtype> f(ny);
    realtype tot=0.0;
    rhs(x, y, &f[0]);
    for (indextype i=0; i<ny; ++i){
        tot += std::min(std::abs(f[i]/m_atol[i]), std::abs(f[i]/y[i]/m_rtol));
    }
    out[0] = tot/ny - m_special_settings[0];
    this->nrev++;
    return AnyODE::Status::success;
"""
}
native_extend={
    'p_constructor': [
        'if (special_settings.size() != 1) std::cerr << "len(special_settings) != 1" << std::endl;'
    ]
}
nativesys = native_sys['cvode'].from_other(
    kineticsys, namespace_override=native_override, namespace_extend=native_extend)
for path in nativesys._native._written_files:
    if path.endswith('.cpp'):
        print(path)
        print('...\n' + ''.join(open(path).readlines()[-20:]))
        print("")
```

There are some linking issues with boost's program options in the below (commented) cells.

```python
#standalone_prog = nativesys.as_standalone('chem_kinet', compile_kwargs=dict(options=['warn', 'pic', 'openmp', 'debug']))
#standalone_prog
```

```python
#p = subprocess.Popen([standalone_prog, '--return-on-root', '1', '--special-settings', '1000'],
#                     stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
#out, err = p.communicate(input='2 1e-2 2e-3 0 800 8 0 0 0 0 1'.encode('utf-8'))
#retc = p.wait()
#assert retc == 0
#print(err.decode('utf-8'))
```

```python
#res_sa, = [Result(*args, kineticsys) for args in parse_standalone_output(out.decode('utf-8').split('\n'))]
#res_sa.plot()
```

## Time to reach steady state
If we define steady state to occur when the change in chemical concentrations is below a certain threshold, then the obtained time will depend on that threshold. Here we investigate how that choice affects the answer (with respect to numerical precision etc.)

```python
native = native_sys['cvode'].from_other(kineticsys, namespace_override=native_override)
```

```python
def plot_tss_conv(factor, tols, ax):
    tol_kw = dict(plot=False, return_on_root=True, nsteps=2000, special_settings=[factor])
    tss = [integrate_and_plot(native, atol=tol, rtol=tol, **tol_kw).xout[-1] for tol in tols]
    ax.semilogx(tols, tss, label=factor)
```

```python
fig, ax = plt.subplots(figsize=(14, 6))
tols = np.logspace(-15, -10, 200)
for factor in [1e2, 1e3, 1e4, 1.1e4, 1e5, 1e6, 1e7]:
    plot_tss_conv(factor, tols, ax)
ax.legend()
```


# Molecular graph generation with PyTorch and PyGeometric
> We use [GraphVAE](https://arxiv.org/abs/1802.03480) for molecular generation with one shot generation of a probabilistic graph with predefined maximum size.

- toc: true
- badges: true
- comments: false
- author: Anirudh Jain
- categories: [graph generation, pytorch, pygeometric, tutorial]

# Requirements

The following packages need to be installed:
- rdkit
- pytorch
- torch_geometric
- networkx

```python
#collapse-hide

#Initial imports

import numpy as np
import torch
import matplotlib.pyplot as plt
from glob import glob
import tqdm
from rdkit import Chem
from rdkit.Chem import AllChem
from rdkit.Chem import Draw
from rdkit.Chem.Draw import IPythonConsole
from torch.utils.data import Dataset, DataLoader
import torch
from torch_geometric.data import Batch
from torch.utils.data import random_split
```

# Introduction

We represent a molecule as graph $G = (\mathcal{X, A})$ using PyGeometric framework. Each molecule is represented by a feature matrix $\mathcal{X}$ and adjacency matrix $\mathcal{A}$. We use QM9 dataset from [MoleculeNet:A Benchmark for Molecular Machine Learning](https://arxiv.org/abs/1703.00564) implemented in `torch_geometric.datasets.QM9`. PyGeometric relies on rdkit to process the SMILES string and convert them into graphs.

We modify the data processing script in two ways:
- We strip hydrogen atoms from the molecules to keep only the heavy atoms
- We kekulize the molecules to convert aromatic rings to Kekule form

The modified script can be found [here](https://gist.github.com/sponde25/7dfa5492c21c007cf1e60a02dced1334)

After processing the dataset, we have a set of molecules with 4 heavy atoms (C, N, O, F) and 3 bond types (SINGLE, DOUBLE and TRIPLE) with maximum graph size of 9.

The decoder outputs the graph as one-hot encoded vectors for atoms `[9 x 5]` and bonds `[9 x 4]`. The label 0 represents empty atom or edge.

```python
#Imports for data pre-processing

import torch_geometric
from qm9_modified import QM9
from torch_geometric.utils.convert import to_networkx
import networkx
```

```python
# Setting up variables for the dataset

MAX_ATOM = 5
MAX_EDGE = 4
path = '/scratch/project_2002655/datasets/qm9_noH' # Change the path for your local directory structure
dataset = QM9(path)

# Store the max. graph size
MAX_N = -1
for data in dataset:
    if MAX_N < data.x.shape[0]: MAX_N = data.x.shape[0]
MAX_E = int(MAX_N * (MAX_N - 1))
print('MAX ATOMS: {}'.format(MAX_N))    # Maximum number of atoms in a graph in the dataset
print('MAX EDGE: {}'.format(MAX_E))     # Corresponding size of upper triangle adjacency matrix
```

    MAX ATOMS: 9
    MAX EDGE: 72

`torch_geometric` stores the graph as `torch_geometric.data.Data` and we generate the one-hot representation of the graph $G$ as described above. For each graph $G$, we create a vector $\mathcal{X}$ as one-hot encoded for atom of dimension `[MAX_N x MAX_ATOM]` and vector bond of dimension `[MAX_E x MAX_EDGE]`.

```python
# We create a matrix to map the index of the edge vector $\mathcal{A}$ to the upper triangular adjacency matrix.

index_array = torch.zeros([MAX_N, MAX_N], dtype=int)
idx = 0
for i in range(MAX_N):
    for j in range(MAX_N):
        if i < j:
            index_array[i, j] = idx
            idx+=1

print(index_array)
```

    tensor([[ 0,  0,  1,  2,  3,  4,  5,  6,  7],
            [ 0,  0,  8,  9, 10, 11, 12, 13, 14],
            [ 0,  0,  0, 15, 16, 17, 18, 19, 20],
            [ 0,  0,  0,  0, 21, 22, 23, 24, 25],
            [ 0,  0,  0,  0,  0, 26, 27, 28, 29],
            [ 0,  0,  0,  0,  0,  0, 30, 31, 32],
            [ 0,  0,  0,  0,  0,  0,  0, 33, 34],
            [ 0,  0,  0,  0,  0,  0,  0,  0, 35],
            [ 0,  0,  0,  0,  0,  0,  0,  0,  0]])

We process the `torch_geometric.dataset` to generate matrices $\mathcal{X}$ and $\mathcal{A}$ which act as the ground truth for our decoder. We will also setup utility functions to convert between the vector representation $(\mathcal{X}, \mathcal{A})$ and `torch_geometric.data` representation $\mathcal{G}$. We use the following key for atoms and bonds:
```
    C: 1    SINGLE: 1
    N: 2    DOUBLE: 2
    O: 3    TRIPLE: 3
    F: 4
```
`0` is the placeholder label for empty entry.

```python
# Initialize the labels with -1
edge_labels = torch.ones(len(dataset), MAX_E) * -1
atom_labels = torch.ones(len(dataset), MAX_N) * -1
idx = 0
for data in dataset:
    edge_attr = data.edge_attr      # One hot encoded bond labels
    edge_index = data.edge_index    # Bond indices as one hot adjacency list
    upper_index = edge_index[0] < edge_index[1] # Bond indices as upper triangular adjacency matrix
    _, edge_label = torch.max(edge_attr, dim=-1)# Bond labels from one hot vectors
    x = data.x[:, 1:5]              # One hot encoded atom labels
    _, atom_label = torch.max(x, dim=-1)        # Atom labels from one hot vectors
    # Expand the label vectors to size [MAX_N x MAX_ATOM] and [MAX_E x MAX_EDGE]
    atom_labels[idx][:len(atom_label)] = atom_label
    a0 = edge_index[0,upper_index]
    a1 = edge_index[1,upper_index]
    up_idx = index_array[a0, a1]
    edge_labels[idx][up_idx] = edge_label[upper_index].float()
    idx += 1

atom_labels += 1
edge_labels += 1
```

Now that we have the dataset represented as $(\mathcal{X}, \mathcal{A})$ let's plot some graphs to visually check if the molecules are as we expected. We use `rdkit` to plot the molecules which does a lot of having lifting for us. The function `graphToMol` takes in the vectors $(\mathcal{X}, \mathcal{A})$ and returns an object of type `rdkit.Mol`. We can also obtain visualizations for the graphs $\mathcal{G}$ by using `torch_geometric.utils.convert.to_networkx` and then ploting the netowrkx graph. But `rdkit` plots the molecules in a canonical orientation and is built to minimize intramolecular clashes, i.e. to maximize the clarity of the drawing.

```python
#collapse-hide

def get_index(index, index_array):
    for i in range(9):
        for j in range(9):
            if i < j:
                if(index_array[i, j] == index):
                    return [i, j]

def graphToMol(atom, edge):

    possible_atoms = {
        0: 'H',
        1: 'C',
        2: 'N',
        3: 'O',
        4: 'F'
    }
    possible_edges = {
        1: Chem.rdchem.BondType.SINGLE,
        2: Chem.rdchem.BondType.DOUBLE,
        3: Chem.rdchem.BondType.TRIPLE
    }
    max_n = 9

    mol = Chem.RWMol()
    rem_idxs = []
    for a in atom:
        atom_symbol = possible_atoms[a.item()]
        mol.AddAtom(Chem.Atom(atom_symbol))
    for a in mol.GetAtoms():
        if a.GetAtomicNum() == 1:
            rem_idxs.append(a.GetIdx())
    for i, e in enumerate(edge):
        e = e.item()
        if e != 0:
            a0, a1 = get_index(i, index_array)
            if a0 in rem_idxs or a1 in rem_idxs:
                return None
            bond_type = possible_edges[e]
            mol.AddBond(a0, a1, order=bond_type)
    rem_idxs.sort(reverse=True)
    for i in rem_idxs:
        mol.RemoveAtom(i)
    return mol
```

```python
# We pick 9 random molecules from QM9 dataset to plot
mols = []
for i in np.random.randint(0, 100, size=9):
    mols.append(graphToMol(atom_labels[i], edge_labels[i]))
Chem.Draw.IPythonConsole.ShowMols(mols)
```

The final step is to create a custom dataset for PyTorch which combines the two representations $\mathcal{G}$ and $(\mathcal{X}, \mathcal{A})$. The dataset returns a list containing the graph, atom label and edge label. We also define a `collate_fn` which creates mini batches for the data loader that we will need for training and testing our model.

```python
#collapse-hide

# Utility function to combine the torch_geometric.data and torch.tensor into one mini batch
def collate_fn(datas):
    graphs, atoms, edges = [], [], []
    for data in datas:
        graph, atom, edge = data
        graphs.append(graph)
        atoms.append(atom)
        edges.append(edge)
    graphs = Batch.from_data_list(graphs)
    atoms = torch.stack(atoms)
    edges = torch.stack(edges)
    return graphs, atoms, edges

# Returns the dataloader with a given batch size
def get_dataloader(dataset, batch_size=32, shuffle=True):
    return DataLoader(dataset, batch_size=batch_size, shuffle=shuffle, collate_fn=collate_fn)

# Create the dataset from the QM9 dataset from PyGeometric and the label matrices
class GraphTensorDataset(Dataset):

    def __init__(self):

        self.graph_dataset = QM9('/scratch/project_2002655/datasets/qm9_noH')
        self.atom_labels = torch.load('/scratch/project_2002655/drug_discovery/saved/atom_labels_noH.tensor')
        self.edge_labels = torch.load('/scratch/project_2002655/drug_discovery/saved/edge_labels_noH.tensor')

    def __len__(self):
        return len(self.graph_dataset)

    def __getitem__(self, idx):
        if torch.is_tensor(idx):
            idx = idx.tolist()

        graphs = self.graph_dataset[idx]
        atoms = self.atom_labels[idx]
        edges = self.edge_labels[idx]

        return graphs, atoms, edges

    # return the subset of data with the specified size
    def get_splits(self, train_size, test_size, cv_size=None):
        total_data = len(self.graph_dataset)
        if cv_size is not None:
            trainset, testset = random_split(self, [train_size+cv_size, total_data - train_size - cv_size])
            trainset, cvset = random_split(trainset, [train_size, cv_size])
            testset, _ = random_split(testset, [test_size, len(testset) - test_size])
            self.trainset = trainset
            self.cvset = cvset
            self.testset = testset
            return self.trainset, self.testset, self.cvset
        else:
            trainset, testset = random_split(self, [train_size, total_data - train_size])
            testset, _ = random_split(testset, [test_size, len(testset) - test_size])
            self.trainset = trainset
            self.testset = testset
            return self.trainset, self.testset

```

Now we wrap up this section by creating the train and test loaders with a mini batch of size `32`. We train the model on a subset of 500 molecules and test the reconstruction on 10000 molecules. To monitor training we look at three metrics averaged over the minibatch:
- cross-entropy loss over the atom and edge labels
- Atom label accuracy
- Edge label accuracy

```python
dataset = GraphTensorDataset()
train_size = int(500)
test_size = int(10000)
torch.manual_seed(0) # We set the random seed before the random split so we can get the same subset for reproducibility
trainset, testset = dataset.get_splits(train_size, test_size)
trainloader = get_dataloader(trainset)
testloader = get_dataloader(testset)
```

# Model

Our generative model is a VAE with an encoder $q_\phi (\textbf{z} | \mathcal{G})$ and a decoder $p_\theta (\mathcal{X}, \mathcal{A} | \textbf{z})$ parameterized by $\phi$ and $\theta$ respectively. We place a prior over the latent vector $\textbf{z} \sim \mathcal{N}(0, I)$ which act as a regularizer to our model. Our goal is to map the graph $\mathcal{G}$ into the latent vector $\textbf{z}$ and generate new graphs by using the decoder to map new latent vectors sampled from the prior.

***Encoder***: For a graph $\mathcal{G}(\mathcal{V}, \mathcal{E})$ with upto N nodes, we aim to map the graph to a latent vector $\textbf{z} \in \mathbb{R}^D$. We use graph convolution network(GCN) to learn graph level feature vector $h_{\mathcal{G}}$. For each node $\mathcal{v} \in \mathcal{V}$, GCN update the node level features as,
\begin{equation}
    h_{\mathcal{v}} = \sum_{\mathcal{u} \in Nbd(\mathcal{v}, \mathcal{G})}\Phi (W_l^T X_{\mathcal{u}})
\end{equation}
where $Nbd(\mathcal{v}, \mathcal{G})$ is the neighborhood of node $\mathcal{v}$ in graph $\mathcal{G}$, $W_l$ are the shared weights analogous to the kernel in convolution layer and $X_\mathcal{u}$ is the feature vector of node $\mathcal{u}$ with $\Phi$ as the non-linearity.

After 2 graph convolution layers, we create a graph level vector representation by taking the mean of all node feature vectors $h_{\mathcal{v}}$.
\begin{equation}
    h_{\mathcal{G}} = 1 / |\mathcal{V}| \sum_{\mathcal{v} \in \mathcal{V}} h_{\mathcal{v}}
\end{equation}
Now we use fully connected layers to learn the latent vector $\textbf{z}$ with the inference model as,
\begin{equation}
    q(\textbf{z}|\mathcal{G}) = \mathcal{N}(\textbf{z}|\mu(h_{\mathcal{G}}), \sigma^2(h_{\mathcal{G}}))
\end{equation}
where $\mu(h_{\mathcal{G}})$ and $\sigma^2(h_{\mathcal{G}})$ are parameterized by neural networks to learn the latent space.

***Decoder***: The primary problem with graph generation is that order of nodes can be arbritary which makes comparing two graphs a combatorial problem. We sidestep this problem by framing the graph generation problem as multi class classification problem. Keeping in the spirit of a toy example, we consider two independent classification problems.

We train a decoder to predict atom and edge label vectors $(\mathcal{X}, \mathcal{A})$ respectively. We can train the model by using the standard VAE loss,
\begin{equation}
    \mathcal{L} = \alpha_{\mathcal{a}} \mathcal{L}_{\mathcal{a}} + \alpha_\mathcal{e} \mathcal{L}_{\mathcal{e}} + \alpha_{KL} KL[q(\textbf{z}|\mathcal{G}) | p(\textbf{z})]
\end{equation}

$\mathcal{L}_{\mathcal{a}}$ and $\mathcal{L}_{\mathcal{e}}$ are the cross entropy loss for multiclass vectors for atoms and edges respectively. This restricts the decoder to memorize the ordering for the atom labels and independently learn the edges based only on the latent vector $\textbf{z}$. We set $\alpha_\mathcal{a} = 1/|\mathcal{X}|$, $\alpha_\mathcal{e} = 1/|{\mathcal{A}}|$ and $\alpha_KL = 1/ 128$ so all three loses are on the same scale. $\mathcal{X}$ is the number of atoms in a mini batch, $|\mathcal{A}|$ is the number of edges in a mini batch and we set the dimension of the latent vector as $128$.

We use a single sample from the latent distribution to train the VAE and 100 samples during testing.

```python
#collapse-hide

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch_geometric
from torch_geometric.nn import GCNConv, BatchNorm, GlobalAttention
from torch_geometric.utils import add_self_loops

class VAE(nn.Module):
    def __init__(self,  max_n, max_atoms, max_edges, in_features):
        super(VAE, self).__init__()
        n_output = int(max_n * (max_n - 1) / 2)
        self.n_output = n_output
        self.max_n = max_n
        self.max_atoms = max_atoms
        self.max_edges = max_edges

        self.gc1 = GCNConv(in_features, 32)
        self.gc2 = GCNConv(32, 64)
        self.gc1_bn = nn.BatchNorm1d(32)
        self.gc2_bn = nn.BatchNorm1d(64)

        attn1 = nn.Linear(64, 1)
        attn2 = nn.Linear(64, 64)
        self.graph_pool = GlobalAttention(attn1, attn2)

        self.fc1 = nn.Linear(64, 128)
        self.fc1_bn = nn.BatchNorm1d(128)

        self.mu = nn.Linear(128, 128)
        self.logv = nn.Linear(128, 128)

        self.dec1 = nn.Linear(128, 128)
        self.dec1_bn = nn.BatchNorm1d(128)
        self.dec2 = nn.Linear(128, 256)
        self.dec2_bn = nn.BatchNorm1d(256)
        self.dec3 = nn.Linear(256, 512)
        self.dec3_bn = nn.BatchNorm1d(512)

        self.atoms = nn.Linear(512, max_atoms*max_n)
        self.edges = nn.Linear(512, max_edges*n_output)

    def encode(self, data):
        x, edge_index, batch = data.x, data.edge_index, data.batch
        edge_index, _ = add_self_loops(edge_index)

        x = self.gc1(x, edge_index)
        x = F.relu(self.gc1_bn(x))
        x = self.gc2(x, edge_index)
        x = F.relu(self.gc2_bn(x))
        #x = self.graph_pool(x, batch)
        #x = F.tanh(x)
        x = torch_geometric.nn.global_add_pool(x, data.batch)
        x = self.fc1(x)
        x = F.relu(self.fc1_bn(x))
        return self.mu(x), self.logv(x)

    def sample(self, mu, logv):
        eps = torch.randn_like(logv).to(mu.device)
        return mu + eps * torch.exp(logv)

    def decode(self, sample):
        x = self.dec1(sample)
        x = F.relu(self.dec1_bn(x))
        x = self.dec2(x)
        x = F.relu(self.dec2_bn(x))
        x = self.dec3(x)
        x = F.relu(self.dec3_bn(x))

        atoms = self.atoms(x)
        atoms = atoms.view(-1, self.max_n, self.max_atoms)
        atoms = F.softmax(atoms, dim=-1)

        edges = self.edges(x)
        edges = edges.view(-1, self.n_output, self.max_edges)
        edges = F.softmax(edges, dim=-1)
        return atoms, edges

    def forward(self, data, num_samples=1):
        mu, logv = self.encode(data)
        _atoms, _edges = [], []
        for i in range(num_samples):
            sample = self.sample(mu, logv)
            atoms, edges = self.decode(sample)
            _atoms.append(atoms)
            _edges.append(edges)
        return mu, logv, _atoms, _edges
```

Now that we have the dataloader and the model, we can setup utility functions `train` and `test` along with the metrics that we need to evaluate the model performance. We use multi class accuracy to monitor as an indicator for model performance.

```python
#collapse-hide

from pathlib import Path

# KL loss for the latent vector
def get_vae_loss(mu, logv):
    kl_loss = 0.5 * torch.sum(torch.exp(logv) + mu**2 - 1.0 - logv)
    return kl_loss/128

# Reconstruction loss for atom and edge vectors
def get_recon_loss(atom_true, atom_prob, edge_true, edge_prob):
    ashape = atom_prob.shape
    num_atoms = (atom_true!=0).int().sum()
    atom_prob = torch.log(atom_prob)
    atom_loss = F.nll_loss(atom_prob.view(-1, ashape[-1]),
                                atom_true.view(-1).long(), reduction='sum') / num_atoms
    eshape = edge_prob.shape
    edge_prob = torch.log(edge_prob)
    num_edges = (edge_true!=0).int().sum()
    edge_loss = F.nll_loss(edge_prob.view(-1, eshape[-1]),
                                edge_true.view(-1).long(), reduction='sum') / num_edges
    return atom_loss, edge_loss

# Return the correct predictions for multi class label classification
def get_accuracy(y_true, y_prob, threshold = 0.5):
    if len(y_prob.shape) <=2:
        y_pred = (y_prob >= threshold).int()
    else:
        _, y_pred = torch.max(y_prob, dim=-1)
    index = y_true != 0
    correct = y_true[index] == y_pred[index]
    return correct.int().sum(), index.int().sum()

# Class to run and store training statistics we can monitor for model performance
class Experiment():
    def __init__(self, model, optimizer, path = './saved/tmp/'):
        self.model = model
        self.optimizer = optimizer
        self.path = path

        Path(path+'models/').mkdir(parents=True, exist_ok=True)
        Path(path+'metrics/').mkdir(parents=True, exist_ok=True)
        Path(path+'figs/').mkdir(parents=True, exist_ok=True)

        self.trainLoss = []
        self.trainAcc = []
        self.trainAUC = []
        self.trainLossBatch = []
        self.trainAccBatch = []
        self.betaBatch = []
        self.testLoss = []
        self.testAcc = []
        self.testAUC = []
        self.testLossBatch = []
        self.testAccBatch = []

    def train(self, dataloader, epoch, cyclic = False, device = 'cuda'):
        self.model.train()

        total_data = len(dataloader.dataset)
        total_batches = len(dataloader)
        batch_idx = epoch * total_batches
        if cyclic:
            batches_cycle = int(total_batches / 3)
            batches_half_cycle = int(batches_cycle / 2)
            betas = np.ones(batches_cycle)
            betas[:batches_half_cycle] = np.linspace(0, 1, num=batches_half_cycle)
        atom_correct = atom_total = edge_correct = edge_total = 0.
        atom_auc = edge_auc = 0.
        kl_loss = atom_loss = edge_loss = 0.
        beta = 1

        for data in dataloader:
            graph = data[0].to(device)
            atom_true = data[1].to(device)
            edge_true = data[2].to(device)
            batch_size = atom_true.shape[0]

            # Core Training
            self.optimizer.zero_grad()
            mu, logv, atom_probs, edge_probs = self.model(graph)
            _kl_loss = get_vae_loss(mu, logv)
            aloss = eloss = 0.
            for atom_prob, edge_prob in zip(atom_probs, edge_probs):
                _atom_loss, _edge_loss = get_recon_loss(atom_true, atom_prob, edge_true, edge_prob)
                aloss += _atom_loss
                eloss += _edge_loss
            C = len(atom_probs)
            _atom_loss = aloss / C
            _edge_loss = eloss / C
            _recon_loss = _atom_loss + _edge_loss
            if cyclic:
                beta_idx = batch_idx % batches_cycle
                beta = betas[beta_idx]
            loss = _kl_loss * beta + _recon_loss
            loss.backward()
            self.optimizer.step()
            batch_idx += 1

            with torch.no_grad():
                self.trainLossBatch.append([_kl_loss, _atom_loss, _edge_loss])
                self.betaBatch.append(beta)

                kl_loss += _kl_loss.item() * batch_size
                atom_loss += _atom_loss.item() * batch_size
                edge_loss += _edge_loss.item() * batch_size
                _atom_correct = _edge_correct = _atom_auc = _edge_auc = 0.
                C = len(atom_probs)
                for atom_prob, edge_prob in zip(atom_probs, edge_probs):
                    acorrect, _atom_total = get_accuracy(atom_true, atom_prob)
                    ecorrect, _edge_total = get_accuracy(edge_true, edge_prob)
                    aauc = get_multi_auc(atom_true.cpu(), atom_prob.cpu(), atom_prob.shape[-1]) * batch_size
                    eauc = get_multi_auc(edge_true.cpu(), edge_prob.cpu(), edge_prob.shape[-1]) * batch_size
                    _atom_correct += acorrect
                    _edge_correct += ecorrect
                    _atom_auc += aauc
                    _edge_auc += eauc
                atom_auc += _atom_auc.item() / C
                edge_auc += _edge_auc.item() / C
                atom_correct += _atom_correct.item() / C
                atom_total += _atom_total.item()
                edge_correct += _edge_correct.item() / C
                edge_total += _edge_total.item()

                self.trainAccBatch.append([_atom_correct/C/_atom_total, _edge_correct/C/_edge_total])

        self.trainLoss.append([kl_loss/total_data, atom_loss/total_data, edge_loss/total_data])
        self.trainAcc.append([atom_correct/atom_total, edge_correct/edge_total])
        self.trainAUC.append([atom_auc/total_data, edge_auc/total_data])

    def test(self, dataloader, epoch, num_samples = 1, device = 'cuda'):
        self.model.eval()

        with torch.no_grad():
            kl_loss = 0.
            atom_loss = 0.
            edge_loss = 0.
            total_data = len(dataloader.dataset)
            total_batches = len(dataloader)
            batch_idx = epoch * total_batches
            atom_correct = atom_total = edge_correct = edge_total = 0.
            atom_auc = edge_auc = 0.

            for data in dataloader:
                graph = data[0].to(device)
                atom_true = data[1].to(device)
                edge_true = data[2].to(device)
                batch_size = atom_true.shape[0]

                mu, logv, atom_probs, edge_probs = self.model(graph, num_samples)
                _kl_loss = get_vae_loss(mu, logv)
                aloss = eloss = 0.
                for atom_prob, edge_prob in zip(atom_probs, edge_probs):
                    _atom_loss, _edge_loss = get_recon_loss(atom_true, atom_prob, edge_true, edge_prob)
                    aloss += _atom_loss
                    eloss += _edge_loss
                C = len(atom_probs)
                _atom_loss = aloss / C
                _edge_loss = eloss / C

                kl_loss += _kl_loss.item() * batch_size
                atom_loss += _atom_loss.item() * batch_size
                edge_loss += _edge_loss.item() * batch_size
                _atom_correct = _edge_correct = _atom_auc = _edge_auc = 0.
                C = len(atom_probs)
                for atom_prob, edge_prob in zip(atom_probs, edge_probs):
                    acorrect, _atom_total = get_accuracy(atom_true, atom_prob)
                    ecorrect, _edge_total = get_accuracy(edge_true, edge_prob)
                    aauc = get_multi_auc(atom_true.cpu(), atom_prob.cpu(), atom_prob.shape[-1]) * batch_size
                    eauc = get_multi_auc(edge_true.cpu(), edge_prob.cpu(), edge_prob.shape[-1]) * batch_size
                    _atom_correct += acorrect
                    _edge_correct += ecorrect
                    _atom_auc += aauc
                    _edge_auc += eauc
                atom_auc += _atom_auc.item() / C
                edge_auc += _edge_auc.item() / C
                atom_correct += _atom_correct.item() / C
                atom_total += _atom_total.item()
                edge_correct += _edge_correct.item() / C
                edge_total += _edge_total.item()

                self.testLossBatch.append([_kl_loss, _atom_loss, _edge_loss])
                self.testAccBatch.append([_atom_correct/_atom_total, _edge_correct/_edge_total])

            self.testLoss.append([kl_loss/total_data, atom_loss/total_data, edge_loss/total_data])
            self.testAcc.append([atom_correct/atom_total, edge_correct/edge_total])
            self.testAUC.append([atom_auc/total_data, edge_auc/total_data])

    def save_state(self, epoch):
        metrics = {}
        trainLoss = np.array(self.trainLoss)
        trainAcc = np.array(self.trainAcc)
        trainAUC = np.array(self.trainAUC)
        testLoss = np.array(self.testLoss)
        testAcc = np.array(self.testAcc)
        testAUC = np.array(self.testAUC)

        metrics['Train KL Loss'] = trainLoss[:, 0]
        metrics['Train Atom Loss'] = trainLoss[:, 1]
        metrics['Train Edge Loss'] = trainLoss[:, 2]

        metrics['Train Atom Acc'] = trainAcc[:, 0]
        metrics['Train Edge Acc'] = trainAcc[:, 1]

        metrics['Train Atom AUC'] = trainAUC[:, 0]
        metrics['Train Edge AUC'] = trainAUC[:, 1]

        metrics['Test KL Loss'] = testLoss[:, 0]
        metrics['Test Atom Loss'] = testLoss[:, 1]
        metrics['Test Edge Loss'] = testLoss[:, 2]

        metrics['Test Atom Acc'] = testAcc[:, 0]
        metrics['Test Edge Acc'] = testAcc[:, 1]

        metrics['Test Atom AUC'] = testAUC[:, 0]
        metrics['Test Edge AUC'] = testAUC[:, 1]

        torch.save(metrics, self.path + 'metrics/epoch_{}.metric'.format(epoch))

        batch_metrics = {}
        trainLossBatch = np.array(self.trainLossBatch)
        trainAccBatch = np.array(self.trainAccBatch)
        testLossBatch = np.array(self.testLossBatch)
        testAccBatch = np.array(self.testAccBatch)

        batch_metrics['Train KL Loss'] = trainLossBatch[:, 0]
        batch_metrics['Train Atom Loss'] = trainLossBatch[:, 1]
        batch_metrics['Train Edge Loss'] = trainLossBatch[:, 2]

        batch_metrics['Train Atom Acc'] = trainAccBatch[:, 0]
        batch_metrics['Train Edge Acc'] = trainAccBatch[:, 1]

        batch_metrics['Test KL Loss'] = testLossBatch[:, 0]
        batch_metrics['Test Atom Loss'] = testLossBatch[:, 1]
        batch_metrics['Test Edge Loss'] = testLossBatch[:, 2]

        batch_metrics['Test Atom Acc'] = testAccBatch[:, 0]
        batch_metrics['Test Edge Acc'] = testAccBatch[:, 1]

        torch.save(batch_metrics, self.path + 'metrics/batches.metric')

        torch.save(self.model.state_dict(), self.path + 'models/gvae_{}.model'.format(epoch))
```

We can use many tricks from the VAE literature to improve performance and obtain better results. [Cyclic Annealing Schedule](https://arxiv.org/abs/1903.10145) for VAE training has been show to significantly improve performance on autoregressive NLP tasks such as language modeling, dialog response generation etc. We can train the model using cyclic annealing schedule by setting the flag `cyclic=True`.

We use the mean of 40 samples during testing to calculate the loss and accuracy on the test set.

```python
model = VAE(MAX_N, MAX_ATOM, MAX_EDGE, 13)
lr = 1e-3
optimizer = torch.optim.Adam(model.parameters(), lr=lr, betas=(0.9, 0.999))
epochs = 100
device = 'cuda'
model = model.to(device)
path = './saved/test/'
experiment = Experiment(model, optimizer, path)

for epoch in range(epochs):

    experiment.train(trainloader, epoch, cyclic=False)
    experiment.test(testloader, epoch, 40)
    experiment.save_state(epoch)
```

# Results

We train the model on the dataset of 500 molecules and test on a dataset of 10,000 molecules for 100 epochs. The small size of the trainset leads to a large generalization gap and the model overfits on trainset with the atom and edge cross entropy loss decreasing but a flat test loss. The test atom classification accuracy converges at around 80% while the edge classification accuracy converges at around 75%. We only consider the labels with non-zero entry in the ground truth to calculate accuracy.

Now we consider metrics to check the generative performance for molecules. We sample $n_s = 10,000$ latent vectors $\textbf{z}$ from the prior $\mathcal{N}(0, I)$ and use the trained decoder to generate new molecules. We use the following three metrics to test the quality of the learned latent space:
- Validity:     Let list $|V|$ represent chemically valid molecules, validity is the ratio $|V|/n_s$
- Uniqueness:   The set of chemically valid molecules $U = set(V)$, uniqueness is the ratio $|U|/|V|$
- Novelty:      Novelty is the set of elements in $U$ not in the trainset $\mathcal{D}_{train}$ defined as $1 - (U \cap \mathcal{D}_{train})/|U|$

We obtain the following results from our trained model:

| Validity | Uniqueness | Novelty|
|----------|-----------|---------|
|    62.33%|   76.40%  |  66.71% |

Even the simple toy example can generate a substantial number of unique and novel valid molecules. This is without any graph matching in the loss function so the model can learn to predict nodes in arbritary order and is restricted by the order of nodes in the trainset. Also these results are obtained from a very small subset of data and is comparable to [GraphAF](https://arxiv.org/abs/2001.09382) SOTA method without validity checks during training (67% in Table 3).

The model predicts a lot of disconnected graphs and molecules with invalid chemical valency rules

# Conclusion

The GraphVAE presents a simple model to learn a generative model over graphs and gives a reasonable performance in modelling the structure behind molecules. This gives us a simple but suprisingly effective baseline for structured generation. Several work has improved upon this baseline using [reinforcement learning](https://arxiv.org/abs/1806.02473) and [flows](https://arxiv.org/abs/2001.09382) for goal directed generation of molecules. Both of these methods use an oracle during autoregressive generation to prune chemically invalid additions to the sub-graph. Going forward, we will look at implementing the SOTA methods for improved baseline. We will also explore ways to develop algorithms for structured data without depending on an oracle but learn the underlying rules implicitly from the data.