# 04 The Closed-Shell MP2 energy

Unlike the Hartree-Fock energy, correlation energies like the MP2 (second-order Moller-Plesset perturbation) energy are usually expressed in terms of MO-basis quantities (integrals, MO energies). The most expensive part of the calculation is the transformation of the two-electron integrals from the AO to the MO basis representation. The purpose of this project is to understand this transformation and fundamental techniques for its efficient implementation. The theoretical background and a concise set of instructions for this project may be found [here](https://github.com/CrawfordGroup/ProgrammingProjects/blob/master/Project%2304/project4-instructions.pdf).

```python
# Following os.chdir code is only for thebe (live code), since only in thebe default directory is /home/jovyan
import os
if os.getcwd().split("/")[-1] != "Project_04":
    os.chdir("source/Project_04")
from solution_04 import Molecule as SolMol

from pyscf import gto
import numpy as np
import scipy.linalg
from scipy.linalg import fractional_matrix_power
from typing import Tuple
np.set_printoptions(precision=7, linewidth=120, suppress=True)
```

```python
# Solution mol only uses PySCF approach
sol_mole = SolMol()
sol_mole.construct_from_dat_file("input/h2o/STO-3G/geom.dat")
sol_mole.obtain_mol_instance(basis="STO-3G")
sol_mole.obtain_nao()
sol_mole.obtain_nocc()
```

## Molecule Object Initialization

In this project, we may use the updated Molecule initialization. Since these are technical details, we toggle the code and explanation below. Most of the method functions are illustrated in [Project 01](../Project_01/Project_01.ipynb) and [Project 03](../Project_03/Project_03.ipynb).

```python
class Molecule:

    def __init__(self):
        # Project 03 existed
        self.atom_charges = NotImplemented  # type: np.ndarray
        self.atom_coords = NotImplemented  # type: np.ndarray
        self.natm = NotImplemented  # type: int
        self.mol = NotImplemented  # type: gto.Mole
        self.nao = NotImplemented  # type: int
        self.charge = 0  # type: int
        self.nocc = NotImplemented  # type: int
        # Project 04 added
        self.mo_coeff = NotImplemented  # type: np.ndarray
        self.mo_energy = NotImplemented  # type: np.ndarray
        self.eri_ao = NotImplemented  # type: np.ndarray
        self.eri_mo = NotImplemented  # type: np.ndarray
        self.energy_rhf = NotImplemented  # type: np.ndarray
        self.energy_corr = NotImplemented  # type: np.ndarray

    def construct_from_dat_file(self, file_path: str):
        with open(file_path, "r") as f:
            dat = np.array([line.split() for line in f.readlines()][1:])
            self.atom_charges = np.array(dat[:, 0], dtype=float).astype(int)
            self.atom_coords = np.array(dat[:, 1:4], dtype=float)
            self.natm = self.atom_charges.shape[0]

    def obtain_mol_instance(self, basis: str, verbose=0):
        mol = gto.Mole()
        mol.unit = "Bohr"
        mol.atom = "\n".join([("{:3d} " + " ".join(["{:25.18f}"] * 3)).format(chg, *coord) for chg, coord in zip(self.atom_charges, self.atom_coords)])
        mol.basis = basis
        mol.charge = self.charge
        mol.spin = 0
        mol.verbose = verbose
        self.mol = mol.build()

    def eng_nuclear_repulsion(self) -> float:
        return self.mol.energy_nuc()

    def obtain_nao(self):
        self.nao = self.mol.nao_nr()

    def obtain_nocc(self):
        assert (self.atom_charges.sum() - self.charge) % 2 == 0
        self.nocc = (self.atom_charges.sum() - self.charge) // 2

    def integral_ovlp(self) -> np.ndarray:
        return self.mol.intor("int1e_ovlp")

    def integral_kin(self) -> np.ndarray:
        return self.mol.intor("int1e_kin")

    def integral_nuc(self) -> np.ndarray:
        return self.mol.intor("int1e_nuc")

    def get_hcore(self) -> np.ndarray:
        return self.integral_kin() + self.integral_nuc()

    def integral_eri(self) -> np.ndarray:
        return self.mol.intor("int2e")

    def integral_ovlp_m1d2(self) -> np.ndarray:
        return fractional_matrix_power(self.integral_ovlp(), -1/2)

    def get_fock(self, dm: np.ndarray) -> np.ndarray:
        return self.get_hcore() + (self.eri_ao * dm).sum(axis=(-1, -2)) - 0.5 * (self.eri_ao * dm[:, None, :]).sum(axis=(-1, -3))

    def get_coeff_from_fock_diag(self, fock: np.ndarray) -> np.ndarray:
        return scipy.linalg.eigh(fock, self.integral_ovlp())[1]

    def make_rdm1(self, coeff: np.ndarray) -> np.ndarray:
        return 2 * coeff[:, :self.nocc] @ coeff[:, :self.nocc].T

    def get_updated_dm(self, dm: np.ndarray) -> np.ndarray:
        return self.make_rdm1(self.get_coeff_from_fock_diag(self.get_fock(dm)))

    def eng_total(self, dm: np.ndarray) -> float:
        return (0.5 * (self.get_hcore() + self.get_fock(dm)) * dm).sum() + self.eng_nuclear_repulsion()

    def scf_process(self, dm_guess: np.ndarray=None) -> Tuple[float, np.ndarray]:
        eng, dm = 0., np.zeros((self.nao, self.nao)) if dm_guess is None else np.copy(dm_guess)
        max_iter, thresh_eng, thresh_dm = 64, 1e-10, 1e-8
        for epoch in range(max_iter):
            eng_next, dm_next = self.eng_total(dm), self.get_updated_dm(dm)
            if np.abs(eng_next - eng) < thresh_eng and np.linalg.norm(dm_next - dm) < thresh_dm:
                eng, dm = eng_next, dm_next
                break
            eng, dm = eng_next, dm_next
        return eng, dm
```

````{toggle}

The meaning of additional attributes of `Molecule` are:
- `mo_coeff` $C_{\mu p}$ Molecular orbital coefficient
- `mo_energy` $\varepsilon_p$ Molecular orbital energy (from fock diagonalization)
- `eri_ao` $(\mu \nu | \kappa \lambda)$ Atomic orbital electron repulsion integral
- `eri_mo` $(p q | r s)$ Molecular orbital electron repulsion integral
- `energy_rhf` $E_\mathrm{tot}^\mathsf{RHF}$ Total energy of RHF
- `energy_corr` $E_\mathrm{corr}$ Correlation energy, in this project it is MP2's form $E_\mathrm{corr}^\mathsf{MP2}$

`mo_coeff` $C_{\mu p}$, `mo_energy` $e_p$, `eri_ao` $(\mu \nu | \kappa \lambda)$, `energy_rhf` $E_\mathrm{tot}^\mathsf{RHF}$ is already calculated in SCF process, as illustrated in [Project 03](../Project_03/Project_03.ipynb). To store these quantities as attribute of `Molecule` is out of consideration of convenience and efficiency (one does not need to evaluate atomic eri every time when it is called).

The other two quantities, `eri_mo` $(pq|rs)$ and `energy_corr` $E_\mathrm{corr}$ is our main concern in this project.

````

## Step 1: Two-Electron Integrals

The Mulliken-ordered integrals are defined as:

$$
(\mu \nu | \kappa \lambda) = \int \phi_\mu (\boldsymbol{r}_1) \phi_\nu (\boldsymbol{r}_1) \frac{1}{|\boldsymbol{r}_1 - \boldsymbol{r}_2|} \phi_\kappa (\boldsymbol{r}_2) \phi_\lambda (\boldsymbol{r}_2) \, \mathrm{d} \boldsymbol{r}_1 \, \mathrm{d} \boldsymbol{r}_2
$$

We have already read and used this integral in [Project 03](../Project_03/Project_03.ipynb#step-3-two-electron-integrals). However we still does not have a function storing this integral to attribute of `Molecule`.

### Implementation

```python
def obtain_eri_ao(mole: Molecule):
    # Attribute Modification: `eri_ao` atomic orbital electron repulsion integral
    raise NotImplementedError("Exactly 1 line of code")

Molecule.obtain_eri_ao = obtain_eri_ao
```

### Solution

```python
sol_mole.obtain_eri_ao()
sol_mole.eri_ao.shape
```

    (7, 7, 7, 7)

## Step 2: Obtain the SCF Intermediate Results

We have already implemented an SCF process, but results are not stored in attributes of `Molecule` currently. We need to have a function to store RHF total energy $E_\mathrm{tot}^\mathsf{RHF}$, molecular orbital coefficients $C_{\mu p}$ and molecular orbital energy $\varepsilon_p$.

````{admonition} Hint 1: Process of obtaining SCF intermediates
:class: dropdown

- Obtain SCF energy and density matrix from SCF calculation (`scf_process`)
- Obtain molecular orbital coefficients and energy from diagonalization of fock matrix (see how `get_coeff_from_fock_diag` works)

````

### Implementation

```python
def obtain_scf_intermediates(mole: Molecule, dm_guess: np.ndarray=None):
    # Attribute Modification: `energy_rhf` Total energy of RHF
    # Attribute Modification: `mo_energy` Molecular orbital energies
    # Attribute Modification: `mo_coeff` Molecular orbital coefficients
    raise NotImplementedError("About 3~5 lines of code")

Molecule.obtain_scf_intermediates = obtain_scf_intermediates
```

### Solution

```python
sol_mole.obtain_scf_intermediates()
sol_mole.energy_rhf, sol_mole.mo_energy, sol_mole.mo_coeff
```

    (-74.94207992819237,
     array([-20.2628916,  -1.2096974,  -0.5479647,  -0.4365272,  -0.3875867,   0.4776187,   0.5881393]),
     array([[-0.9944346, -0.2391589,  0.       , -0.0936832, -0.       ,  0.1116399,  0.       ],
            [-0.024097 ,  0.8857356, -0.       ,  0.4795859,  0.       , -0.6695791, -0.       ],
            [ 0.       ,  0.       , -0.6072848, -0.       ,  0.       ,  0.       , -0.9192343],
            [-0.0031615,  0.0858962,  0.       , -0.7474314, -0.       , -0.7384886, -0.       ],
            [ 0.       , -0.       , -0.       ,  0.       , -1.       ,  0.       , -0.       ],
            [ 0.0045937,  0.1440396, -0.4529977, -0.3294712, -0.       ,  0.7098495,  0.7324607],
            [ 0.0045937,  0.1440396,  0.4529977, -0.3294712,  0.       ,  0.7098495, -0.7324607]]))

## Step 3: MO ERI Transformation (Naïve Algorithm)

Molecular orbital (MO) electron repulsion integral (ERI) could be defined as

$$
(pq|rs) = \int \phi_p (\boldsymbol{r}_1) \phi_q (\boldsymbol{r}_1) \frac{1}{|\boldsymbol{r}_1 - \boldsymbol{r}_2|} \phi_r (\boldsymbol{r}_2) \phi_s (\boldsymbol{r}_2) \, \mathrm{d} \boldsymbol{r}_1 \, \mathrm{d} \boldsymbol{r}_2
$$

just similar to definition of AO ERI $(\mu \nu | \kappa \lambda)$. Note that $r$ in subscript means any (occupied or virtual) molecular orbital, not related to electron coordinate vector $\boldsymbol{r}_1$ or $\boldsymbol{r}_2$.

The most straightforward expression of the AO/MO integral transformation is

$$
(pq|rs) = \sum_\mu \sum_\nu \sum_\kappa \sum_\lambda C_{\mu p} C_{\nu q} (\mu \nu | \kappa \lambda) C_{\kappa r} C_{\lambda s}
$$

This approach is easy to implement (hence the word "[naïve](https://stackoverflow.com/q/5700575/9647779)" above), but is expensive (time-costly) due to its $O(N^8)$ computational order. Nevertheless, you should start with this algorithm to get your code working, and run timings for the test cases below to get an idea of its computational cost.

````{admonition} Naïve transformation code
:class: dropdown

Here's a code block that demonstrates how to carry out the AO to MO two-electron integral transformation using a single $O(N^8)$ step. Note that the original AO- and transformed MO-basis integrals are stored in a 4-dimensional 8-fold-permutational-symmetric tensors.

Indices `p`, `q`, `r`, `s` denotes molecular orbital $p$, $q$, $r$, $s$. Indices `u`, `v`, `k`, `l` denotes atomic orbital $\mu$, $\nu$, $\kappa$, $\lambda$. In this program, we take the convenience that `mole.nao` $n_\mathrm{AO} = n_\mathrm{MO}$, i.e. number of atomic orbitals is equal to molecular orbitals.

```python
for p in range(mole.nao):
    for q in range(mole.nao):
        for r in range(mole.nao):
            for s in range(mole.nao):
                for u in range(mole.nao):
                    for v in range(mole.nao):
                        for k in range(mole.nao):
                            for l in range(mole.nao):
                                eri_mo[p, q, r, s] += eri_ao[u, v, k, l] * coeff[u, p] * coeff[v, q] * coeff[k, r] * coeff[l, s]
```

Spectacular, isn't it :-)

````

````{admonition} Multiple nested for-loop in one line of code
:class: dropdown

There could be some trick to remove some of nests. One could create an numpy array which contains multiple loop indices (ordinary nested list is also okay). For example, `p`, `q`, `r`, `s` could be looped by following code:

```python
def repeat_loop(dim: int, nested: int) -> np.ndarray:
    return np.array([np.tile(np.repeat(np.arange(dim), dim**(nested - 1 - i)), dim**i) for i in range(nested)]).T
```
```python
>>> repeat_loop(mole.nao, 4)
array([[0, 0, 0, 0],
       [0, 0, 0, 1],
       [0, 0, 0, 2],
       ...,
       [6, 6, 6, 4],
       [6, 6, 6, 5],
       [6, 6, 6, 6]])
```

Then run the following code:

```python
for p, q, r, s in repeat_loop(mole.nao, 4):
    for u, v, k, l in repeat_loop(mole.nao, 4):
        eri_mo[p, q, r, s] += eri_ao[u, v, k, l] * coeff[u, p] * coeff[v, q] * coeff[k, r] * coeff[l, s]
```

The result `eri_mo` should be the same. However, simplify code in this way is at the cost of memory ($O(N^4)$ memory consumption). So in most cases, it is unbearable to use the following memory-costly code, although even more concise:

```python
for p, q, r, s, u, v, k, l in repeat_loop(mole.nao, 8):
    eri_mo[p, q, r, s] += eri_ao[u, v, k, l] * coeff[u, p] * coeff[v, q] * coeff[k, r] * coeff[l, s]
```

````

````{admonition} Timing in Jupyter
:class: dropdown

In Jupyter (or IPython), one can use [magic command](https://ipython.readthedocs.io/en/stable/interactive/magics.html). There are two magic commands in timing:
- [%%time](https://ipython.readthedocs.io/en/stable/interactive/magics.html#magic-time) Time of one execution.
- [%%timeit](https://ipython.readthedocs.io/en/stable/interactive/magics.html#magic-timeit) Average time of multiple execution.

In most cases, `%%time` is more useful and percise. However, `%%time` is only suitable when execution time is relatively quick (0~1000 ms). For this step, we may use `%time`.

````

### Implementation

```python
def get_eri_mo_naive(mole: Molecule) -> np.ndarray:
    # Naive algorithm
    # Output: MO electron repulsion integral
    raise NotImplementedError("About 5~15 lines of code")

Molecule.get_eri_mo_naive = get_eri_mo_naive
```

### Solution

```python
%%time
sol_mole.get_eri_mo_naive()[0, 3]
```

    CPU times: user 19.9 s, sys: 78.1 ms, total: 19.9 s
    Wall time: 20.9 s

    array([[ 0.166246 ,  0.0218153, -0.       ,  0.0244196, -0.       ,  0.0027303,  0.       ],
           [ 0.0218153,  0.0126143, -0.       , -0.0184892,  0.       , -0.0184186, -0.       ],
           [-0.       , -0.       ,  0.0055846,  0.       , -0.       , -0.       , -0.0002852],
           [ 0.0244196, -0.0184892,  0.       , -0.0089254, -0.       ,  0.0012042, -0.       ],
           [-0.       ,  0.       , -0.       , -0.       ,  0.0046733, -0.       , -0.       ],
           [ 0.0027303, -0.0184186, -0.       ,  0.0012042, -0.       ,  0.0180084,  0.       ],
           [ 0.       , -0.       , -0.0002852, -0.       , -0.       ,  0.       ,  0.0037402]])

This algorithm is not suitable if basis gets even larger.

## Step 4: MO ERI Transformation (Smarter Algorithm)

Notice that none of the orbital coefficients $\mathbf{C}$ in the above expression have any indices in common. Thus, the summation could be rearranged such that:

$$
(pq|rs) = \sum_\lambda C_{\lambda s} \left[ \sum_\kappa C_{\kappa r} \left[ \sum_\nu C_{\nu q} \left[ \sum_\mu C_{\mu p} (\mu \nu | \kappa \lambda) \right] \right] \right]
$$

This means that each summation within brackets could be carried out separately, starting from the innermost summation over $\mu$, if we store the results at each step. This reduces the $O(N^8)$ algorithm above to four $O(N^5)$ steps.

After you have the noddy algorithm working and timed, modify it to use this smarter algorithm and time the test cases again. Enjoy!

````{admonition} Hint 1: Smarter algorithm step-by-step
:class: dropdown

Although this algorithm can be illustrated in one line of code, it is better to be splited into 4 lines of formulas.

$$
\begin{align}
(p \nu | \kappa \lambda) &= \sum_\mu C_{\mu p} (\mu \nu | \kappa \lambda) \\
(pq | \kappa \lambda) &= \sum_\nu C_{\nu q} (p \nu | \kappa \lambda) \\
(pq | r \lambda) &= \sum_\kappa C_{\kappa r} (pq | \kappa \lambda) \\
(pq | rs) &= \sum_\lambda C_{\lambda s} (pq | r \lambda) \\
\end{align}
$$

To calculate $(pq|\kappa \lambda)$, for example, could be

```python
loop_indices = repeat_loop(mole.nao, 4)
tmp_1 = np.zeros((nao, nao, nao, nao))
for u, v, k, l in loop_indices:
    for p in range(nao):
        tmp_1[p, v, k, l] += eri_ao[u, v, k, l] * coeff[u, p]
tmp_2 = np.zeros((nao, nao, nao, nao))
for p, v, k, l in loop_indices:
    for q in range(nao):
        tmp_2[p, q, k, l] += tmp_1[p, v, k, l] * coeff[v, q]
```

````

### Implementation

```python
def get_eri_mo_smarter(mole: Molecule):
    # Smarter algorithm
    # Output: MO electron repulsion integral
    raise NotImplementedError("About 5 + 3~5 * 4 lines of code")
```

### Solution

```python
%%time
sol_mole.get_eri_mo_smarter()[0, 3]
```

    CPU times: user 93.8 ms, sys: 0 ns, total: 93.8 ms
    Wall time: 103 ms

    array([[ 0.166246 ,  0.0218153, -0.       ,  0.0244196, -0.       ,  0.0027303,  0.       ],
           [ 0.0218153,  0.0126143, -0.       , -0.0184892,  0.       , -0.0184186, -0.       ],
           [-0.       , -0.       ,  0.0055846,  0.       , -0.       ,  0.       , -0.0002852],
           [ 0.0244196, -0.0184892,  0.       , -0.0089254, -0.       ,  0.0012042, -0.       ],
           [-0.       ,  0.       , -0.       , -0.       ,  0.0046733, -0.       , -0.       ],
           [ 0.0027303, -0.0184186,  0.       ,  0.0012042, -0.       ,  0.0180084,  0.       ],
           [ 0.       , -0.       , -0.0002852, -0.       , -0.       ,  0.       ,  0.0037402]])

```python
%%timeit
sol_mole.get_eri_mo_smarter()[0, 3]
```

    97 ms ± 801 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

## Step 5: MO ERI Transformation (`numpy.einsum`)

`numpy.einsum` ([NumPy API](https://numpy.org/doc/stable/reference/generated/numpy.einsum.html)) provides an extremely concise way to do tensor contraction, with sufficient support of efficiency.

We still use the following formula:

$$
(pq|rs) = \sum_{\mu \nu \kappa \lambda} C_{\mu p} C_{\nu q} (\mu \nu | \kappa \lambda) C_{\kappa r} C_{\lambda s}
$$

and molecular orbital basis ERI could be calculated as

```python
eri_mo = np.einsum("up, vq, uvkl, kr, ls -> pqrs", coeff, coeff, eri_ao, coeff, coeff, optimize=True)
```

where `optimize=True` tells `numpy.einsum` to use the optimized (smarter) algorithm. This optional parameter is critical

Finally, store molecular orbital ERI into attribute `eri_mo` in `Molecule`.

````{admonition} Hint 1: Contraction path of numpy.einsum
:class: dropdown

To see how `numpy.einsum` do the contraction route, we can use function `numpy.einsum_path` ([NumPy API](https://numpy.org/doc/stable/reference/generated/numpy.einsum_path.html)). Learning this function is optional to this project.

```
>>> print(np.einsum_path("up, vq, uvkl, kr, ls -> pqrs", coeff, coeff, eri_ao, coeff, coeff)[1])
  Complete contraction:  up,vq,uvkl,kr,ls->pqrs
         Naive scaling:  8
     Optimized scaling:  5
      Naive FLOP count:  2.882e+07
  Optimized FLOP count:  1.345e+05
   Theoretical speedup:  214.373
  Largest intermediate:  2.401e+03 elements
--------------------------------------------------------------------------
scaling                  current                                remaining
--------------------------------------------------------------------------
   5               uvkl,up->klpv                      vq,kr,ls,klpv->pqrs
   5               klpv,vq->klpq                         kr,ls,klpq->pqrs
   5               klpq,kr->lpqr                            ls,lpqr->pqrs
   5               lpqr,ls->pqrs                               pqrs->pqrs
```

From the output, we know that the naive algorithm complexity is $O(N^8)$, the optimized (smarter) algorithm is $O(N^5)$. The first contraction path, for example, could be expressed as

$$
\mathtt{Tensor1}_{\kappa \lambda p \nu} = \sum_{\mu} (\mu \nu | \kappa \lambda) C_{\mu p}
$$

The last contraction path could be

$$
(pq|rs) = \sum_{\lambda} \mathtt{Tensor3}_{\lambda pqr} C_{\lambda s}
$$

````

### Implementation

```python
def get_eri_mo_einsum(mole: Molecule):
    # Use numpy.einsum to automatically find optimal contraction path
    # Output: MO electron repulsion integral
    raise NotImplementedError("About 1~3 lines of code")

Molecule.get_eri_mo_einsum = get_eri_mo_einsum
```

```python
def obtain_eri_mo(mole: Molecule):
    # Attribute Modification: `eri_mo` Molecular orbital electron repulsion integral
    raise NotImplementedError("Exactly 1 line of code")

Molecule.obtain_eri_mo = obtain_eri_mo
```

### Solution

```python
%%time
sol_mole.get_eri_mo_einsum()[0, 3]
```

    CPU times: user 0 ns, sys: 0 ns, total: 0 ns
    Wall time: 1.49 ms

    array([[ 0.166246 ,  0.0218153, -0.       ,  0.0244196, -0.       ,  0.0027303,  0.       ],
           [ 0.0218153,  0.0126143, -0.       , -0.0184892,  0.       , -0.0184186, -0.       ],
           [-0.       , -0.       ,  0.0055846,  0.       , -0.       ,  0.       , -0.0002852],
           [ 0.0244196, -0.0184892,  0.       , -0.0089254, -0.       ,  0.0012042, -0.       ],
           [-0.       ,  0.       , -0.       , -0.       ,  0.0046733, -0.       , -0.       ],
           [ 0.0027303, -0.0184186,  0.       ,  0.0012042, -0.       ,  0.0180084,  0.       ],
           [ 0.       , -0.       , -0.0002852, -0.       , -0.       ,  0.       ,  0.0037402]])

```python
%%timeit
sol_mole.get_eri_mo_einsum()
```

    416 µs ± 2.87 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

## Step 6: Compute the MP2 Energy

Restricted MP2 correlation energy is (Helgaker, et al., eq 14.4.56)

$$
E_\mathrm{corr}^\mathsf{MP2} = \sum_{iajb} \frac{(ia|jb) \big[ 2 (ia|jb) - (ib|ja) \big]}{\varepsilon_i + \varepsilon_j - \varepsilon_a - \varepsilon_b}
$$

where $i$ and $j$ denote doubly-occupied orbitals and $a$ and $b$ unoccupied orbitals, and the denominator involves the MO energies.

Finally, store MP2 correlation energy into attribute `energy_corr` in `Molecule`.

````{admonition} Hint 1: Transpose of tensor
:class: dropdown

We know that for matrix, we can use `numpy.transpose`, or simply `numpy.ndarray.T` to transpose the matrix. However, tensor have more than 2 dimensions, thus transpose of tensor needs more parameters. For example, if we want to transpose `tsr` $T_{iajb}$ to $T_{ibja}$, we can either use `numpy.transpose` ([NumPy API](https://numpy.org/doc/stable/reference/generated/numpy.transpose.html)) or `numpy.swapaxes` ([NumPy](https://numpy.org/doc/stable/reference/generated/numpy.swapaxes.html)).

```python
>>> tsr.transpose(0, 3, 2, 1)  # `numpy.transpose` approach
>>> tsr.swapaxes(1, 3)         # `numpy.swapaxes`  approach
```

````

````{admonition} Hint 2: Denominator
:class: dropdown

You can make another 4-dimension tensor $D_{ij}^{ab}$, which is stored in $(i, a, j, b)$:

$$
D_{ij}^{ab} = \varepsilon_i - \varepsilon_a + \varepsilon_j - \varepsilon_b
$$

Using NumPy boardcasting ([NumPy doc](https://numpy.org/doc/stable/user/theory.broadcasting.html#array-broadcasting-in-numpy)), this can be done by

```python
D_iajb = e[:nocc, None, None, None] - e[None, nocc:, None, None] + e[None, None, :nocc, None] - e[None, None, None, nocc:]
```

Utilizing this tensor, one can calculate restricted MP2 correlation energy with no for-loops.

````

### Implementation

```python
def eng_mp2_corr(mole: Molecule):
    # Output: (Restricted) MP2 correlation energy
    raise NotImplementedError("About 4~10 lines of code")

Molecule.eng_mp2_corr = eng_mp2_corr
```

```python
def obtain_mp2_corr(mole: Molecule):
    # Attribute Modification: `energy_corr` Post-HF (in this project is RMP2) correlation energy
    raise NotImplementedError("Exactly 1 line of code")

Molecule.obtain_mp2_corr = obtain_mp2_corr
```

### Solution

```python
sol_mole.obtain_eri_mo()
sol_mole.obtain_mp2_corr()
print("SCF total       energy: {:16.8f}".format(sol_mole.energy_rhf))
print("MP2 correlation energy: {:16.8f}".format(sol_mole.energy_corr))
print("MP2 total       energy: {:16.8f}".format(sol_mole.energy_rhf + sol_mole.energy_corr))
```

    SCF total       energy:     -74.94207993
    MP2 correlation energy:      -0.04914964
    MP2 total       energy:     -74.99122956

## Test Cases

The input structures, integrals, etc. for these examples are found in the [input directory](https://github.com/ajz34/PyCrawfordProgProj/tree/master/source/Project_04/). You can also use PySCF approach and simply ignore the integral files.

**Water/STO-3G** ([Directory](https://github.com/ajz34/PyCrawfordProgProj/tree/master/source/Project_04/input/h2o/STO-3G))

```python
sol_mole = SolMol()
sol_mole.construct_from_dat_file("input/h2o/STO-3G/geom.dat")
sol_mole.obtain_mol_instance(basis="STO-3G")
sol_mole.print_solution_04()
```

    SCF total       energy:     -74.94207993
    MP2 correlation energy:      -0.04914964
    MP2 total       energy:     -74.99122956

**Water/DZ** ([Directory](https://github.com/ajz34/PyCrawfordProgProj/tree/master/source/Project_04/input/h2o/DZ))

```python
sol_mole = SolMol()
sol_mole.construct_from_dat_file("input/h2o/DZ/geom.dat")
sol_mole.obtain_mol_instance(basis="DZ")
sol_mole.print_solution_04()
```

    SCF total       energy:     -75.97787898
    MP2 correlation energy:      -0.15270988
    MP2 total       energy:     -76.13058885

**Water/DZP** ([Directory](https://github.com/ajz34/PyCrawfordProgProj/tree/master/source/Project_04/input/h2o/DZP)) For this molecule, some additional code is included. See [Project 03](../Project_03/Project_03.ipynb#Test-Cases) for details.

```python
sol_mole = SolMol()
sol_mole.construct_from_dat_file("input/h2o/DZP/geom.dat")
sol_mole.obtain_mol_instance(basis="DZP_Dunning")
sol_mole.mol._basis["H"][2][1][0] = 0.75
sol_mole.mol.cart = True
sol_mole.mol.build()
sol_mole.print_solution_04()
```

    SCF total       energy:     -76.00882179
    MP2 correlation energy:      -0.22251923
    MP2 total       energy:     -76.23134103

**Methane/STO-3G** ([Directory](https://github.com/ajz34/PyCrawfordProgProj/tree/master/source/Project_04/input/ch4/STO-3G))

```python
sol_mole = SolMol()
sol_mole.construct_from_dat_file("input/ch4/STO-3G/geom.dat")
sol_mole.obtain_mol_instance(basis="STO-3G")
sol_mole.print_solution_04()
```

    SCF total       energy:     -39.72685032
    MP2 correlation energy:      -0.05604667
    MP2 total       energy:     -39.78289699

## References

- Szabo, A.; Ostlund, N. S. *Modern Quantum Chemistry: Introduction to Advanced Electronic Structure Theory* Dover Publication Inc., 1996.

    ISBN-13: 978-0486691862

- Helgaker, T.; Jørgensen, P.; Olsen, J. *Molecular Electronic-Structure Theory* John Wiley & Sons, ltd., 2000

    ISBN-13: 978-1118531471


# Parameter variation studies of kinetic systems
This notebook shows how one can explore the impact of a certain parameter on a kinetic model. We will also use units explicitly for our parameters.

```python
from collections import defaultdict
from itertools import chain
import numpy as np
import sympy as sp
import matplotlib.pyplot as plt
from ipywidgets import interact
from chempy import Substance, Reaction, ReactionSystem
from chempy.kinetics.rates import Arrhenius, MassAction
from chempy.kinetics.ode import get_odesys
from chempy.printing.numbers import number_to_scientific_latex
from chempy.units import SI_base_registry, default_units as u
sp.init_printing()
%matplotlib inline
```

We will use a generic model representing a decay-chain with two decays:

```python
A, B, C = map(Substance, 'ABC')
r1 = Reaction({'A'}, {'B'}, MassAction(Arrhenius(unique_keys=('A1', 'Ea_R_1'))))
r2 = Reaction({'B'}, {'C'}, MassAction(Arrhenius(unique_keys=('A2', 'Ea_R_2'))))
rsys = ReactionSystem([r1, r2])
rsys
```

"Arrhenius" behaviour means that the rate of reaction depends exponentially on the inverse absolute temperature.

We will use units on all our parameters in this notebook. This will prevent us from incorrect conversions or using parameters of the wrong dimensionality where they don't belong:

```python
params = {'A1': 1e11/u.s, 'A2': 2e11/u.s, 'Ea_R_1': 8e3*u.K, 'Ea_R_2': 8.5e3*u.K, 'temperature': 300*u.K}
c0 = defaultdict(lambda: 0*u.molar, {'A': 1*u.molar})
variables = c0.copy()
variables.update(params)
rsys.rates(variables)
```

```python
odesys, extra = get_odesys(rsys, include_params=False, lower_bounds=0)
print(dict(zip(odesys.dep, odesys.names)))
print(dict(zip(odesys.params, odesys.param_names)))
odesys.exprs
```

Let's look at the influence of ``Ea_R_2``, we will choose three temperatures: 8100, 8200 and 8300 K (this is all fictive so never mind the very high temperatures):

```python
params2 = params.copy()
pk = 'Ea_R_2'
params2[pk] = [8.1e3, 8.2e3, 8.3e3]*u.K
```

Running the integartion & plotting the result:

```python
res2 = odesys.integrate(7*u.s, c0, params2, integrator='cvode')
fig, axes = plt.subplots(1, len(res2), figsize=(14, 4))
for r, ax in zip(res2, axes):
    r.plot(ax=ax)
    ax.set_title('$%s = %s$' % (pk.replace('_', '\\_'), number_to_scientific_latex(r.named_param('Ea_R_2'))))
```

We can also use ``ipywidgets`` to get interactive controls:

```python
def integrate_and_plot(T_C=25):
    res = odesys.integrate(7*u.s, c0, dict(params, temperature=(T_C+273.15)*u.K), integrator='cvode')
    res.plot()
```

```python
interact(integrate_and_plot)
```


```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
plt.style.use('classic')
%matplotlib inline
```

# Class 11: Introduction to Dynamic Optimization: A Two-Period Cake-Eating Problem

Dynamic optimization, the optimal choice over elements in a time series, is at the heart of macroeconomic theory. People and firms routinely make decisions that affect their future opportunities. For example, a household chooses how much of its income to save and therefore allocates some of its current consumption to the future. In this Notebook, we consider a simple two-period dynamic optimization model. A person has some initial quantity of cake and chooses the optimal way to eat it over the next two periods. We solve for the optimal consumption path.

## Preferences

Suppose that a person has only two periods to eat cake. Let $C_0$ denote the quantity of cake consumed in period 0 and let $C_1$ denote the quantity consumed in period 1. The person's utility in period 0 from consuming $C_0$ and $C_1$ is given by:

\begin{align}
U(C_0,C_1) & = \log C_0 + \beta \log C_1,
\end{align}

where $0\leqslant\beta\leqslant 1$ is the person's *subjective discount factor*. $U$ is an *intertemporal utility function* and $\log C$ is the *flow* of utility generated each period. The presence of $\beta$ indicates the the person places lower weight on consumption in the future. The utility function implies indifference curves of the form:

\begin{align}
C_1 & =  e^{ \frac{\bar{U} - \log C_0}{\beta}},
\end{align}

where $\bar{U}$ is some given level of utility.

### Example: Plot Indifference Curves for Different Utility Levels

For $C_0$ between 0.01 and 10 and $\beta=0.95$, plot indifference curves for $\bar{U}=1, 2, 3, 4$. Set x- and y-axis limits to [0,10].

```python
# Define a function for computing c1 given a beta value and an array of c0 values
def indifference_curve(u,beta,c0):

    return np.exp((u-np.log(c0))/beta)

# Create a variable called 'c0' that stores c0 values from 0.01 to 10
c0 = np.linspace(0.01,10,1000)

# Create a variable called 'beta' that stores the value of beta
beta = 0.95

# Plot indifference curves
plt.plot(c0,indifference_curve(u=1,beta=beta,c0=c0),lw=3,alpha=0.75,label='u=1')
plt.plot(c0,indifference_curve(u=2,beta=beta,c0=c0),lw=3,alpha=0.75,label='u=2')
plt.plot(c0,indifference_curve(u=3,beta=beta,c0=c0),lw=3,alpha=0.75,label='u=3')
plt.plot(c0,indifference_curve(u=4,beta=beta,c0=c0),lw=3,alpha=0.75,label='u=4')

plt.xlim([0,10])
plt.ylim([0,10])
plt.xlabel('$C_0$',fontsize=14)
plt.ylabel('$C_1$',fontsize=14)
plt.title('Indifference curves of $\log C_0 + 0.95 \log C_1$')

# Place legend to right of figure. PROVIDED
plt.legend(loc='center left', bbox_to_anchor=(1, 0.5))

plt.grid()
plt.tight_layout()
```

### Example: Plot Indifference Curves for Different $\beta$ Values

For $C_0$ between 0.01 and 30 and $\bar{U} = 3$, plot indifference curves for $\beta=1, 0.95,0.5,0.25$. Set x-axis limits to [0,30] and y-axis limits to [0,10].

```python
# Create a variable called u that stores the value of utility for the indifference curves
u = 3

# Create variable called 'c0' that stores c0 values from 0.01 to 30
c0 = np.linspace(0.01,30,1000)

# Plot indifference curves
plt.plot(c0,indifference_curve(u=u,beta=1,c0=c0),lw=3,alpha=0.75,label='$\\beta=1$')
plt.plot(c0,indifference_curve(u=u,beta=0.95,c0=c0),lw=3,alpha=0.75,label='$\\beta=0.95$')
plt.plot(c0,indifference_curve(u=u,beta=0.5,c0=c0),lw=3,alpha=0.75,label='$\\beta=0.5$')
plt.plot(c0,indifference_curve(u=u,beta=0.25,c0=c0),lw=3,alpha=0.75,label='$\\beta=0.25$')

plt.xlim([0,30])
plt.ylim([0,10])
plt.xlabel('$C_0$',fontsize=14)
plt.ylabel('$C_1$',fontsize=14)
plt.title('Indifference curves of $\log C_0 + \\beta \log C_1$')

# Place legend to right of figure. PROVIDED
plt.legend(loc='center left', bbox_to_anchor=(1, 0.5))

plt.grid()
plt.tight_layout()
```

## Budget Constraints and the Boundary Condition

Let $K_t$ denote the quantity of cake available in period $t$ and let $K_0$ denote the initial quantity of cake. The cake neither grows nor depreciates over time so the person faces the following budget constraints each period:

\begin{align}
C_0 & =  K_0 - K_1\\
C_1 & =  K_1 - K_2.
\end{align}

Finally, the person's problem is subject to a *boundary condition* that imposes a limit on the endpoint of the problem. Since the person only consumes cake in periods 0 and 1, there is no value in leaving leftover cake at the end of period 1. This reasoning implies the boundary condition:

\begin{align}
K_2 & = 0.
\end{align}

The boundary condition allows us to eliminate $K_2$ from the period 1 budget constraint. Combining the two budget constraints to eliminate $K_1$ implies an *intertemporal budget constraint* that links consumption in period 0 with consumption in period 1:

\begin{align}
C_0 & =  K_0 - C_1.
\end{align}

Notice that the implied price of $C_0$ in terms of $C_1$ is 1.

### Example: Plot the Intertemporal Budget Constraint

For $C_0$ between 0.01 and 20 and plot indifference curves for $K_0=5, 10, 15, 20$. Set x-axis limits to [0,10] and y-axis limits to [0,30].

```python
# Define a function for computing c1 given a k0 value and an array of c0 values
def bc(k0,c0):
    '''Function for computing the intertemporal budget constraint for the two-period cake-eating problem

            Args:
            k0 (float):         initial quantity of cake
            c0 (numpy array):   array of initial consumption values

        Returns:
            numpy array
    '''

    c1 = k0-c0
    c1[c1<0]=0

    return c1

# Create variable called 'c0' that stores c0 values from 0.01 to 10
c0 = np.linspace(0.01,10,1000)

# Plot budget constraints
plt.plot(c0,bc(5,c0),lw=3,alpha=0.75,label='$K_0=5$')
plt.plot(c0,bc(10,c0),lw=3,alpha=0.75,label='$K_0=10$')
plt.plot(c0,bc(15,c0),lw=3,alpha=0.75,label='$K_0=15$')
plt.plot(c0,bc(20,c0),lw=3,alpha=0.75,label='$K_0=20$')

plt.xlabel('$C_0$',fontsize=14)
plt.ylabel('$C_1$',fontsize=14)
plt.title('Budget Constraints: $C_1 = K_0 - C_1$')

# Place legend to right of figure. PROVIDED
plt.legend(loc='center left', bbox_to_anchor=(1, 0.5))

plt.grid()
plt.tight_layout()
```

## Optimization

The person's problem is to choose $C_0$, $C_1$, $K_1$, and $K_2$ to maximize his utility subject to the two budget constraints and the boundary condition. However, the problem can be simplified considerably. Use the boundary condition to eliminate $K_2$ from the budget constraints. Then use the budget constraints to substitute $C_0$ and $C_1$ out of the utility function. The result is an unconstrained optimization problem in $K_1$.

\begin{align}
\max_{K_1} \; \log (K_0 - K_1) + \beta \log K_1,
\end{align}

Take the derivative with respect to $K_1$ and set the derivative equal to zero to obtain the *first-order* or *optimality* condition:

\begin{align}
-\frac{1}{K_0 - K_1} + \frac{\beta}{K_1} & = 0.
\end{align}

Solve for $K_1$ to find the optimal cake holding for period 1:

\begin{align}
\boxed{K_1  = \frac{\beta}{1+\beta}K_0}
\end{align}

Then use the budget constraints to infer optimal cake consumption in period 0:
\begin{align}
\boxed{C_0  = \frac{1}{1+\beta}K_0}
\end{align}

and period 1:

\begin{align}
\boxed{C_1  = \frac{\beta}{1+\beta}K_0}.
\end{align}

Notice that $C_0 + C_1 = K_0$ so all of the cake is eaten by the end.

### Example: Solve for the Optimal Consumption Path

Compute $K_1, C_0, C_1$ for $\beta = 0.9$ and $K_0 = 20$.

```python
# Define a function that returns the solution to the two-period cake-eating problem
def two_period_cake(beta,cake0):

    '''Function for computing solution to two-period cake-eating problem

            Args:
            beta (float):   subjective discount factor
            cake0 (float):  initial quantity of cake

        Returns:
            numpy array, numpy array
    '''

    # Initialize arrays for cake and consumption
    cake = np.zeros(3)
    consumption = np.zeros(2)

    # Assign values to cake array elements
    cake[0] = cake0
    cake[1] = beta/(1+beta)*cake[0]

    # Assign values to consumption array elements as implied by the budget constraints
    consumption[0] = cake[0]-cake[1]
    consumption[1] = cake[1]

    return cake,consumption

# Create a variable called 'cake0' that stores initial cake value
cake0 = 20

# Create a variable called 'beta' that stores beta value
beta = 0.9

# Compute the optimal path
cake,consumption = two_period_cake(beta=beta,cake0=cake0)
print('k[1]:',round(cake[1],5))
print('c[0]:',round(consumption[0],5))
print('c[1]:',round(consumption[1],5))
```

    k[1]: 9.47368
    c[0]: 10.52632
    c[1]: 9.47368

$\beta <1$ means that the person places greater weight on consumption in period 0 and therefore the person chooses to consume a greater share of the cake in period 0.


```python
from sympy import *
from math import sin, pi
from scipy.integrate import dblquad, quad
from numpy.random import uniform
import numpy as np
import matplotlib.pyplot as plt
```

```python
def f(x,y):
    return (sin(pi*y)-sin(pi*x))/(y-x)
```

```python
quad(lambda x: (.776*x - np.sin(np.pi*x))**2, -1, 1)
```

```python
N_TRIALS = 10**8
x1 = uniform(low=-1, high=1, size=N_TRIALS)
x2 = uniform(low=-1, high=1, size=N_TRIALS)
y1 = np.sin(np.pi*x1)
y2 = np.sin(np.pi*x2)
a1 = (x1*y1 + x2*y2)/(x1**2 + x2**2)
a2 = (x1**2*y1 + x2**2*y2)/(x1**4 + x2**4)
b3 = x1*y2/(x2*(x1-x2)) - x2*y1/(x1*(x1-x2))
a3 = y1/(x1*(x1-x2)) - y2/(x2*(x1-x2))
mean_a1 = np.mean(a1)
mean_a2 = np.mean(a2)
mean_a3 = np.mean(a3)
mean_b3 = np.mean(b3)
print(mean_a1)
print(mean_a2)
print(mean_a3)
print(mean_b3)
```

    1.42814904333
    -2.64344587668e-05
    7.26530522799e-05
    2.92810987114

```python
def g1(x):
    return mean_a*x

def g3(x):
    return mean_a3*x**2 + mean_b3*x
```

```python
MC = 10**6
x = uniform(low=-1, high=1, size=MC)
bias1 = (g1(x) - np.sin(pi*x))**2
var1 = np.mean((a - mean_a)**2) * x**2
bias2 = (g3(x) - np.sin(pi*x))**2
total_err1 = np.mean(bias) + np.mean(var)
print(mean_bias)
print(mean_var)
```

```python
xplot = np.linspace(-1, 1)
yplot1 = g1(xplot)
yplot3 = g3(xplot)
plt.plot(xplot, np.sin(np.pi*xplot), xplot, yplot1, xplot, yplot3)
plt.show()
```

```python

```


Stochastic Processes:

Data Analysis and Computer Simulation

Brownian motion 2: computer simulation

-Random force in the Langevin equation-

#### Note 1

- I the previous lesson, we learned how to simulate the motion of a damped harmonic oscillator using Python.
- We have also tested a few different numerical integration schemes, such as the Euler and Runge-Kutta method, and examined their numerical accuracy by calculating the total energy of the system for the undamped case.
- Now, we will try to perform a similar simulation for the motion of a Brownian particle,by numerically solving the Langevin Equation.
- In this lesson, to prepare for performing the simulations, we will first learn how to deal with the stochastic random force when we numerically solve the Langevin equation.

# Langevin equation

## Model for a Brownian particle in 3-D

\begin{equation}
m\frac{d\mathbf{V}(t)}{dt}=\color{blue}{-\zeta\mathbf{V}(t)}+\color{red}{\mathbf{F}(t)}
\tag{21}
\end{equation}

#### Note 2

- Let us start by recalling the Langevin equation which we introduced last week as Eq.(21).
- We consider the equation of motion of a spherical particle of radius $a$ and mass $m$ in a solvent fluid, which we treat as a continuum medium with viscosity $\eta$.
- $R(t)$ and $V(t)$ are the position and velocity of the particle at time $t$.
- To construct Newton's equation of motion for a Brownian particle, the inertia force, which is on the left hand side of Eq.(F1), must be balanced with the total force acting on the particle.
- Here we assume that the total force is composed of two distinct components, namely the friction force and the random force.
- The former is proportional, but in the opposite direction, to the particle's velocity, with the friction constant $\zeta$ the constant of proportionality.
- The later represents the effects of the many collisions taking place between the Brownian particle and the fluid molecules as a series of random events.

## Time evolution equations

\begin{equation}
\frac{d\mathbf{R}(t)}{dt}=\mathbf{V}(t)\tag{F1}
\end{equation}

\begin{equation}
m\frac{d\mathbf{V}(t)}{dt}=\color{black}{-\zeta\mathbf{V}(t)}+\color{black}{\mathbf{F}(t)}
\tag{F2}
\end{equation}

## Random force

\begin{equation}
\langle \mathbf{F}(t)\rangle=\mathbf{0}
\tag{F3}
\end{equation}

\begin{equation}
\langle \mathbf{F}(t)\mathbf{F}(0)\rangle = {2k_B T\zeta}\mathbf{I}\delta(t)
\tag{F4}
\end{equation}

#### Note 3

- By putting all of this together, we obtain the Langevin equation given in Eq.(F2), which determines the time evolution of particle's velocity together with Eq.(F1), which determines its temporal position.
- For the random force, it is assumed that the average of the force along any direction, x, y, or z, is zero as shown in Eq.(F3).
- As we have seen already, each component of the random force is considered to be a white noise random variable.
- Therefore the successive forces are completely uncorrelated, and also independent of the forces in different directions.
- This is expressed mathematically as Eq.(F4), which defines the auto-correlation function for the random force as proportional to the unit tensor $\mathbf{I}$ and the Dirac delta function $\delta(t)$.
- The pre-factor $2k_B T \zeta$ is determined to satisfy the fluctuation dissipation theorem, which we have also learned about previously.

## Cf. Euler method for a damped harmonic oscillator

$$
\frac{d\mathbf{R}(t)}{dt} =\mathbf{V}(t)\hspace{17mm} \tag{B1}
$$

$$
m\frac{d\mathbf{V}(t)}{dt}=-\zeta\mathbf{V}(t)-k\mathbf{R}(t) \tag{B2}
$$

$$
\mathbf{R}_{i+1}=\mathbf{R}_i+\int_{t_i}^{t_{i+1}} dt\mathbf{V}(t)\simeq\mathbf{R}_i+\mathbf{V}_i \Delta t \hspace{15mm}\tag{B3}
$$

$$
\mathbf{V}_{i+1}=\mathbf{V}_i-\frac{\zeta}{m}\int_{t_i}^{t_{i+1}} dt\mathbf{V}(t)-\frac{k}{m}\int_{t_i}^{t_{i+1}} dt\mathbf{R}(t)
$$
$$
\simeq\left(1-\frac{\zeta}{m}\Delta t\right)\mathbf{V}_i - \frac{k}{m} \mathbf{R}_i \Delta t \hspace{12mm}\tag{B4}
$$

#### Note 4

- Let me first summarize what we have done for the damped harmonic oscillator, whose equations of motion are represented as usual ordinary differential equations shown in Eqs.(B1) and (B2).
- The Euler method allows us to numerically solve Eqs.(B1) and (B2) by discretizing the integral over a small time increment $\Delta t$.
- The approximated difference equations shown in Eqs. (B3) and (B4) are correct to first order in $\Delta t$.

## Application of Euler method to Eqs.(F1) and (F2)

\begin{equation}
\mathbf{R}_{i+1}=\mathbf{R}_i+\int_{t_i}^{t_{i+1}} dt\mathbf{V}(t)\simeq\mathbf{R}_i+\mathbf{V}_i \Delta t
\tag{F5}
\end{equation}

$$
\mathbf{V}_{i+1}=\mathbf{V}_i-\frac{\zeta}{m}\int_{t_i}^{t_{i+1}} dt\mathbf{V}(t)+\frac{1}{m}
\color{red}{
\int_{t_i}^{t_{i+1}} dt\mathbf{F}(t)}
$$
$$
\neq
\left(1-\frac{\zeta}{m}\Delta t\right)\mathbf{V}_i + \frac{1}{m} \color{red}{\mathbf{F}_i\Delta t}
\tag{F6}
$$

$$
\because
\color{black}{\int_{t_i}^{t_{i+1}} dt\mathbf{F}(t)\neq\mathbf{F}_i\Delta t}
\tag{F7}
$$

#### Note 5

- Although it is not suitable in the present case, for solving the Langevin equation with a random force, a simple application of the Euler method leads to the set of difference equations shown here as Eqs.(F5) and (F6). As you can see, they are very similarly to Eqs.(B3) and (B4) for the damped harmonic oscillator.
- While Eq.(F5) is OK, Eq.(6) is completly wrong.
- It is incorrect to approximate the integral of the random force $F$ over the interval $\Delta t$, from $t_i$ to $t_{i+1}$, as $F_i$ times $\Delta t$, as given in Eq.(F7).
- This approximation is valid only when the integrand is a smooth continuous function of $t$, such that the Taylor expansion in terms of $\Delta t$ can be truncated to first order.
- The random force is, however, not continuous. Although we do assume that its integral is continous.
- It is a stochastic variable, rapidly fluctuating around its mean, and there exist no correlations between subsequent values.

## Cumulative impulse $\Delta \mathbf{W}_i$: the Wiener process

$$
\int_{t_i}^{t_{i+1}} dt\mathbf{F}(t)
\equiv\Delta\mathbf{W}_i
\tag{F8}
$$

- $F_\alpha(t)\rightarrow$ A series of random numbers drawn from some distribution with an average and variance equal to zero and $2k_B T\zeta$, respectively.

- $\Delta W_{\alpha,i}$ $\rightarrow$ A series of random numbers drawn from a "Gaussian distribution", with an average and variance equal to zero and $2k_B T\zeta\Delta t$, respectively. This is a consequence of the central limit theorem (see the supplemental note for details).

#### Note 6

- Since approximate integration methods based on a Taylor expansion are not applicable when we have a random force, we introduce a new stochastic variable $\Delta W_i$, which represents the cumulative impulse imparted on the particle over a time interval $\Delta t$, from $t_i$ to $t_{t+1}$. In the literature, this is referred to as a Wiener process.
- From Eqs.(F3) and (F4) we see that the temporal values of the random force along each direction are sampled by a series of random numbers drawn from a well-defined distribution, with an average and variance equal to zero and  $2kBT\zeta$, respectively.
- Because $\Delta W_i$ is defined as the cumulative impulse of $F(t)$ over a time interval $\Delta t$, it is expected that the $\Delta W_i$ along each direction are sampled by a series of random numbers drawn from a Gaussian distribution, with a well-defined average and variance, consequence of the central limit theorem.
- Theoretical considerations based on the properties of random variables indeed show that $\Delta W_i$ is given by a "Gaussian distribution", with average and variance equal to zero and  $2kBT\zeta$ times $\Delta t$, respectively.
- While this result is what one would intuitively expect from the central limit theorem, you are encouraged to refer to the supplemental note for a detailed derivation.

## Modified velocity update equation (Eq.(F6)$\rightarrow$(F9))

$$
\mathbf{V}_{i+1}=\mathbf{V}_i-\frac{\zeta}{m}\int_{t_i}^{t_{i+1}} dt\mathbf{V}(t)+\frac{1}{m}
\color{red}{
\int_{t_i}^{t_{i+1}} dt\mathbf{F}(t)}
$$
$$
\simeq\left(1-\frac{\zeta}{m}\Delta t\right)\mathbf{V}_i + \frac{1}{m} \color{red}{\Delta \mathbf{W}_i}
\tag{F9}
$$

\begin{equation}
\langle \Delta \mathbf{W}_i\rangle=\mathbf{0}
\tag{F10}
\end{equation}

\begin{equation}
\langle \Delta \mathbf{W}_i\Delta \mathbf{W}_j\rangle = {2k_B T\zeta}\Delta t\mathbf{I}\delta_{ij}
\tag{F11}
\end{equation}

#### Note 7

- Instead of the naive and incorrect approximation of Eq.(F6), a correct set of modified equations for the velocity update are shown here as Eqs.(F9) to (F11).
- Simulations for the motions of a Brownian particle can thus be performed by solving the coupled set of equations (F5), (F9), (F10) and (F11).
- In the next lesson, I will show you how to do exactly this using Python.

## Homework

```python

```


```python
from sympy import *

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

```python
alpha, gamma, a, b, c, d = symbols(
    'alpha gamma a b c d', float=True
)

t = Symbol('t')
p = Function('p', is_real = true)(t)

D = Function('D', is_real = true)(p)
S = Function('S', is_real = true)(p)

D = -a*p + b
S = c*p + d

z = Function('z', is_real = true)(p)
z = D - S

class BasicOperationsForGivenODE:
    """
        У конструкторі наведені допоміжні аргументи для автономного рівняння
                            p'(t) = alpha * F(z(p(t))),
        де z(p) = D(p) - S(p) = (b-d)-(a+c)p, p = p(t), t >= 0
        a, b, c, d > 0 - параметри лінійних функцій попиту та пропозиції
        gamma > 0 таке, що p(0) = gamma
        F така, що F(0) = 0, F(x) = y, sign(x) = sign(y)
    """
    def __init__(self, F):
        self.F = Function('F', is_real = true)(z)
        self.F = F

        self.diffeq = Eq(p.diff(t), alpha * self.F)

        self.sol_non = dsolve(self.diffeq)
        self.sol_chy = dsolve(self.diffeq, ics={p.subs(t, 0): gamma})

    # Надалі:
    # s - набір чисел для кожного параметра.
    # (Можна знехтувати, якщо потрібно отримати загальний результат)
    # chy - чи врахувати початкову умову автономного рівняння чи ні
    def get_solution(self, chy: bool = False, s: dict = {}):
        """
            Метод розв'язує задане ДР з урахуванням (або без) задачі Коші
        """
        sol = self.sol_chy if chy else self.sol_non
        if isinstance(sol, Equality):
            return sol.subs(s)
        for i, sl in enumerate(sol):
            sol[i] = sl.subs(s)
        return sol

    def get_equation(self, s: dict = {}):
        """
            Метод повертає загальний вигляд диференціального рівняння
            з урахуванням вхідних даних
        """
        return factor(self.diffeq).subs(s)

    def get_stable_points(self, s: dict = {}):
        """
            Метод розв'язує алгебричне рівняння відносно функції ціни,
            повертає точку рівноваги (розв'язок)
        """
        return solveset(z, p).subs(s)

    @staticmethod
    def rhs_solution_lambdify(diffeq_sol, input_array, alph, params_dict, chy: bool = True):
        """
            Метод для перетворення розв'язку ДР на функцію, яку можна
            використовувати на масивах бібліотеки numpy
        """
        #sol = self.sol_chy if chy else self.sol_non
        sol = diffeq_sol
        sol_rhs = sol.rhs.subs(params_dict).subs(
            {alpha: alph}
        )
        return lambdify(t, sol_rhs, 'numpy')(input_array)
```

```python
def fast_plot(x, array_of_alphas, case_string, ode_cls, sol = None):
    """
        Функція забезпечує зображення графіків функції p(t)
        в залежності від можливих параметрів адаптації з множини
        array_of_alphas
    """
    plt.figure(figsize=(16, 10))
    plt.grid(1)
    plt.xlabel("Time, t", fontdict={'fontsize': 14})
    plt.ylabel("Price, p(t)", fontdict={'fontsize': 14})
    diffeq_sol = ode_cls.get_solution(chy = True, s = {}) if sol is None else sol
    for alph in array_of_alphas:
        plt.plot(x, ode_cls.rhs_solution_lambdify(diffeq_sol, x, alph, params_cases[case_string]), label='α = %.2f' % alph)
    plt.legend(loc='upper right', prop={'size': 16})
    plt.title(
        "Price behaviour depending on adaptation coefficient change",
        fontdict={'fontsize': 16}
    )

    plt.show()
```

```python
t_space = np.linspace(0, 1.5, 100)

gamma_global = 10
alphas = [0.25, 1, 1.75]

params_cases = {
    'case1': {a: 10, b: 15, c: 5, d: 10, gamma: gamma_global},
    'case2': {a: 8, b: 12, c: 8, d: 10, gamma: gamma_global},
    'case3': {a: 6, b: 5, c: 7, d: 5, gamma: gamma_global}
}

F1 = Function('F1', is_real = true)(z)
F1 = z

F2 = Function('F2', is_real = true)(z)
F2 = z*z*z
```

```python
sd = BasicOperationsForGivenODE(F1)
```

```python
F1
```

$\displaystyle - a p{\left(t \right)} + b - c p{\left(t \right)} - d$

```python
sd.get_solution({})
```

$\displaystyle p{\left(t \right)} = \frac{b - d + e^{\left(C_{1} - \alpha t\right) \left(a + c\right)}}{a + c}$

```python
fast_plot(t_space, alphas, 'case1', sd)
```

```python
hd = BasicOperationsForGivenODE(F2)
```

```python
F2
```

$\displaystyle \left(- a p{\left(t \right)} + b - c p{\left(t \right)} - d\right)^{3}$

```python
sol1, sol2 = hd.get_solution(chy=True, s={})
```

```python
sol1
```

$\displaystyle p{\left(t \right)} = \frac{- a^{3} \alpha b t + a^{3} \alpha d t - \frac{a^{3} b}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} + \frac{a^{3} d}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} - 3 a^{2} \alpha b c t + 3 a^{2} \alpha c d t - \frac{3 a^{2} b c}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} + \frac{3 a^{2} c d}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} - 3 a \alpha b c^{2} t + 3 a \alpha c^{2} d t - \frac{3 a b c^{2}}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} + \frac{3 a c^{2} d}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} - \frac{\sqrt{2} a \sqrt{a^{3} \alpha t + \frac{a^{3}}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} + 3 a^{2} \alpha c t + \frac{3 a^{2} c}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} + 3 a \alpha c^{2} t + \frac{3 a c^{2}}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} + \alpha c^{3} t + \frac{c^{3}}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}}}}{2} - \alpha b c^{3} t + \alpha c^{3} d t - \frac{b c^{3}}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} + \frac{c^{3} d}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} - \frac{\sqrt{2} c \sqrt{a^{3} \alpha t + \frac{a^{3}}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} + 3 a^{2} \alpha c t + \frac{3 a^{2} c}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} + 3 a \alpha c^{2} t + \frac{3 a c^{2}}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} + \alpha c^{3} t + \frac{c^{3}}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}}}}{2}}{- a^{4} \alpha t - \frac{a^{4}}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} - 4 a^{3} \alpha c t - \frac{4 a^{3} c}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} - 6 a^{2} \alpha c^{2} t - \frac{6 a^{2} c^{2}}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} - 4 a \alpha c^{3} t - \frac{4 a c^{3}}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}} - \alpha c^{4} t - \frac{c^{4}}{2 a^{3} \gamma^{2} - 4 a^{2} b \gamma + 6 a^{2} c \gamma^{2} + 4 a^{2} d \gamma + 2 a b^{2} - 8 a b c \gamma - 4 a b d + 6 a c^{2} \gamma^{2} + 8 a c d \gamma + 2 a d^{2} + 2 b^{2} c - 4 b c^{2} \gamma - 4 b c d + 2 c^{3} \gamma^{2} + 4 c^{2} d \gamma + 2 c d^{2}}}$

```python
Eq(p.diff(t), alpha*(b-a*p)**3)
```

$\displaystyle \frac{d}{d t} p{\left(t \right)} = \alpha \left(- a p{\left(t \right)} + b\right)^{3}$

```python
ss = dsolve(Eq(p.diff(t), alpha*(b-a*p)**3), p)
```

```python
ss[0]
```

$\displaystyle p{\left(t \right)} = - \frac{\sqrt{2}}{2 \sqrt{a^{3} \left(C_{1} + \alpha t\right)}} + \frac{b}{a}$

```python
ss[1]
```

$\displaystyle p{\left(t \right)} = \frac{\sqrt{2}}{2 \sqrt{a^{3} \left(C_{1} + \alpha t\right)}} + \frac{b}{a}$

```python
ssc = dsolve(Eq(p.diff(t), (b-a*p)**3), p, ics = {p.subs(t, 0): 10})
```

```python
ssc[0]
```

$\displaystyle p{\left(t \right)} = - \frac{\sqrt{2}}{2 \sqrt{a^{3} \left(t + \frac{1}{2 a \left(10 a - b\right)^{2}}\right)}} - \frac{b t}{a \left(- t - \frac{1}{2 a \left(10 a - b\right)^{2}}\right)} - \frac{b}{2 a^{2} \left(10 a - b\right)^{2} \left(- t - \frac{1}{2 a \left(10 a - b\right)^{2}}\right)}$

```python
(1/(2*(a+c)**3))*((gamma-((b-d)/(a+c)))**(-2))
```

$\displaystyle \frac{1}{2 \left(a + c\right)^{3} \left(\gamma - \frac{b - d}{a + c}\right)^{2}}$

```python
diffeq_sol_z3 = Eq(p, ((b-d)/(a+c)) + 1/sqrt(2*((a+c)**3)*(alpha*t*(1/(2*(a+c)**3))*((gamma-((b-d)/(a+c)))**(-2)))))
```

```python
diffeq_sol_z3
```

$\displaystyle p{\left(t \right)} = \frac{b - d}{a + c} + \frac{1}{\sqrt{\frac{\alpha t}{\left(\gamma - \frac{b - d}{a + c}\right)^{2}}}}$

```python
t_space[0] = t_space[0] - 0.0001
```

```python
fast_plot(t_space, alphas, 'case1', sd, sol=diffeq_sol_z3)
```

```python

```


# Attempt to Improve Solving a Linear Function using a Nueral Network
Given code to use a neural network to fit a linear function, try to optimize the code to get a better fit, i.e. the data points completely overlap on the plot.

```python
import torch
import torch.nn as nn
import numpy as np
import torch.nn.functional as F
import matplotlib.pyplot as plt
```

## Define the Function
Here we define our function $f(x)=ax+b$ with coefficient $a=1$ and intercept term $b=2$.
Then we test the equation with a test value of 2.

```python
# target function
a = 1.
b = 2.
f = lambda x: a*x+b

#test
f(2.)
```

    4.0

## Create Model
Next, we create the neural network model. This is done first by setting the inner and outer dimensions with variables. Next we code the model and vary the internal dimensions to attempt to improve the model. At this level, this is essentially a simple linear algebra exercise:

If we have input $x$, internal parameters $a,b$, and solution $f(x)$ then in the one-dimensional case we have:

\begin{equation}
  \left(
    a_{1}x+b_{2}
  \right)
  a_{2} + b_{2}
  = f(x)
\end{equation}

However, we want to get a better estimate for the true equation. So we increase the interior dimension which corresponds to the number of neurons inside the network. For example, we raised the inner dimension to 3. In matrix form we have:

\begin{equation}
\left(
  \begin{bmatrix} x \end{bmatrix}
  \begin{bmatrix} a_{1} & a_{2} & a_{3} \end{bmatrix}
  +
  \begin{bmatrix} b_{1} & b_{2} & b_{3} \end{bmatrix}
\right)
 \begin{bmatrix} a_{4} \\ a_{5} \\ a_{6} \end{bmatrix}
 +
 \begin{bmatrix} b_{4} \\ \end{bmatrix}
 =
 \begin{bmatrix} f(x) \end{bmatrix}
\end{equation}

What we discovered here is that ReLU was slowing down the process, so since our function is Linear, we can just remove it.

Additionally, we discerned that the higher the inner dimension, that is, the more nodes in each layer, the smaller the error and the better the performance.

```python
#model
#nn.Linear
in_dim = 1
out_dim = 1

model = nn.Sequential(
    nn.Linear(in_dim, 30),
#    nn.ReLU(),
    nn.Linear(30, out_dim)
)
```

Here we define the Loss function as the Mean Squared Error(MSE).

Note that by doing so, we are essentially 'cheating' the system. In most applications, we would not know the function $f$ so we would be unable to find the MSE.

```python
#loss function
criterion = nn.MSELoss()
```

Next we choose a learning rate and a method for learning. The learning rate is the percent of the data that is accepted in each iteration. The Methods we tried were SGD and Adam.

```python
#optimizer
learning_rate = 0.001
optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate)
```

## Train the Model

First we create the training data. This is a batch of random points that we pass through our function $f$.

```python
#training data

batch_size = 1000

x_train = torch.randn(batch_size, 1)
y_train = f(x_train)

```

Once we have the training data, we pass this collection of inputs and solutions into the model. With each iteration we calculate the loss and attempt to optimize the model to further reduce the loss.

In this code we print out the loss every 500 iterations.

```python
# Train the model

num_epochs = 10000

for epoch in range(num_epochs):

    # Forward pass
    outputs = model(x_train)
    loss = criterion(outputs, y_train)

    # Backward and optimize
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    if (epoch+1) % 500 == 0:
        print ('Epoch [{}/{}], Loss: {:.4f}'.format(epoch+1,
                                                    num_epochs, loss.item()))

```

    Epoch [500/10000], Loss: 0.0000
    Epoch [1000/10000], Loss: 0.0000
    Epoch [1500/10000], Loss: 0.0000
    Epoch [2000/10000], Loss: 0.0000
    Epoch [2500/10000], Loss: 0.0000
    Epoch [3000/10000], Loss: 0.0000
    Epoch [3500/10000], Loss: 0.0000
    Epoch [4000/10000], Loss: 0.0000
    Epoch [4500/10000], Loss: 0.0000
    Epoch [5000/10000], Loss: 0.0000
    Epoch [5500/10000], Loss: 0.0000
    Epoch [6000/10000], Loss: 0.0000
    Epoch [6500/10000], Loss: 0.0000
    Epoch [7000/10000], Loss: 0.0000
    Epoch [7500/10000], Loss: 0.0000
    Epoch [8000/10000], Loss: 0.0000
    Epoch [8500/10000], Loss: 0.0000
    Epoch [9000/10000], Loss: 0.0000
    Epoch [9500/10000], Loss: 0.0000
    Epoch [10000/10000], Loss: 0.0000

## Testing the Model
Now that we have a trained model with low Loss, we want to attempt to replicate the function. To do this we get another random sample of numbers. This sample is passed into both our fuction $f$ and the model.

We then graph both sets of points on a scatter plot. Since the model is highly accurate now, the two sets of points completely overlap.

```python
#test
x_ = torch.randn(50,1)
y_ = f(x_)
plt.scatter(x_.detach().numpy(), y_.detach().numpy(), label='true')

y_pred = model(x_)
plt.scatter(x_.detach().numpy(), y_pred.detach().numpy(), label='pred')

plt.legend()
```

__Todo__

- Improve the above code.
- train a model to $f(x) = x^2 + 1$
- train a model to
$$f(x) = \begin{bmatrix}
1 & 1 \\
0 & 1
\end{bmatrix} x +
\begin{bmatrix}
1  \\
0
\end{bmatrix}$$

```python

```


```
from __future__ import division

import numpy as np
import matplotlib.pyplot as plt

import pydy
import sympy as sym
import sympy.physics.mechanics as me

from scipy.linalg import solve, lstsq
from scipy.integrate import odeint

import time
```

```
n = 3

q0, q1, q2, q3 = me.dynamicsymbols('q0 q1 q2 q3')
u0, u1, u2, u3 = me.dynamicsymbols('u0 u1 u2 u3')

q0d, q1d, q2d, q3d = me.dynamicsymbols('q0 q1 q2 q3', 1)
u0d, u1d, u2d, u3d = me.dynamicsymbols('u0 u1 u2 u3', 1)

g, t = sym.symbols('g t')
m0, m1, m2 = sym.symbols('m0 m1 m2')
l0, l1, l2 = sym.symbols('l0 l1 l2')
Izz0, Izz1, Izz2 = sym.symbols('Izz1 Izz2 Izz3')

# define reference frames
N = me.ReferenceFrame('N')
# fr0 = N.orientnew('fr0', 'Body', [0, 0, q0], '123')
# fr1 = fr0.orientnew('fr1', 'Body', [0, 0, q1], '123')
# fr2 = fr1.orientnew('fr2', 'Body', [0, 0, q2], '123')

# this should also work
fr0 = N.orientnew('fr0', 'Axis', [q0, N.x])
fr1 = fr0.orientnew('fr1', 'Axis', [q1, fr0.x])
fr2 = fr1.orientnew('fr2', 'Axis', [q2, fr1.x])

# angular velocities of frames (this I am less sure about...)
fr0.set_ang_vel(N, u0 * N.z)
fr1.set_ang_vel(fr0, u1 * fr0.z)
fr2.set_ang_vel(fr1, u2 * fr1.z)

# origin
O = me.Point('O')
O.set_vel(N, 0)

jt0 = me.Point('jt0')
jt0.set_pos(O, q3 * N.x)
jt0.set_vel(N, u3 * N.x)

# joints
jt1 = jt0.locatenew('jt1', l0 * fr0.x)
jt2 = jt1.locatenew('jt2', l1 * fr1.x)
jt3 = jt2.locatenew('jt3', l2 * fr2.x)

# mass centers
mc0 = jt0.locatenew('mc0', l0 / 2 * fr0.x)
mc1 = jt1.locatenew('mc1', l1 / 2 * fr1.x)
mc2 = jt2.locatenew('mc2', l2 / 2 * fr2.x)

# unconstrained point velocities
jt1.v2pt_theory(jt0, N, fr0)
jt2.v2pt_theory(jt1, N, fr1)
jt3.v2pt_theory(jt2, N, fr2)

mc0.v2pt_theory(jt0, N, fr0)
mc1.v2pt_theory(jt1, N, fr1)
mc2.v2pt_theory(jt2, N, fr2)

# inertia dyads
in0 = (me.inertia(fr0, 0, 0, Izz0), mc0)
in1 = (me.inertia(fr1, 0, 0, Izz1), mc1)
in2 = (me.inertia(fr2, 0, 0, Izz2), mc2)

# bodies
bd0 = me.RigidBody('bd0', mc0, fr0, m0, in0)
bd1 = me.RigidBody('bd1', mc1, fr1, m1, in1)
bd2 = me.RigidBody('bd2', mc2, fr2, m2, in2)

# particles
pt0 = me.Particle('pt0', jt0, m0)

# kinematic differential equations
kd = [q0.diff(t) - u0,
      q1.diff(t) - u1,
      q2.diff(t) - u2,
      q3.diff(t) - u3]

# forces
fl = [(mc0, -m0 * g * N.y),
      (mc1, -m1 * g * N.y),
      (mc2, -m2 * g * N.y),
      (jt0, -m0 * g * N.y)]

# bodies
bl = [bd0, bd1, bd2]

# qs and us
qs = [q0, q1, q2, q3]
us = [u0, u1, u2, q3]

# equations of motion using Kane's method
kane = me.KanesMethod(frame=N,
                      q_ind=qs,
                      u_ind=us,
                      kd_eqs=kd,
                      q_dependent=[],
                      configuration_constraints=[],
                      u_dependent=[],
                      velocity_constraints=[])

(fr, frstar) = kane.kanes_equations(fl, bl)
kanezero = fr + frstar

# solve Kane's equations for the derivatives of the speeds
#eom = sym.solvers.solve(kanezero, q0d, q1d, q2d)

# add the kinematical differential equations to get the equations of motion
#eom.update(kane.kindiffdict())

# print the results
#me.mprint(eom)
```

```
frstar
```

    Matrix([
    [nan],
    [nan],
    [nan],
    [  0]])

```
# parameters
ls = .25  # The maximum length of the pendulum is 1 meter
ms = .15  # The maximum mass of the bobs is 10 grams
ws_ratio = .1 # width ratio
ws = ls * ws_ratio
Izzs = ms * (ls**2 + ws**2) / 12

param_names = [g, m0, l0, Izz0, m1, l1, Izz1, m2, l2, Izz2]
param_vals = [9.81, ms, ls, Izzs, ms, ls, Izzs, ms, ls, Izzs]

dynamic = qs + us                                                # Make a list of the states
#dynamic.append(f)                                              # Add the input force
dummy_symbols = [sym.Dummy() for i in dynamic]                 # Create a dummy symbol for each variable
dummy_dict = dict(zip(dynamic, dummy_symbols))

kd_dict = kane.kindiffdict()                              # solved kinematical differential equations
M = kane.mass_matrix_full.subs(kd_dict).subs(dummy_dict)  # substitute into the mass matrix
F = kane.forcing_full.subs(kd_dict).subs(dummy_dict)      # substitute into the forcing vector
M_func = sym.lambdify(dummy_symbols + param_names, M)          # callable mass matrix
F_func = sym.lambdify(dummy_symbols + param_names, F)          # callable forcing vector

def rhs(x, t, args):
    """Returns the derivatives of the states.

    Parameters
    ----------
    x : ndarray, shape(2 * n)
        The current state vector.
    t : float
        The current time.
    args : ndarray
        The constants.

    Returns
    -------
    dx : ndarray, shape(2 * (n + 1))
        The derivative of the state.
    """

    arguments = np.hstack((x, args))     # states, (no input), and parameters
    dx = np.array(solve(M_func(*arguments), F_func(*arguments)))

    return dx.flatten()

# initial conditions
qstart = (np.pi / 2, np.pi / 2, np.pi / 2, 1)
ustart = (0.5, 0.5, 0.5, 0)

qstart = -np.pi / 2 * np.ones(4)
ustart = 0 * np.ones(4)

x0 = np.hstack((qstart, ustart))
t = linspace(0, 2, 1001)
```

```
kd_dict
```

    {Derivative(q2(t), t): nan,
     Derivative(q3(t), t): nan,
     Derivative(q0(t), t): nan,
     Derivative(q1(t), t): nan}

```
xx = x0
#xx = y.T[696]
arguments = np.hstack((xx, param_vals))

mm = M_func(*arguments)
ff = F_func(*arguments)

print solve(mm, ff).flatten()
print lstsq(mm, ff)[0].flatten()
```

```
# numerically integrate
now = time.time()
y, out = odeint(rhs, x0, t, args=(param_vals,), full_output=True)
y = y.T
print('Elapsed time: {0:.2f}'.format(time.time() - now))
```

    Excess work done on this call (perhaps wrong Dfun type).
    Run with full_output = 1 to get quantitative information.
    Elapsed time: 2.69

```
fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True)

ax1.plot(y[0])
ax1.plot(y[1])
ax1.plot(y[2])

ax2.plot(y[3])
ax2.plot(y[4])
ax2.plot(y[5])

ax1.set_ylim(-15, 15)
ax2.set_ylim(-50, 50)
```

    (-50, 50)

```
y.T[694]
```

    array([ -7.56641132e-01,   1.39578773e+01,  -3.24174392e+00,
            -1.03880382e+02,  -5.58516572e+02,   7.78561358e+02])

```
qstart = (0, 0, 0)
ustart = (0, 0, 0)
np.hstack(
```

    array([0, 0, 0, 0, 0, 0])

```

```

```
l0
```

    l0

```
I.orientnew?
```

```
mc0.locatenew?
```

```
frame0 = I.orientnew('frame0', 'Axis', [q[0], I.z])

mc0 = O.locatenew('mc0', q[1] * frame0.x)
mc0.v2pt_theory(O, I, frame0)

in0 = me.inertia(frame0, 0, 0, Izz[0])
body0 = me.RigidBody('body0', mc0, frame0, m[0], (in0, mc0))

frame1 = frame0.orientnew('frame1', 'Axis', [q[2], I.z])
frame2 = frame1.orientnew('frame2', 'Axis', [q[3], I.z])
frame1.set_ang_vel(I, u[2] * I.z)
frame2.set_ang_vel(I, u[3] * I.z)

in1 = me.inertia(frame1, 0, 0, Izz[1])
in2 = me.inertia(frame2, 0, 0, Izz[2])

# mc1 = mc0.locatenew('mc1', l[0] * frame0.x +
#                            l[1] * sym.cos(q[2]) * frame1.x +
#                            l[1] * sym.sin(q[2]) * frame1.y)
# mc2 = mc1.locatenew('mc2', l[1] * frame1.x +
#                            l[2] * sym.cos(q[3]) * frame2.x +
#                            l[2] * sym.sin(q[3]) * frame2.y)

# after looking at the n-pendulum example
mc1 = mc0.locatenew('mc1', l[0] * frame0.x + l[1] * frame1.x)
mc2 = mc1.locatenew('mc2', l[1] * frame1.x + l[2] * frame2.x)
mc1.v2pt_theory(mc0, I, frame1)
mc2.v2pt_theory(mc1, I, frame2)

body1 = me.RigidBody('body1', mc1, frame1, m[1], (in1, mc1))
body2 = me.RigidBody('body2', mc2, frame2, m[2], (in2, mc2))

# each one might need four entries...
frames = [I, frame0, frame1, frame2]
points = [O, mc0, mc1, mc2]
bodies = [body0, body1, body2]
forces = [0, 0, 0]
kindiffs = [qq.diff(t) - uu for qq, uu in zip(q, u)]

# need to figure out forces and how to setup me.KanesMethod

# frames = [I]                              # List to hold the n + 1 frames
# points = [P0]                             # List to hold the n + 1 points
# particles = [Pa0]                         # List to hold the n + 1 particles
# forces = [(P0, f * I.x - m[0] * g * I.y)] # List to hold the n + 1 applied forces, including the input force, f
# kindiffs = [q[0].diff(t) - u[0]]          # List to hold kinematic ODE's
# for i in range(n):
#     Bi = I.orientnew('B' + str(i), 'Axis', [q[i + 1], I.z])   # Create a new frame
#     Bi.set_ang_vel(I, u[i + 1] * I.z)                         # Set angular velocity
#     frames.append(Bi)                                         # Add it to the frames list

#     Pi = points[-1].locatenew('P' + str(i + 1), l[i] * Bi.x)  # Create a new point
#     Pi.v2pt_theory(points[-1], I, Bi)                         # Set the velocity
#     points.append(Pi)                                         # Add it to the points list

#     Pai = Particle('Pa' + str(i + 1), Pi, m[i + 1])           # Create a new particle
#     particles.append(Pai)                                     # Add it to the particles list

#     forces.append((Pi, -m[i + 1] * g * I.y))                  # Set the force applied at the point

#     kindiffs.append(q[i + 1].diff(t) - u[i + 1])              # Define the kinematic ODE:  dq_i / dt - u_i = 0
```

```
kane = me.KanesMethod(I, q_ind=q, u_ind=u, kd_eqs=kindiffs) # Initialize the object
fr, frstar = kane.kanes_equations(forces, bodies)     # Generate EoM's fr + frstar = 0
```

```
kane.kanes_equations()
```

```
kane.kanes_equations(forces, bodies)
```

```
N = me.ReferenceFrame('N')
Y = N.orientnew('Y', 'Axis', [q1, N.z])
L = Y.orientnew('L', 'Axis', [q2, Y.x])
R = L.orientnew('R', 'Axis', [q3, L.y])

w_R_N_qd = R.ang_vel_in(N)
R.set_ang_vel(N, u1 * L.x + u2 * L.y + u3 * L.z)
```

```
C = me.Point('C')
C.set_vel(N, 0)
Dmc = C.locatenew('Dmc', r * L.z)
Dmc.v2pt_theory(C, N, R)
```

    r*u2*L.x - r*u1*L.y

```
I = me.inertia(L, m / 4 * r**2, m / 2 * r**2, m / 4 * r**2)
me.mprint(I)
```

    m*r**2/4*(L.x|L.x) + m*r**2/2*(L.y|L.y) + m*r**2/4*(L.z|L.z)

```
kd = [me.dot(R.ang_vel_in(N) - w_R_N_qd, uv) for uv in L]
```

```
ForceList = [(Dmc, -m * g * Y.z)]
BodyD = me.RigidBody('BodyD', Dmc, R, m, (I, Dmc))
BodyList = [BodyD]
```

```
KM = me.KanesMethod(N, q_ind=[q1, q2, q3], u_ind=[u1, u2, u3], kd_eqs=kd)
(fr, frstar) = KM.kanes_equations(ForceList, BodyList)
MM = KM.mass_matrix

forcing = KM.forcing
rhs = MM.inv() * forcing
kdd = KM.kindiffdict()
rhs = rhs.subs(kdd)
rhs.simplify()
```

```
me.mpprint(rhs)
```

    ⎡                              2           ⎤
    ⎢4⋅g⋅sin(q₂) + 6⋅r⋅u₂⋅u₃ - r⋅u₃ (t)⋅tan(q₂)⎥
    ⎢──────────────────────────────────────────⎥
    ⎢                   5⋅r                    ⎥
    ⎢                                          ⎥
    ⎢                -2⋅u₁⋅u₃                  ⎥
    ⎢                ─────────                 ⎥
    ⎢                    3                     ⎥
    ⎢                                          ⎥
    ⎣         (-2⋅u₂ + u₃⋅tan(q₂))⋅u₁          ⎦

```

```


# Case 2: Water Layer

* Author: **Team G15**
* Attempt: 3

## Analysis

### To find

1. Temperature of Water Surface $(T_w)$
2. Total heat flux entering the house through the roof, $(q_t)$ when a water layer is present

### Nomenclature

* $S$ = Intensity of Solar Radiation (i.e. solar constant)
* $v_w$ = water velocity
* $v_a$ = wind velocity
* $\epsilon_w$ = emissivity of water surface
* $\sigma$ = Stefan-Boltzmann constant $(5.67*10^{-8}\ W/m^2K^4)$
* $T_r$ = room temperature (inside)
* $T_w$ = water surface temperature (outside)
* $T_a$ = ambient air temperature (outside)
* $\overline T_w$ = average water surface temperature (outside)
* $\overline T_a$ = average air temperature (outside)
* $\tau_w$ = fraction of solar radiation absorbed by water
* $k_w$ = thermal conductivity of water
* $L_w$ = length of water layer
* $h_w$ = convection coefficient of water layer
* $h_r$ = radiative heat transfer coefficient
* $h_c$ = convective heat transfer coefficient
* $h_e$ = evaporative heat transfer coefficient

### Assumptions

1. Steady state with room maintained at fixed ambient temperature
2. Water is still ($v_w = 0$) but gentle breeze is present ($v_a = 10\ km/h$)
3. Dry Surroundings

### Equations

#### Energy balance,

$$ q_t = q_c + q_r - q_e $$

#### Radiation heat transfer,

\begin{align*}
  q_r &= \tau_w\cdot S - h_r \cdot (T_a - T_w) \\
\\
  h_r &= \epsilon_w\cdot \sigma\cdot \frac{(\overline T_w)^4 - (\overline T_a - 12)^4}{\overline T_a - \overline T_w}
\end{align*}

#### Convection heat transfer,

\begin{align*}
  q_c &= h_c\cdot (T_a - T_w) \\
\\
  h_c &= 5.678 \cdot (1 + 0.85\cdot(v_a - v_w))
\end{align*}

#### Evaporative heat transfer,

\begin{align*}
  q_e &= 0.013\cdot h_c\cdot (p(\overline T_w) - \gamma\cdot p(\overline T_a)) \\
\\
  p(T) &= R_1\cdot T + R_2
\end{align*}

#### Total heat transfer,
\begin{align*}
  q_t &= \frac{T_w - T_r}{R_{net}} \\
\\
  R_{net} &= \frac{1}{h_r} + \sum_{i=1}^{3} \frac{L_i}{k_i} + \frac{1}{h_{w}} \\
\\
  h_w &= \frac{k_w}{L_w}\cdot (0.14\cdot(Gr\cdot Pr)^{1/3} + 0.644\cdot (Pr\cdot Re)^{1/3}) \\
\\
  Gr &= \frac{g\cdot\beta\cdot(T_w-T_a)\cdot(L_w)^{3}}{\nu^2}
\end{align*}

### Properties

#### Outside Air

* Mild breeze $v_a = 2.78\ m/s$
* $T_a \in [305, 320] K$
* $T_f = 320K$
* $\beta = \frac{1}{T_f} = 0.0031\ K^{-1}$
* Table A.4, air ($T_f$):
  * $\nu = 18 \cdot 10^{-6}\ m^2/s$
  * $\alpha = 25 \cdot 10^{-6}\ m^2/s$
  * $Pr = 0.702$
  * $k = 27.7 \cdot 10^{-3}\ W/m\cdot K$
* $S = 1366\ W/m^2$
* $R_1=325\ Pa/^\circ C$ and $R_2 = -5155\ Pa$ (*from reference* **#1**)
* $\gamma=0.27$ (approx average over a day)

#### Water layer

* $L_w = 0.1\ m$ (approx thickness of water layer)
* Table A.6, water ($T_w$):
  * $\nu = 18 \cdot 10^{-6}\ m^2/s$
* Still water $v_w = 0$
* $\epsilon_w = 0.95$
* $\tau_w=0.6$

#### Roof

* $t = 0.2\ m$ thick with,
  * Cement = $5\ cm$
  * Brick = $10\ cm$
  * Lime = $5\ cm$
* $K_i$, Conductivity of each layer,
  * Cement = $0.72\ W/m\cdot K$
  * Brick = $0.71\ W/m\cdot K$
  * Lime = $0.73\ W/m\cdot K$

#### Inside air

* $T_r = 300K$ (Room Temperature)
* $h_r = 8.4\ W/m^2\cdot K$

### Tools used

* **Python**
* **SymPy** for creating symbolic equations and solving them
* **NumPy**
* **Matplotlib** for plotting results

## Solving (Python Code)

### Initialize Values

```python
import sympy as sp
import numpy as np
```

#### Outside Air

* Saturation pressure of water p = R_1\*T + R_2

```python
v_a = 2.78 # Velocity (m / s)

# Temperatures
T_f = 320 # (K)
beta = 1/T_f # (K)
T_a = np.array([305.0, 310.0, 315.0, 320.0]) # (K)
T_a_avg = 273 + 37 # (K)

# Constants
sigma = 5.67e-8 # Stefan Boltzmann constant (W / m^2 * K^4)
g = 9.8 # (m^2 / s)
R_1 = 325 # N / m^2 °C
R_2 = -5155 # N / m^2
gamma = 0.27
S = 1366 # Solar constant

def p(T): # Saturation pressure of water as a function of temperature (N / m^2)
    return R_1 * (T-273) + R_2
```

#### Water Layer

```python
v_w = 0 # Velocity (m / s)
L_w = 5 # Dimensions (m)

# Temperatures
T_w = sp.symbols('T_w') # (K)
T_w_avg = 273 + 32 # (K)

# Constants
epsilon_w = 0.95 # Emissivity of water surface
tau_w = 0.6 # Water's solar absorbtivity
```

* Table A.6 used (*from reference* **#2**)
* Upon analysing the below data, we can approximate $h_w$ to $950\ W/m^2$

```python
rho_w = 990 # density (kg / m^3)
k_w = 0.63 # thermal conductivity (W / m * K)
mu_w = 1e-6 * np.array([769, 695, 631, 577]) # viscosity (N * s / m^2)
nu_w = mu_w / rho_w # dynamic visosity (m^2 / s)

Pr_w = np.array([5.20, 4.62, 4.16, 3.77]) # Prandtl number
Re_w = 0 # Reynolds number, still water
Gr_w = g * beta * (T_a - T_w) * L_w**3 / nu_w**2 # Grashof number

# Water free convection coeffecient
h_w = (k_w/L_w) * (0.14 * (Gr_w*Pr_w)**(1/3) + 0.644 * (Pr_w*Re_w)**(1/3))

# Example at T_a = 310K and T_w = 306K
h_w_test = h_w[1].replace(T_w, 306)
print('Approximate min value of h_w = %.2f' % (h_w_test))
```

    Approximate min value of h_w = 923.62

#### Roof Layers

```python
# Layer 1: Concrete
k_1 = 0.72 # (W / m * K)
L_1 = 0.05 # (m)

# Layer 2: Brick
k_2 = 0.71 # (W / m * K)
L_2 = 0.10 # (m)

# Layer 3: Lime
k_3 = 0.73 # (W / m * K)
L_3 = 0.05 # (m)
```

#### Inside Air

```python
h_r = 8.4 # (W / m^2 * K)
T_r = 300 # (K)
```

### Equations

#### Radiation Heat

```python
h_r = epsilon_w * sigma * (T_w_avg**4 - (T_a_avg - 12)**4)/(T_a_avg - T_w_avg) # (W / m^2 * K)
q_r = tau_w * S - h_r * (T_a - T_w) # (W / m^2)

# Example at T_a = 310K and T_w = 306K
q_r_test = q_r[1].replace(T_w, 306)
print('Approximate value of q_r = %.2f' % (q_r_test))
```

    Approximate value of q_r = 786.53

#### Convection Heat

* Forced convection and free convection both have been used

```python
h_c = 5.678 * (1 + 0.85 * (v_a - v_w))
print('h_c = %.2f' % (h_c))

q_c = h_c * (T_a - T_w) # (W / m^2)

# Example at T_a = 310K and T_w = 306K
q_c_test = q_c[1].replace(T_w, 306)
print('Approximate value of q_c = %.2f' % (q_c_test))
```

    h_c = 19.10
    Approximate value of q_c = 76.38

#### Evaporation Heat:

```python
q_e = 0.013 * h_c * (p(T_w_avg) - gamma * p(T_a_avg)) # function p defined above, (W / m^2)

# Example at T_a = 310K and T_w = 306K
print('Approximate value of q_e = %.2f' % (q_e))
```

    Approximate value of q_e = 841.55

#### Total Heat:

```python
h_w = 1200 # from above approximation (W / m^2 * K)
R = 1/h_r + L_1/k_1 + L_2/k_2 + L_3/k_3 + 1/h_w # (m^2 * K / W)

q_t = (T_w - T_r) / R # (W / m^2)

# Example at T_a = 310K and T_w = 306K
q_t_test = q_t.replace(T_w, 306)
print('Approximate value of q_t = %.2f' % (q_t_test))
```

    Approximate value of q_t = 14.98

### Solving

\begin{align*}
  q_c + q_r - q_e &= q_t
\\
  \therefore\hspace{3pt} q_c + q_r - q_e - q_t &= 0
\end{align*}

#### Calculate $T_w$

```python
eq = q_c + q_r - q_e - q_t

n = len(eq)
T_w_calc = np.empty(n, dtype=object)

for i in range(n):
    T_w_calc[i] = round(sp.solve(eq[i], T_w)[0], 2)

for i in range(n):
    print('T_w = %.1f K for T_a = %.1f K' % (T_w_calc[i], T_a[i]))
```

    T_w = 302.4 K for T_a = 305.0 K
    T_w = 306.5 K for T_a = 310.0 K
    T_w = 310.5 K for T_a = 315.0 K
    T_w = 314.6 K for T_a = 320.0 K

#### Calculate $q_t$

```python
q_t_calc = np.empty(n, dtype=object)

for i in range(n):
    q_t_calc[i] = q_t.replace(T_w, T_w_calc[i])

for i in range(n):
    print('Heat entering = %.1f W/m^2 for T_a = %.1f K' % (q_t_calc[i], T_a[i]))
```

    Heat entering = 6.0 W/m^2 for T_a = 305.0 K
    Heat entering = 16.2 W/m^2 for T_a = 310.0 K
    Heat entering = 26.3 W/m^2 for T_a = 315.0 K
    Heat entering = 36.5 W/m^2 for T_a = 320.0 K

### Plot

* Temp Drop Due to Water ($T_a - T_w$) vs Outside Air Temp ($T_a$)
* Total Heat Flux Entering ($q_t$) vs Outside Air Temp ($T_a$)

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

# Initialize matplotlib
plt.rc('text', usetex=True) # Unnecessary
plt.style.use('ggplot')
plt.rcParams['grid.color'] = '#C0C0C0'

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

ax1 = fig.add_subplot(121)
plt.plot(T_a, T_a-T_w_calc, color='#1F77B4cc', marker='o')
plt.xticks(fontsize=20)
plt.yticks(fontsize=20)
plt.xlabel('$T_a$', fontsize=20)
plt.ylabel('$T_w$', fontsize=20)
plt.title('Temp Drop Due to Water ($T_a - T_w$) vs Outside Air Temp ($T_a$)', fontsize=18, pad=15)

ax2 = fig.add_subplot(122)
plt.plot(T_a, q_t_calc, color='#1F77B4cc', marker='o')
plt.xticks(fontsize=20)
plt.yticks(fontsize=20)
plt.xlabel('$T_a$', fontsize=20)
plt.ylabel('$q_t$', fontsize=20)
plt.title('Total Heat Flux Entering ($q_t$) vs Outside Air Temp ($T_a$)', fontsize=18, pad=15)

fig.tight_layout(w_pad=10)
```

## References

1. A. Shrivastava *et al*. ["Evaporative cooling model..."](https://github.com/relaxxpls/CL246-G15/blob/main/docs/papers/Experimental_validation_of_a_thermal_mod.pdf) (1984)
2. F. Incropera *et al*. "Fundamentals of Heat and Mass Transfer."