# Background

This notebook is based on

**Emily S. C Ching, P. H. Tam, "[Effects of hidden nodes on the reconstruction of bidirectional networks](https://journals.aps.org/pre/abstract/10.1103/PhysRevE.98.062318)", Phys. Rev. E 98, 062318 (2018)** [1]

The authors in [1] consider a weighted bidirectional network of $N$ nodes, each has state variable $x_{i}(t)$ governed by the coupled stochastic differential equations (SDEs).

\begin{align}
    \frac{dx_{i}}{dt} \; = \; f(x_{i}) \; + \; \sum_{j \neq i}\, g_{ij}A_{ij}h(x_{j} - x_{i}) \; + \; \eta_{i} \tag{1}
\end{align}
for $i = 1, 2, \dots, N$.
* $f(x_{i})$: intrinsic dynamics of node $i$ with a stable fixed point $X_{0}$
* $A_{ij}$: adjacency matrix element
* $g_{ij}$: coupling strength
* $h$: diffusive-like coupling function with $h^{\prime}(0) > 0$ and $h(-z) = -h(z)$
* $\eta_{i}$: Gaussian white noise with $\overline{\eta}_{i} = 0$ and $\overline{\eta_{i}(t) \eta_{j}(t^{\prime})} = \sigma^{2}_{n}\, \delta_{ij} \, \delta(t - t^{\prime})$.

As $t \to \infty$, dynamics of nodes fluctuates around the stable fixed point $X_{0}$ (Stationary Dynamics)

The aim of network reconstruction is to infer the network connectivity $A_{ij}$ from the dynamics of nodes $x_{i}(t)$. This is known as inverse problem.

# Problem
The authors consider a realistic situation in which some of the nodes are not available for measurements. They asked the following question:

Given only $n < N$ nodes available for measurement, is it possible to infer the network connectivity *among the measured nodes*? [1]

# Mathematical Relation
The authors obtained the following result [1]

\begin{align}
    (\Sigma^{-1}_{m})_{ij} \; = \; - \frac{2h^{\prime}(0)}{\sigma^{2}_{n}} \Big( g_{ij} A_{ij} + C_{ij} \Big) \tag{2}
\end{align}
for $i \neq j$, and $i, j = 1, 2, \dots, n < N$
* $\mathbf{\Sigma^{-1}_{m}}:$ inverse covariance matrix **formed by the measured nodes only**
* $A_{ij}$: adjacency matrix element **among the measured nodes**
* $g_{ij}$: coupling strength **among the measured nodes**
* $C_{ij}$: effect of hidden nodes acting on the measured node $i$ and $j$

**Remarks**
-  Here $i, j$ represents node indices of measured nodes, and they do not necessarily mean the first $n$ node indices of the whole network.
- The authors in [1] consider $h^{\prime}(0) = 1$

This equation shows that even if there are some nodes not available for measurements, $(\Sigma^{-1}_{m})_{ij}$ is still related to $g_{ij}A_{ij}$, the network connectivity among the measured nodes. The details of $C_{ij}$ can be found in [1].

If there are no hidden nodes (i.e. all N nodes can be accessed and their dynamics can be measured), then we have
\begin{align}
    (\Sigma^{-1})_{ij} \; = \; - \frac{2h^{\prime}(0)}{\sigma^{2}_{n}} g_{ij} A_{ij} \tag{3}
\end{align}
for $i \neq j$, and $i, j = 1, 2, \dots, n < N$

**Remarks**
Notice that Eq. (2) also holds for $i \neq j$, and $i, j = 1, 2, \dots, N$.

But as we are going to compare the case with hidden nodes (see below), we constraint ourself to the $n$ measured nodes

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

# Import my python packages
import plots
import utils
import gen_net
import gen_cov
import choose_nodes
import reconstruct
import evaluate
```

# Network Generation

First, we choose our network as ER random network with Gaussian coupling.

```python
# Set random seed
np.random.seed(seed=22)

# Construct a weighted random network with Gaussian coupling

# Total number of node
N = 100

# Connection probability
p = 0.2

# Mean value of Gaussian coupling
mean = 10

# Standard deviation of Gaussian coupling
std = 2

# Construct a ER unweighted bidirectional random network
A = gen_net.er_random(N, p)

# Construct the corresponding weighted network with Gaussian coupling strength
W = gen_net.gaussian(A, mean, std)
```

```python
# Image plot the weighted adjacency matrix
plots.img_plots.weighted_adj_plot(W)
```

# Stationarity Checking
To obtain the analytic result in [1], the dynamics has to be stationary. For this class of dynamics to be stationary, we check the eigenvalue of the weighted Laplacian matrix $\mathbf{\mathcal{L}}$

\begin{align}
    \mathcal{L}_{ij} \; = \; \sum_{k} (g_{ik}A_{ik}) \, \delta_{ij} \;\; - \;\; g_{ij}A_{ij} \tag{4}
\end{align}

For Eq. (1) to have stationary dynamics, one of the conditions is that $\mathbf{\mathcal{L}}$ should have no negative eigenvalues.

```python
_ = utils.dynamics.stationary_check(W)
```

    Absolute minimum eigenvalue of L =  1.8234210563328407e-14
    The dynamics would probably fluctuate around a stable fixed point

# Dynamics Simulation

In this demo, we choose:
1. $f(x) \; = \; r x(1-x)$
2. $h(x-y) \; = \;  y - x$

Hence, the coupled SDEs becomes

\begin{align}
    \frac{dx_{i}}{dt} \; = \; rx_{i}(1-x_{i}) \; - \; \sum_{j=1}^{N} \mathcal{L}_{ij} \, x_{j} \; + \; \eta_{i} \tag{5}
\end{align}

In the following, we generate the dynamics of $x_{i}(t)$ by solving the coupled SDEs using Euler-Maruyama method and obtain $\mathbf{\Sigma}$, `cov` (the covariance matrix of the whole network)

```python
# Set random seed
np.random.seed(13)

# Parameter for f(x)
r = 10

# Noise strength
sigma = 1

# Integration step
int_dt = 5e-5

# Sampling step
sample_dt = 5e-4

# Time step to begin sampling
sample_start = int(5e3)

# Total number of data points to compute covariance matrix
data_num = int(2e6)

# cov: covariance matrix formed by ALL the nodes
# x_ts: sampled time series of the first node
cov, x_ts = gen_cov.logistic_diffusive(W, r, sigma, int_dt, sample_dt, sample_start, data_num, get_ts=True)
```

    100%|██████████| 20005000/20005000 [12:26<00:00, 26797.76it/s]

```python
# Plot part of the sampled time series of the first node
t = np.arange(len(x_ts))*sample_dt

# We plot only part of the time series for better visualization
plt.plot(t[:20000], x_ts[:20000], color='b')

# Green line is the time step we start do the sampling for covariance computation
# It is used to avoid the transient dynamics at the very beginning
plt.axvline(x=sample_start*sample_dt, color='g', linestyle='--')

# Red line is the stable fixed point of f(x)
# We see the dynamics is fluctuating around it
plt.axhline(y=1, color='r', linestyle='--')

plt.title("Sampled time series of the first node", fontsize=14)
plt.xlabel(r"$t$", fontsize=14)
plt.ylabel(r"$x_{1}(t)$", fontsize=14)
plt.xticks(fontsize=12)
plt.yticks(fontsize=12)
plt.grid(True)
```

# Node Selection

In the following, we choose $n = 30$ nodes from $N=100$ nodes as measured nodes randomly.

With the selected node indices `measure_id` and `hidden_id`, we can compute
1. `cov_m_inv` Eq.(2)
2. `cov_inv_m` Eq. (3)

from `cov` generated from the above simulation.

```python
# Set random seed
np.random.seed(19)

# Number of measured nodes
n = 30

# Get the node indices that corresponds to measured nodes and hidden nodes
measure_id, hidden_id = choose_nodes.random(N, n)

# cov_inv_m and cov_m_inv is computed from cov
cov_inv_m, cov_m_inv = utils.dynamics.inverse_covariance(cov, measure_id, hidden_id)
```

# Data Visualization
We extract the off-diagonal elements of `cov_m_inv` and `cov_inv_m` and they should be distributed according to Eq. (2), and (3) respectively. We also label the data point according to $A_{ij} = 0$ or $1$.

```python
# Get the network connectivity among the measured nodes
W_m = utils.base.block_diag_up(W, measure_id)
W_m_off = utils.base.off_diag_upper(W_m)
```

```python
# Select only the off-diagonal elements (upper triangle)
data_nohidden = utils.base.off_diag_upper(cov_inv_m)
data_hidden = utils.base.off_diag_upper(cov_m_inv)

# Separate the data into unconnected and connected as if we know the underlying connectivity
ucon_nohidden = data_nohidden[W_m_off == 0]
conn_nohidden = data_nohidden[W_m_off != 0]

ucon_hidden = data_hidden[W_m_off == 0]
conn_hidden = data_hidden[W_m_off != 0]
```

```python
# Plot the distributions
num_bin = 50
x_min = -45
x_max = 5

plots.dist_plots.covinv_plots(ucon_nohidden, conn_nohidden,
                              ucon_hidden, conn_hidden,
                              num_bin, x_min, x_max)
```

1. **Left**: A bimodal distribution is observed with unconnected data points around zero as Eq. (2) suggested. The data points for $A_{ij} = 0$ and $1$ are well separated.

2. **Right**: A bimodal distribution is still observed even if there are 70 out of 100 nodes are hidden nodes. What is remarkable is that when you compare with the **Left** figure, the whole distribution is shifted to the left by roughly the same amount without distorting the bimodal feature.

Why is it the case? Let's take a look at $C_{ij}$ for $i \neq j$, the effect of hidden node

# The Hidden Node Effect
Let's look at the behaviour of $C_{ij}$ and see how it actually affect the distribution

```python
C = utils.network.hidden_effect(W, measure_id, hidden_id, a=r)
```

```python
# Select only the off-diagonal elements (upper triangle)
C_off = utils.base.off_diag_upper(C)
C_ucon = C_off[W_m_off == 0]
C_conn = C_off[W_m_off != 0]
```

```python
# Get the number of count in the histogram
num_bin = 12
x_min = 0
x_max = 10
plots.dist_plots.hidden_node_effect_plot(C_ucon, C_conn, num_bin, x_min, x_max)
```

The distribution shows that $C_{ij}$ takes similar value no matter the measured node pair $(i, j)$ is connected or unconnected.

In other words, $C_{ij}$ in this case (ER Random Network) is **independent** on $A_{ij}$.

Therefore, the whole distribution of $(\Sigma^{-1}_{m})_{ij}$ is basically shifted to the left **without** distorting the bimodal behaviour.

# Network Reconstruction
Using **k-means clustering** to reconstruct the underlying network connectivity.

Clustered data with centroid close to zero are reconstructed as unconnected pairs. Otherwise, they are reconstructed as connected pairs.

```python
# k-means cluster the data and reconstruct the adjacency matrix
A_reco_nohidden = reconstruct.kmeans(data_nohidden, n, k=2)
A_reco_hidden = reconstruct.kmeans(data_hidden, n, k=2)
```

# Reconstructed Results

Let's compare the reconstructed adjacency matrix with the actual adjacency matrix among the measured nodes

```python
# Get the original adjacency matrix among the measured nodes
A_m = W_m
A_m = (A_m != 0).astype('int')
```

```python
# Error rates for data with no hidden node effect
_, _, _ = evaluate.error_rates(A_m, A_reco_nohidden)
```

    Number of bidirectional links = 97
    Number of false positive = 0
    Number of false negative = 0
    False positive rate = 0.0000%
    False negative rate = 0.0000%

```python
# Error rates for data with hidden node effect
_, _, _, = evaluate.error_rates(A_m, A_reco_hidden)
```

    Number of bidirectional links = 97
    Number of false positive = 1
    Number of false negative = 3
    False positive rate = 1.0309%
    False negative rate = 3.0928%

# Conclusion
1. The one without hidden node effect has a perfect network reconstruction.
2. Even the one with hidden node effect (70 out of 100 are hidden nodes), the result is also acceptable


Discussion:
- in the paper "Linear dimensionality reduction: survey, insights, and generalizations" by Cunningham and Ghahramani, 2015, JMLR [CG15], the authors point out that considering some linear dimensionality reduction algorithms e.g. sparse PCA, maximal autocorrelation factors (MAF), as eigenvalue / generalized-eigenvalue problems is impossible or inaccurate, and instead suggest that we should consider all such problems as optimization problems over matrix manifolds. In most cases, these are optimization problems over orthogonal matrices that minimize simple objectives:

**PCA**

Find the orthogonal linear projection $f: X \mapsto M^T X$ that minimizes squared reconstruction error, i.e. $$ \text{argmin}_{M \in \mathcal{O}^{d \times r}} \|X - M M^T X \|^2_F$$

**MAF**

Find the linear projection $M^T X$ that maximizes the correlation between time-adjacent points $$ \text{argmin}_{M \in \mathcal{O}^{d \times r}} \frac{\text{tr}(M^T \Sigma_{\tau} M)}{\text{tr}(M^T \Sigma M)}$$

where
- $\Sigma$ is the empirical covariance $E(x_t x_t^T) = \frac{1}{n}X X^T$
- $\Sigma_\tau$ is the symmetrized empirical cross-covariance at time-lag $\tau$, $ \frac{1}{2} (E(x_{t+\tau} x_t^T) + E(x_t x_{t+\tau}^T))$

*Comments:*
- Interpretation: maximizes cross-covariance of the transformation (numerator) while avoiding overcounting data that has high power (denominator)
- Cunningham and Ghahramani note that we can make several variations easily:
    - To maximize the cross-covariance rather than correlation, maximize $\text{tr}(M^T \Sigma_{\tau} M)$ (the eigenvector solution is optimal for this variant)
    - To maximize (or minimize) the squared distance between time-adjacent points, ($E(\|y_{t+tau}-y_t\|^2)$), the objective becomes $\text{tr}(M^T (\Sigma - \Sigma_{\tau}) M)$ (interpretation: discrete-time analog of SFA)
        - See also: Turner and Sahani 2007:
    - If you specify a particular form of temporal structure, then you can seek linear projections containing that structure. Example: to find a linear subspace where linear dynamics is preserved, minimize $\| \dot{X} - MDM^TX\|_F^2$ for some dynamics matrix $D \in \mathbb{R}^{r\times r}$, where we have access to first-derivative data $\dot{X}$
        - *This is of particular interest in the context of MSM-construction*, since we have a clear idea of the type of dynamics we would like our projections to preserve.
        - We could directly optimize the linear projection to capture Markovian jump dynamics in the projected space
        - See also Churchland et al., 2012: "Neural population dynamics during reaching" (Nature), describes this method for finding projections of the population state that best capture "rotational" information

```python
import autograd
from autograd import grad
from autograd import numpy as np
from autograd.numpy import random as npr
from autograd.numpy import linalg as la
```

```python
from sklearn.decomposition import PCA
```

```python
def proj_stiefel(Z,M):
    ''' M is a matrix on the stiefel manifold,
    Z is what we want to project'''
    skew_MTZ = 0.5 * (np.dot(M.T,Z) - np.dot(Z.T,M))
    MMT = np.dot(M,M.T)
    I = np.eye(len(MMT))
    return np.dot(M,skew_MTZ) + np.dot(I - MMT,Z)
```

```python
def proj_grad_descent_stiefel(d,r,obj_func,
                              convergence_threshold=0.1,max_iter=100):
    obj_grad = grad(obj_func)

    # initialize M in the set of orthonormal matrices of desired dimension, M ∈ O^{d×r}

    M = PCA().fit_transform(npr.randn(d,r)) # orthogonalize
    M /= M.sum(1) # normalize

    # while obj_func(M) has not converged:
    converged = False
    timed_out=False
    n_iter = 0
    while (not converged) and (not timed_out):
        old_M = M

        # calculate free gradient of the objective
        free_grad = obj_grad(M)

        # calculate search direction (eq. 34)
        proj_M = proj_stiefel(M,free_grad)
        retr_M = retr_stiefel_closed_form(

        # while the objective_func(retraction(projection(step))) is not < f(M) - \eps

            # adjust step size using linesearch / retraction (eq. 35)

        # update M

        n_iter += 1
        if n_iter >= max_iter:
        if np.mean((new_M - old_M)**2) < thresh:
            converged = True

    # return M
```

Notes from "optimization on manifolds" (http://press.princeton.edu/chapters/absil/)

**Chapter 1: Intro**

Two prototypical structures for problems to be considered:
- Search space is an *embedded sub-manifold* of the original vector space (e.g. by imposing a norm equality constraint, inducing a spherical search space)
- Search space is a *quotient manifold* of the original vector space

Goal: efficiently translate iterative optimization procedures from their original context (Euclidean vector space) to nonlinear manifolds.

Key concepts from diff-geom --> numerical relaxation:
- Motion along geodesics --> *retraction*
- Parallel transport --> *vector transport*

**Chapter 2: Motivations and Applications**

We'll look at a case study, the eigenvalue problem. This is important in many areas, and since eigenvectors are scale-invariant they nicely illustrate the approach in this book
- Review:
    - Let $\mathbb{F}$ be the field of real or complex numbers
    - Let $A$ be an $n \times n$ matrix with $A_{ij} \in \mathbb{F}$
    - Any nonzero vector $v \in \mathbb{C}^n$ that satisfies $$A v = \lambda v$$ for some $\lambda \in \mathbb{C}$ is an *eigenvector* of $A$, and $\lambda$ is its associated *eigenvalue* (with $(\lambda,v)$ called an *eigenpair*)
    - The set of eigenvalues of $A$ is the *spectrum* of $A$
    - The eigenvalues of $A$ are the zeros of the *characteristic polynomial* of $A$
    $$\mathcal{P}_A (z) \equiv \det(A - zI)$$
    - Their *algebraic multiplicity* is their multiplicity as zeros of $\mathcal{P}_A$.
    - If $T$ is an invertible matrix and $(\lambda,v)$ is an eigenpair of $A$, then
        - $(\lambda , Tv)$ is an eigenpair of $TAT^{-1}$,
        - the transformation $A \mapsto TAT^{-1}$ is a *similarity transformation* of $A$
    - A *linear subspace* $\mathcal{S}$ of $\mathbb{F}^n$ is a subset of $\mathbb{F}^n$ closed under linear combinations:
    $$ (ax + by) \in \mathcal{S} \; \forall x,y \in \mathcal{S}, \forall a,b \in \mathbb{F}$$
    - *Span.* A set $\{y_1,\dots,y_p\}$ of elements of $\mathcal{S}$ such that every element of $\mathcal{S}$ can be written as a linear combination of $y_1,\dots,y_p$ is called a *spanning set* of $\mathcal{S}$
        - $\mathcal{S}$ is *the column space* or *the span* of the $n \times p$ matrix $Y = [y_1,\dots,y_p]$
        - $Y$ *spans* $\mathcal{S}$
        - $\mathcal{S} = \text{span}(Y) = \{ Y x : x \in \mathbb{F}^p \} = Y \mathbb{F}^p$
    - The set of $p$-dimensional subspaces of $\mathbb{F}^n$ is a *Grassman manifold* denoted $\text{Grass}(p,n)$
    - The kernel $\text{ker}(B)$ of a matrix $B$ is the subspace formed by the vectors $x$ such that $Bx = 0$.

    ... incomplete notes ...

- The eigenvalue problem as an optimization problem
    -

```python
X = npr.randn(1000,10)
from sklearn.decomposition import PCA
pca = PCA(2)
pca.fit(X)
M = pca.components_.T
M.shape
```

    (10, 2)

```python
def cov(X):
    ''' compute covariance matrix of X '''
    return np.dot(X,X.T) / len(X)

def t_cov(X,tau=1):
    ''' compute time-lagged covariance matrix of X '''
    E1 = np.dot(X[tau:],X[:-tau].T) / len(X)
    E2 = np.dot(X[:-tau],X[tau:].T) / len(X)
    return 0.5*(E1 + E2)
```

```python
def MAF_obj(M,cov_X,t_cov_X):
    ''' minimize this'''
    return np.trace(M.T.dot(t_cov_X.dot(M)))
```

```python
def PCA_obj(M,X):
    return np.sum((X.T - M.dot(M.T.dot(X.T)))**2)
```

```python
opt = PCA_obj(M,X)
opt
```

    7901.0659563781683

```python
M,M.shape,(M**2).sum(0),M / (M**2).sum(0)
```

    (array([[-0.06636459,  0.15036049],
            [ 0.27727964,  0.2738639 ],
            [-0.30247766, -0.15038684],
            [-0.05136056,  0.36339234],
            [ 0.29914065, -0.55265155],
            [ 0.12406988, -0.0604768 ],
            [ 0.1172024 ,  0.08542416],
            [-0.08513059, -0.65586791],
            [ 0.36465999, -0.01944447],
            [-0.7521583 , -0.02829893]]),
     (10, 2),
     array([ 1.,  1.]),
     array([[-0.06636459,  0.15036049],
            [ 0.27727964,  0.2738639 ],
            [-0.30247766, -0.15038684],
            [-0.05136056,  0.36339234],
            [ 0.29914065, -0.55265155],
            [ 0.12406988, -0.0604768 ],
            [ 0.1172024 ,  0.08542416],
            [-0.08513059, -0.65586791],
            [ 0.36465999, -0.01944447],
            [-0.7521583 , -0.02829893]]))

```python
winners = []
for i in range(100):
    M_ = M+npr.randn(*M.shape)*0.001
    for i in range(M_.shape[1]):
        M_[:,i] = M_[:,i] / np.sqrt(sum(M_[:,i]**2))
    M_val = PCA_obj(M_,X)
    if M_val < opt:
        print('omg nooo',M_val)
        winners.append((M_,M_val))
```

    ('omg nooo', 7901.0654428110711)
    ('omg nooo', 7901.0625223425814)
    ('omg nooo', 7901.0615678569666)
    ('omg nooo', 7901.0654086907498)
    ('omg nooo', 7901.0624129863827)
    ('omg nooo', 7901.0648717670429)
    ('omg nooo', 7901.0657691569431)
    ('omg nooo', 7901.0646361123781)
    ('omg nooo', 7901.0632247688573)
    ('omg nooo', 7901.064269383347)
    ('omg nooo', 7901.0633142935258)
    ('omg nooo', 7901.0606578805236)
    ('omg nooo', 7901.0656958986874)
    ('omg nooo', 7901.0651823817861)
    ('omg nooo', 7901.063804997988)
    ('omg nooo', 7901.0654964295518)
    ('omg nooo', 7901.061599159023)
    ('omg nooo', 7901.0636243430436)
    ('omg nooo', 7901.0640195981468)
    ('omg nooo', 7901.0656457135883)
    ('omg nooo', 7901.0635915276289)

```python

```

```python
M_ = winners[0][0]
(M_**2).sum(0)
M_[:,0].dot(M_[:,1]),M[:,0].dot(M[:,1])
```

    (0.00071477004472439432, 2.7755575615628914e-16)

```python
M_[0]
```

    array([-0.06604021,  0.15108481])

```python
(M_[:,0]**2).sum(),((M_[:,0] / (M_[:,0]**2).sum())**2).sum()
```

    (1.001101986953032, 0.99889922608546011)

```python
np.linalg.norm(M_.T.dot(M_) - np.eye(len(M_.T)))
```

    0.0010108374912272426

```python
np.allclose(M_.T.dot(M_),np.eye(len(M_.T)))
```

    False

(notes from [CG15] Appendix A)

### Optimization over the Stiefel manifold
$\newcommand{\odr}{\mathcal{O}^{d\times r}}$
$\newcommand{\rdr}{\mathbb{R}^{d \times r}}$

*Overview:* We want to take a free gradient in $\rdr$, project it onto the tangent space $T_M \odr$, then retract from the tangent space to the manifold $\odr$

**Tangent space:** linear approximation to the manifold at a particular point.
- Define a *curve* $\gamma(\cdot)$ on the manifold $ \odr$ as a smooth map $\gamma(\cdot): \mathbb{R} \to \odr$
- The tangent space $T_M \odr$ is then:
$$ \{ \dot{\gamma}(0): \gamma(\cdot) \text{ is a curve on } \odr \text{ with } \gamma(0) = M \}$$
where $\dot{\gamma}$ is the derivative $\frac{d}{dt}\gamma(t)$
- Or in words: the tangent space at $M$ is the set of first derivatives at $M$ of all curves on the manifold that pass through $M$, i.e. the space of directions along the manifold at point $M$
- The Stiefel manifold admits some equivalent forms for the tangent space at a point $M$:
$$ \begin{align}
T_M \odr & = \{ \dot{\gamma}(0): \gamma(\cdot) \text{ is a curve on } \odr \text{ with } \gamma(0) = M \}\\
& = \{ X \in \rdr : M^TX + X^TM = 0 \}\\
& = \{ MA + (I - MM^T)B : A = -A^T,B \in \rdr \}
\end{align}$$
- The third form is particularly useful since it allows us to construct points in the tangent space

**Projection from $\rdr$ onto the tangent space $T_M \odr$**
- We would like to project an arbitrary vector $Z \in \mathbb{R}^{d\times r}$ onto the tangent space $T_M \odr$. In other words, given a point not in the tangent space, we would like to find the closest point to it that's in the tangent space.
- This projection $\pi_M(Z)$ is given by
$$ \pi_M(Z) = M \text{skew}(M^TZ) + (I - MM^T)Z$$

**Retraction from $T_M \odr$ to $\odr$**
- After computing the gradient $-\nabla_M f$ at a point $M$, we would like to step in the direction of the projected gradient to $M + \beta \pi_M(-\nabla_M f)$ where $\beta$ is the step size

```python
dot = np.dot
```

```python
M,M.shape
```

    (array([[-0.06636459,  0.15036049],
            [ 0.27727964,  0.2738639 ],
            [-0.30247766, -0.15038684],
            [-0.05136056,  0.36339234],
            [ 0.29914065, -0.55265155],
            [ 0.12406988, -0.0604768 ],
            [ 0.1172024 ,  0.08542416],
            [-0.08513059, -0.65586791],
            [ 0.36465999, -0.01944447],
            [-0.7521583 , -0.02829893]]), (10, 2))

```python
nabla = np.zeros(M.shape)
nabla[0,0] = 1
nabla[1,1] = 1
```

```python
M + 0.01*proj_stiefel(M,-nabla)
```

    array([[-0.07599905,  0.15063037],
           [ 0.2776812 ,  0.26520679],
           [-0.30259848, -0.15144546],
           [-0.05054947,  0.36427772],
           [ 0.29776045, -0.55352544],
           [ 0.12385823, -0.06037713],
           [ 0.11730727,  0.08590871],
           [-0.08647647, -0.65784612],
           [ 0.36437641, -0.018718  ],
           [-0.75171964, -0.02998469]])

```python
M.T.dot(M)
```

    array([[  1.00000000e+00,   2.91433544e-16],
           [  2.91433544e-16,   1.00000000e+00]])

```python
def skew(M,Z):
    ''' return the skew-symmetric part of $M^T Z$'''
    return 0.5 * (dot(M.T,Z) - dot(Z.T,M))

def proj_stiefel(M,Z):
    ''' M is a d-by-r point on the stiefel manifold, defining a tangent
    space $T_M \mathcal{O}^{d \times r}$
    $Z \in \mathbb{R}^{d\times r}$ is an arbitrary point
    we would like to project onto the '''
    MskewMTZ = np.dot(M,skew(M,Z))
    IMMTZ = dot((np.eye(len(M)) - dot(M,M.T)),Z)
    return MskewMTZ + IMMTZ
```

```python
def retr_stiefel_closed_form(M,Z):
    ''' compute '''
    ZTZ = dot(Z.T,Z)
    return la.inv(la.sqrtm(dot(M+Z,(np.eye(len(ZTZ)) + ZTZ))))

def retr_stiefel_svd(M,Z):
    U,s,V = la.svd(M+Z)
    print(U.shape,V.shape)
    return dot(U,V.T)
```

```python
retr_stiefel_svd(M,M + 0.01*proj_stiefel(M,-nabla))
```

```python
X.shape, np.dot(X,X.T).shape
```

    ((10, 2), (10, 10))

```python
def proj(X,U):
    skew_XTU = 0.5 * (np.dot(X.T,U) - np.dot(U.T,X))
    XXT = np.dot(X,X.T)
    I = np.eye(len(XXT))
    return np.dot(X,skew_XTU) + np.dot(I - XXT,U)
```

```python
X = M
Z = M_
```

```python
proj_stiefel(X,X)
```

    array([[ -2.08166817e-17,  -5.57279917e-17],
           [ -1.66533454e-16,  -2.03830008e-16],
           [  1.66533454e-16,   1.49186219e-16],
           [ -7.28583860e-17,  -1.82471226e-16],
           [  5.55111512e-17,   1.47451495e-16],
           [ -4.16333634e-17,   2.16840434e-18],
           [ -5.55111512e-17,  -6.41847686e-17],
           [  2.28983499e-16,   2.97938757e-16],
           [ -1.38777878e-16,  -8.41340886e-17],
           [  2.77555756e-16,   2.35922393e-16]])

```python
proj(X,Z)
```

    array([[ -8.08533463e-04,  -1.74880914e-03],
           [  1.54659220e-03,  -7.71805873e-04],
           [  7.91613473e-04,  -7.55978183e-04],
           [  1.25280879e-03,   4.85741442e-04],
           [ -3.87057535e-05,  -1.46878111e-04],
           [ -4.73537614e-04,  -4.58327309e-04],
           [  5.55372729e-04,  -2.09554031e-05],
           [  2.13110793e-03,  -1.21287630e-04],
           [  2.62155575e-04,   8.22368371e-04],
           [  1.16520863e-04,  -4.75602255e-04]])

```python
X.shape
```

    (1000, 10)

```python
X.T.shape
```

    (10, 1000)

```python
np.dot(X.T,X).shape,np.dot(X,X.T).shape
```

    ((10, 10), (1000, 1000))

```python
X.T.dot(X).shape,(X.dot(X.T)).shape
```

    ((10, 10), (1000, 1000))

```python
np.linalg.norm(X),np.trace(np.dot(X,X.T)),np.trace(np.dot(X.T,X))
```

    (100.96890275582501, 10194.719323715246, 10194.719323715248)

```python
X
```

    array([[ -1.15793447e+00,   7.08890921e-04,   1.08963956e-02, ...,
              1.34422705e+00,   1.21995753e+00,  -8.89123821e-01],
           [ -1.38337094e+00,  -3.17456729e-01,  -1.61633593e+00, ...,
             -1.53145704e+00,  -5.66074139e-01,   8.15097114e-01],
           [ -7.05693721e-01,   8.58759013e-01,  -2.00323789e+00, ...,
             -4.35369887e-01,   8.36220132e-01,   9.51043165e-01],
           ...,
           [  4.27508644e-01,   4.50559213e-01,  -1.00726948e+00, ...,
             -1.19187319e+00,   3.18524852e-01,   6.62183770e-02],
           [  6.52649944e-01,   1.61298106e+00,  -4.77056430e-01, ...,
             -9.90813058e-01,   1.67999338e-01,   1.09724008e+00],
           [ -1.38277316e+00,  -1.55336640e+00,   5.09705697e-01, ...,
             -8.89839013e-01,  -6.80742608e-01,   4.95242623e-01]])

```python

```


The following notes summarize parts of the contents in the paper "Derivation of an EM algorithm for constrained and unconstrained multivarite autoregressive state-space (MARSS) models" [link](http://faculty.washington.edu/eeholmes/Files/Holmes2010.pdf)

------

A Kalman filter/smoother is an optimal estimator -- i.e. infers parameters of interest from indirect, inaccurate and uncertain observations. It is recursive so that new measurements can be processed as they arrive. If all noise is Gaussian, the Kalman filter minimises the mean square error of the estimated parameters.

The Kalman Smoother calculate the expectations and variances of state variables.  Given a state model:

\begin{align}
x_t  &=  Bx_{t-1} + u + w_t \\
y_t  &=  zx_t + a + v_t
\end{align}

Denote the conditional expection and variance of $x_t$ given observation $y_1^\tau$ as: $x_t^\tau = E[x_t|y_1^\tau], v_t^\tau = Var[x_t|y_1^\tau]$. The classical Kalman Filter will generate the forward estimation of $x_t$ conditioned on data up to time $t$: $x_t^{t-1}, x_t^t, V_t^{t-1}, V_t^t$. By contrast, the Kalman Smoother will generate the posterior estimation of $x_t$ based on the complete observation data: $x_t^T, V_t^T$.

###### The forward recursions: Kalman Filter

\begin{align}
&x_t^{t-1} = B_tx_{t-1}^{t-1} + u_t \\
&x_t^t = x_t^{t-1} + K_t(y_t - Z_tx_t^{t-1} - a_t) \\
&V_t^{t-1} = B_tV_{t-1}^{t-1}B_t' + Q_t \\
&V_t^t = (I - K_tZ_t)V_t^{t-1} \\
&K_t = V_t^{t-1}Z_t'(Z_tV_t^{t-1}Z_t' + H_tR_tH_t')^{-1}
\end{align}

###### The backward recursions: Kalman Smoother

######Expectations involving $Y_t$

The classic Kalman Smoother is an algorithm to compute these estimations conditioned on no missing values in $y$.

\begin{align}
&E[Y_t|Y_t = y_t] = y_t \\
&E[Y_tY_t'|Y_t = y_t] = y_ty_t'  \\
&E[Y_tX_t'|Y_t = y_t] = y_t{x_t^T}' \\
&E[Y_tX_{t-1}'|Y_t = y_t] = y_tE[X_{t-1}'] = y_t{x_{t-1}^T}'
\end{align}


# SARIMAX and ARIMA: Frequently Asked Questions (FAQ)

This notebook contains explanations for frequently asked questions.

* Comparing trends and exogenous variables in `SARIMAX`, `ARIMA` and `AutoReg`
* Reconstructing residuals, fitted values and forecasts in `SARIMAX` and `ARIMA`
* Initial residuals in `SARIMAX` and `ARIMA`

## Comparing trends and exogenous variables in `SARIMAX`, `ARIMA` and `AutoReg`

`ARIMA` are formally OLS with ARMA errors.  A basic AR(1) in the OLS with ARMA errors is described as

$$
\begin{align}
Y_t & = \delta + \epsilon_t \\
\epsilon_t & = \rho \epsilon_{t-1} + \eta_t \\
\eta_t & \sim WN(0,\sigma^2) \\
\end{align}
$$

In large samples, $\hat{\delta}\stackrel{p}{\rightarrow} E[Y]$.

`SARIMAX` uses a different representation, so that the model when estimated using `SARIMAX` is

$$
\begin{align}
Y_t & = \phi + \rho Y_{t-1} + \eta_t \\
\eta_t & \sim WN(0,\sigma^2) \\
\end{align}
$$

This is the same representation that is used when the model is estimated using OLS (`AutoReg`). In large samples, $\hat{\phi}\stackrel{p}{\rightarrow} E[Y](1-\rho)$.

In the next cell, we simulate a large sample and verify that these relationship hold in practice.

```python
%matplotlib inline
```

```python
import numpy as np
import pandas as pd

rng = np.random.default_rng(20210819)
eta = rng.standard_normal(5000)
rho = 0.8
beta = 10
epsilon = eta.copy()
for i in range(1, eta.shape[0]):
    epsilon[i] = rho * epsilon[i - 1] + eta[i]
y = beta + epsilon
```

```python
from statsmodels.tsa.api import SARIMAX, AutoReg
from statsmodels.tsa.arima.model import ARIMA
```

The three models are specified and estimated in the next cell.  An AR(0) is included as a reference. The AR(0) is identical using all three estimators.

```python
ar0_res = SARIMAX(y, order=(0, 0, 0), trend="c").fit()
sarimax_res = SARIMAX(y, order=(1, 0, 0), trend="c").fit()
arima_res = ARIMA(y, order=(1, 0, 0), trend="c").fit()
autoreg_res = AutoReg(y, 1, trend="c").fit()
```

The table below contains the estimated parameter in the model, the estimated AR(1) coefficient, and the long-run mean which is either equal to the estimated parameters (AR(0) or `ARIMA`), or depends on the ratio of the intercept to 1 minus the AR(1) parameter.

```python
intercept = [
    ar0_res.params[0],
    sarimax_res.params[0],
    arima_res.params[0],
    autoreg_res.params[0],
]
rho_hat = [0] + [r.params[1] for r in (sarimax_res, arima_res, autoreg_res)]
long_run = [
    ar0_res.params[0],
    sarimax_res.params[0] / (1 - sarimax_res.params[1]),
    arima_res.params[0],
    autoreg_res.params[0] / (1 - autoreg_res.params[1]),
]
cols = ["AR(0)", "SARIMAX", "ARIMA", "AutoReg"]
pd.DataFrame(
    [intercept, rho_hat, long_run],
    columns=cols,
    index=["delta-or-phi", "rho", "long-run mean"],
)
```

### Differences between trend and exog in `SARIMAX`

When `SARIMAX` includes `exog` variables, then the `exog` are treated as OLS regressors, so that the model estimated is

$$
\begin{align}
Y_t - X_t \beta & = \delta + \rho (Y_{t-1} - X_{t-1}\beta) + \eta_t \\
\eta_t & \sim WN(0,\sigma^2) \\
\end{align}
$$

In the next example, we omit the trend and instead include a column of 1, which produces a model that is equivalent, in large samples, to the case with no exogenous regressor and `trend="c"`. Here the estimated value of `const` matches the value estimated using `ARIMA`. This happens since both exog in `SARIMAX` and the trend in `ARIMA` are treated as linear regression models with ARMA errors.

```python
sarimax_exog_res = SARIMAX(y, exog=np.ones_like(y), order=(1, 0, 0), trend="n").fit()
print(sarimax_exog_res.summary())
```

### Using `exog` in `SARIMAX` and `ARIMA`

While `exog` are treated the same in both models, the intercept continues to differ.  Below we add an exogenous regressor to `y` and then fit the model using all three methods. The data generating process is now

$$
\begin{align}
Y_t & = \delta + X_t \beta + \epsilon_t \\
\epsilon_t & = \rho \epsilon_{t-1} + \eta_t \\
\eta_t & \sim WN(0,\sigma^2) \\
\end{align}
$$

```python
x = rng.standard_normal(y.shape)
y += 3 * x
```

```python
sarimax_exog_res = SARIMAX(y, exog=x, order=(1, 0, 0), trend="c").fit()
arima_exog_res = ARIMA(y, exog=x, order=(1, 0, 0), trend="c").fit()
```

Examining the parameter tables, we see that the parameter estimates on `x1` are identical while the estimates of the `intercept` continue to differ due to the differences in the treatment of trends in these estimators.

#### `SARIMAX`

```python
sarimax_exog_res.summary().tables[1]
```

#### `ARIMA`

```python
arima_exog_res.summary().tables[1]
```

### `exog` in `AutoReg`

When using `AutoReg` to estimate a model using OLS, the model differs from both `SARIMAX` and `ARIMA`. The `AutoReg` specification with exogenous variables is

$$
\begin{align}
Y_t & = \phi + \rho Y_{t-1} + X_{t}\beta + \eta_t \\
\eta_t & \sim WN(0,\sigma^2) \\
\end{align}
$$

This specification is not equivalent to the specification estimated in `SARIMAX` and `ARIMA`. Here the difference is non-trival, and naive estimation on the same time series results in different parameter values, even in large samples (and the limit). Estimating this model changes the parameter estimates on the AR(1) coefficient.

#### `AutoReg`

```python
autoreg_exog_res = AutoReg(y, 1, exog=x, trend="c").fit()
autoreg_exog_res.summary().tables[1]
```

The key difference can be seen by writing the model in lag operator notation.

$$
\begin{align}
(1-\phi L ) Y_t & = X_{t}\beta + \eta_t \Rightarrow \\
Y_t & = (1-\phi L )^{-1}\left(X_{t}\beta + \eta_t\right) \\
Y_t & = \sum_{i=0}^{\infty} \phi^i \left(X_{t-i}\beta + \eta_{t-i}\right)
\end{align}
$$

where it is is assumed that $|\phi|<1$.  Here we see that $Y_t$ depends on all lagged values of $X_t$ and $\eta_t$.  This differs from the specification estimated by `SARIMAX` and `ARIMA`, which can be seen to be

$$
\begin{align}
Y_t - X_t \beta & = \delta + \rho (Y_{t-1} - X_{t-1}\beta) + \eta_t \\
\left(1-\rho L \right)\left(Y_t - X_t  \beta\right) & = \delta +  \eta_t \\
Y_t - X_t  \beta & = \frac{\delta}{1-\rho} +  \left(1-\rho L \right)^{-1}\eta_t \\
Y_t - X_t  \beta & = \frac{\delta}{1-\rho} +  \sum_{i=0}^\infty \rho^i \eta_{t-i} \\
Y_t  & = \frac{\delta}{1-\rho} + X_t  \beta +  \sum_{i=0}^\infty \rho^i \eta_{t-i} \\
\end{align}
$$

In this specification, $Y_t$ only depends on $X_t$ and no other lags.

### Using the correct DGP with `AutoReg`

Simulating the process that is estimated in `AutoReg` shows that the parameters are recovered from the true model.

```python
import numpy as np
import pandas as pd

y = beta + eta
epsilon = eta.copy()
for i in range(1, eta.shape[0]):
    y[i] = beta + rho * y[i - 1] + 3 * x[i] + eta[i]
```

#### `AutoReg` with correct DGP

```python
autoreg_alt_exog_res = AutoReg(y, 1, exog=x, trend="c").fit()
autoreg_alt_exog_res.summary().tables[1]
```

## Reconstructing residuals, fitted values and forecasts in `SARIMAX` and `ARIMA`

TODO: Complete

## Initial residuals `SARIMAX` and `ARIMA`

Residuals for observations before the maximal model order, which depends on the AR, MA, Seasonal AR, Seasonal MA and differencing parameters, are not reliable and should not be used for performance assessment. In general, in an ARIMA with orders $(p,d,q)\times(P,D,Q,s)$, the formula for residuals that are less well behaved is:

$$
\max((P+D)s+p+d,Qs+q)
$$

We can simulate some data from an ARIMA(1,0,0)(1,0,0,12) and examine the residuals.

```python
import numpy as np
import pandas as pd

rng = np.random.default_rng(20210819)
eta = rng.standard_normal(5200)
rho = 0.8
psi = -0.6
beta = 20
epsilon = eta.copy()
for i in range(13, eta.shape[0]):
    epsilon[i] = (
        rho * epsilon[i - 1]
        + psi * epsilon[i - 12]
        - (rho * psi) * epsilon[i - 13]
        + eta[i]
    )
y = beta + epsilon
y = y[200:]
```

With a large sample, the parameter estimates are very close to the DGP parameters.

```python
res = ARIMA(y, order=(1, 0, 0), trend="c", seasonal_order=(1, 0, 0, 12)).fit()
print(res.summary())
```

We can first examine the initial 13 residuals by plotting against the actual shocks in the model.  While there is a correspondence, it is fairly weak and the correlation is much less than 1.

```python
import matplotlib.pyplot as plt

_ = plt.scatter(res.resid[:13], eta[200 : 200 + 13])
```

Looking at the next 24 residuals and shocks, we see there is nearly perfect correlation. This is expected in large samples once the less accurate residuals are ignored.

```python
_ = plt.scatter(res.resid[13:37], eta[200 + 13 : 200 + 37])
```

Next, we simulate an ARIMA(1,1,0), and include a time trend.

```python
rng = np.random.default_rng(20210819)
eta = rng.standard_normal(5200)
rho = 0.8
beta = 20
epsilon = eta.copy()
for i in range(2, eta.shape[0]):
    epsilon[i] = (1 + rho) * epsilon[i - 1] - rho * epsilon[i - 2] + eta[i]
t = np.arange(epsilon.shape[0])
y = beta + 2 * t + epsilon
y = y[200:]
```

Again the parameter estimates are very close to the DGP parameters.

```python
res = ARIMA(y, order=(1, 1, 0), trend="t").fit()
print(res.summary())
```

The residuals are not accurate, and the first residual is approximately 500.  The others are closer, although in this model the first 2 should usually be ignored.

```python
res.resid[:5]
```

The reason why the first residual is so large is that the optimal prediction of this value is the mean of the difference, which is 1.77.  Once the first value is known, the second value makes use o fthe first value in its prediction and the prediction is substantially closer to the truth.

```python
res.predict(0, 5)
```

It is worth noting that the results class contains two parameters than can be helpful in understanding which residuals are problematic, `loglikelihood_burn` and `nobs_diffuse`.

```python
res.loglikelihood_burn, res.nobs_diffuse
```


# Introduction

By exposing sample to electron beam, we can measure electrons inelastic scattering energy loss by identifying resonant collisions resulting in molecular vibrational levels excitations. Vibrational levels, up to a first anharmonic term, can be described as:

\begin{equation}
E_n = \hbar \omega (n + \frac{1}{2}) - \hbar \omega X (n + \frac{1}{2}) ^ 2
\end{equation}

# Analysis

```python
import numpy as np
import pandas as pd

import matplotlib.pyplot as plt

from scipy.optimize import curve_fit

import plotly.plotly as pl
from plotly import tools
from plotly import graph_objs as go
from plotly.offline import plot, init_notebook_mode, iplot
```

```python
data_1 = pd.read_csv("../data/Ascii1.dat", header=None, sep="\t").rename({0: "e", 1: "n"}, axis=1)
data_2 = pd.read_csv("../data/Ascii2.dat", header=None, sep="\t").rename({0: "e", 1: "n"}, axis=1)
```

```python
e_1 = data_1.e.values
n_1 = data_1.n.values

e_2 = data_2.e.values
n_2 = data_2.n.values
```

```python
idx_1 = np.where((e_1 < 2.7) & (e_1 > 1.3))
idx_2 = np.where(n_2 > 20)
```

```python
fig, ax = plt.subplots(1, 2)

fig.set_figwidth(15)

ax[0].scatter(e_1[idx_1], n_1[idx_1])
ax[1].scatter(e_2[idx_2], n_1[idx_2])
```

```python
n1 = np.linspace(1, 5, 5)
n2 = np.linspace(1, 5, 5)

e1 = np.array([1.510, 1.763, 2.005, 2.275, 2.531])
e2 = np.array([1.292, 1.570, 1.838, 2.117, 2.402])
```

```python
n1
```

    array([1., 2., 3., 4., 5.])

```python
def energy(n, *args):
    return args[0] * (n + .5) - args[0] * args[1] * (n + .5) ** 2
```

```python
init_notebook_mode(True)

trace_1 = go.Scatter(x=e_1[idx_1], y=n_1[idx_1], mode='markers')
trace_2 = go.Scatter(x=e_2[idx_2], y=n_2[idx_2], mode='markers')

fig = tools.make_subplots(rows=1, cols=2)

fig.append_trace(trace_1, 1, 1)
fig.append_trace(trace_2, 1, 2)

fig['layout'].update(height=600, width=800, title='Vibrational spectra')
#iplot(fig)
```

    This is the format of your plot grid:
    [ (1,1) x1,y1 ]  [ (1,2) x2,y2 ]

    Layout({
        'height': 600,
        'title': {'text': 'Vibrational spectra'},
        'width': 800,
        'xaxis': {'anchor': 'y', 'domain': [0.0, 0.45]},
        'xaxis2': {'anchor': 'y2', 'domain': [0.55, 1.0]},
        'yaxis': {'anchor': 'x', 'domain': [0.0, 1.0]},
        'yaxis2': {'anchor': 'x2', 'domain': [0.0, 1.0]}
    })

```python
popt_1, pcov_1 = curve_fit(energy, n1, e1, p0=[2e3, 5e-3], maxfev=8000)
```

```python
popt_2, pcov_2 = curve_fit(energy, n2, e2, p0=[2e3, 5e-3], maxfev=8000)
```

```python
corr = 1 / 1.23941 * 10 ** 4
```

```python
h_bar_omega_1 = popt_1[0] * corr
d_h_bar_omega_1 = pcov_1[0, 0] * corr

h_bar_omega_x_1 = popt_1[1] * corr
d_h_bar_omega_x_1 = (popt_1[0] * pcov_1[1, 1] +
                     popt_1[1] * pcov_1[0, 0] +
                     2 * popt_1[1] * popt_1[0] * pcov_1[0, 1]) * corr
```

```python
h_bar_omega_2 = popt_2[0] * corr
d_h_bar_omega_2 = pcov_2[0, 0] * corr

h_bar_omega_x_2 = popt_2[1] * corr
d_h_bar_omega_x_2 = (popt_2[0] * pcov_2[1, 1] +
                     popt_2[1] * pcov_2[0, 0] +
                     2 * popt_2[1] * popt_2[0] * pcov_2[0, 1]) * corr
```

```python
h_bar_omega_1
```

    7601.650809599039

```python

```

Physics of Molecules/src/Vibrational spectra.ipynb


# Homework 1
*This notebook includes both coding and written questions. Please hand in this notebook file with all the outputs and your answers to the written questions.*

This assignment covers linear filters, convolution and correlation.

```python
# Setup
import numpy as np
import matplotlib.pyplot as plt
from time import time
from skimage import io

from __future__ import print_function

%matplotlib inline
plt.rcParams['figure.figsize'] = (10.0, 8.0) # set default size of plots
plt.rcParams['image.interpolation'] = 'nearest'
plt.rcParams['image.cmap'] = 'gray'

# for auto-reloading extenrnal modules
%load_ext autoreload
%autoreload 2
```

## Part 1: Convolutions
### 1.1 Commutative Property (5 points)
Recall that the convolution of an image $f:\mathbb{R}^2\rightarrow \mathbb{R}$ and a kernel $h:\mathbb{R}^2\rightarrow\mathbb{R}$ is defined as follows:
$$(f*h)[m,n]=\sum_{i=-\infty}^\infty\sum_{j=-\infty}^\infty f[i,j]\cdot h[m-i,n-j]$$

Or equivalently,
\begin{align}
(f*h)[m,n] &= \sum_{i=-\infty}^\infty\sum_{j=-\infty}^\infty h[i,j]\cdot f[m-i,n-j]\\
&= (h*f)[m,n]
\end{align}

Show that this is true (i.e. prove that the convolution operator is commutative: $f*h = h*f$).

**Your Answer:** *Write your solution in this markdown cell. Please write your equations in [LaTex equations](http://jupyter-notebook.readthedocs.io/en/latest/examples/Notebook/Typesetting%20Equations.html).*

### 1.2 Shift Invariance (5 points)
Let $f$ be a function $\mathbb{R}^2\rightarrow\mathbb{R}$. Consider a system $f\xrightarrow{s}g$, where $g=(f*h)$ with some kernel $h:\mathbb{R}^2\rightarrow\mathbb{R}$. Also consider functions $f'(m,n) = f(m-m_0, n-n_0)$ and $g'(m,n) = g(m-m_0, n-n_0)$.

Show that $S$ defined by any kernel $h$ is a Linear Shift Invariant (LSI) system by showing that $g' = (f'*h)$.

**Your Answer:** *Write your solution in this markdown cell. Please write your equations in [LaTex equations](http://jupyter-notebook.readthedocs.io/en/latest/examples/Notebook/Typesetting%20Equations.html).*

### 1.3 Linear Invariance (10 points)

Recall that a system S is considered a linear system if and only if it satisfies the superposition property. In mathematical terms, a (function) S is a Linear Invariant system iff it satisfies:

$S[\alpha f_i[n,m] + \beta f_j[k,l]] = \alpha S[f_i[n,m]] + \beta S[f_j[k,l]]$

Let $f_i$ and $f_j$ be functions $\mathbb{R}^2\rightarrow\mathbb{R}$. Consider a system $f\xrightarrow{s}g$, where $g=(f*h)$ with some kernel $h:\mathbb{R}^2\rightarrow\mathbb{R}$.

Show that $S$ defined by any kernel $h$ is a Linear Invariant (LSI) system by showing that the superposition property holds for S.

**Your Answer:** *Write your solution in this markdown cell. Please write your equations in [LaTex equations](http://jupyter-notebook.readthedocs.io/en/latest/examples/Notebook/Typesetting%20Equations.html).*

### 1.4 Implementation (30 points)

In this section, you will implement two versions of convolution:
- `conv_nested`
- `conv_fast`

First, run the code cell below to load the image to work with.

```python
# Open image as grayscale
img = io.imread('dog.jpg', as_gray=True)

# Show image
plt.imshow(img)
plt.axis('off')
plt.title("Isn't he cute?")
plt.show()
```

Now, implement the function **`conv_nested`** in **`filters.py`**. This is a naive implementation of convolution which uses 4 nested for-loops. It takes an image $f$ and a kernel $h$ as inputs and outputs the convolved image $(f*h)$ that has the same shape as the input image. This implementation should take a few seconds to run.

*- Hint: It may be easier to implement $(h*f)$*

We'll first test your `conv_nested` function on a simple input.

```python
from filters import conv_nested

# Simple convolution kernel.
kernel = np.array(
[
    [1,0,1],
    [0,0,0],
    [1,0,0]
])

# Create a test image: a white square in the middle
test_img = np.zeros((9, 9))
test_img[3:6, 3:6] = 1

# Run your conv_nested function on the test image
test_output = conv_nested(test_img, kernel)

# Build the expected output
expected_output = np.zeros((9, 9))
expected_output[2:7, 2:7] = 1
expected_output[5:, 5:] = 0
expected_output[4, 2:5] = 2
expected_output[2:5, 4] = 2
expected_output[4, 4] = 3

# Plot the test image
plt.subplot(1,3,1)
plt.imshow(test_img)
plt.title('Test image')
plt.axis('off')

# Plot your convolved image
plt.subplot(1,3,2)
plt.imshow(test_output)
plt.title('Convolution')
plt.axis('off')

# Plot the exepected output
plt.subplot(1,3,3)
plt.imshow(expected_output)
plt.title('Exepected output')
plt.axis('off')
plt.show()

# Test if the output matches expected output
assert np.max(test_output - expected_output) < 1e-10, "Your solution is not correct."
```

Now let's test your `conv_nested` function on a real image.

```python
from filters import conv_nested

# Simple convolution kernel.
# Feel free to change the kernel to see different outputs.
kernel = np.array(
[
    [1,0,-1],
    [2,0,-2],
    [1,0,-1]
])

out = conv_nested(img, kernel)

# Plot original image
plt.subplot(2,2,1)
plt.imshow(img)
plt.title('Original')
plt.axis('off')

# Plot your convolved image
plt.subplot(2,2,3)
plt.imshow(out)
plt.title('Convolution')
plt.axis('off')

# Plot what you should get
solution_img = io.imread('convoluted_dog.jpg', as_gray=True)
plt.subplot(2,2,4)
plt.imshow(solution_img)
plt.title('What you should get')
plt.axis('off')

plt.show()
```

Let us implement a more efficient version of convolution using array operations in numpy. As shown in the lecture, a convolution can be considered as a sliding window that computes sum of the pixel values weighted by the flipped kernel. The faster version will i) zero-pad an image, ii) flip the kernel horizontally and vertically, and iii) compute weighted sum of the neighborhood at each pixel.

First, implement the function **`zero_pad`** in **`filters.py`**.

```python
from filters import zero_pad

pad_width = 20 # width of the padding on the left and right
pad_height = 40 # height of the padding on the top and bottom

padded_img = zero_pad(img, pad_height, pad_width)

# Plot your padded dog
plt.subplot(1,2,1)
plt.imshow(padded_img)
plt.title('Padded dog')
plt.axis('off')

# Plot what you should get
solution_img = io.imread('padded_dog.jpg', as_gray=True)
plt.subplot(1,2,2)
plt.imshow(solution_img)
plt.title('What you should get')
plt.axis('off')

plt.show()
```

Next, complete the function **`conv_fast`** in **`filters.py`** using `zero_pad`. Run the code below to compare the outputs by the two implementations. `conv_fast` should run significantly faster than `conv_nested`.
Depending on your implementation and computer, `conv_nested` should take a few seconds and `conv_fast` should be around 5 times faster.

```python
from filters import conv_fast

t0 = time()
out_fast = conv_fast(img, kernel)
t1 = time()
out_nested = conv_nested(img, kernel)
t2 = time()

# Compare the running time of the two implementations
print("conv_nested: took %f seconds." % (t2 - t1))
print("conv_fast: took %f seconds." % (t1 - t0))

# Plot conv_nested output
plt.subplot(1,2,1)
plt.imshow(out_nested)
plt.title('conv_nested')
plt.axis('off')

# Plot conv_fast output
plt.subplot(1,2,2)
plt.imshow(out_fast)
plt.title('conv_fast')
plt.axis('off')

# Make sure that the two outputs are the same
if not (np.max(out_fast - out_nested) < 1e-10):
    print("Different outputs! Check your implementation.")
```

### Extra Credit 1 (10 points)
Devise a faster version of convolution and implement **`conv_faster`** in **`filters.py`**. You will earn extra credit only if the `conv_faster` runs faster (by a fair margin) than `conv_fast` **and** outputs the same result.

```python
from filters import conv_faster

t0 = time()
out_fast = conv_fast(img, kernel)
t1 = time()
out_faster = conv_faster(img, kernel)
t2 = time()

# Compare the running time of the two implementations
print("conv_fast: took %f seconds." % (t1 - t0))
print("conv_faster: took %f seconds." % (t2 - t1))

# Plot conv_nested output
plt.subplot(1,2,1)
plt.imshow(out_fast)
plt.title('conv_fast')
plt.axis('off')

# Plot conv_fast output
plt.subplot(1,2,2)
plt.imshow(out_faster)
plt.title('conv_faster')
plt.axis('off')

# Make sure that the two outputs are the same
if not (np.max(out_fast - out_faster) < 1e-10):
    print("Different outputs! Check your implementation.")
```

---
## Part 2: Cross-correlation

Cross-correlation of two 2D signals $f$ and $g$ is defined as follows:
$$(f\star{g})[m,n]=\sum_{i=-\infty}^\infty\sum_{j=-\infty}^\infty f[i,j]\cdot g[m-i,n-j]$$

### 2.1 Template Matching with Cross-correlation (12 points)
Suppose that you are a clerk at a grocery store. One of your responsibilites is to check the shelves periodically and stock them up whenever there are sold-out items. You got tired of this laborious task and decided to build a computer vision system that keeps track of the items on the shelf.

Luckily, you have learned in CS131 that cross-correlation can be used for template matching: a template $g$ is multiplied with regions of a larger image $f$ to measure how similar each region is to the template.

The template of a product (`template.jpg`) and the image of shelf (`shelf.jpg`) is provided. We will use cross-correlation to find the product in the shelf.

Implement **`cross_correlation`** function in **`filters.py`** and run the code below.

*- Hint: you may use the `conv_fast` function you implemented in the previous question.*

```python
from filters import cross_correlation

# Load template and image in grayscale
img = io.imread('shelf.jpg')
img_grey = io.imread('shelf.jpg', as_gray=True)
temp = io.imread('template.jpg')
temp_grey = io.imread('template.jpg', as_gray=True)

# Perform cross-correlation between the image and the template
out = cross_correlation(img_grey, temp_grey)

# Find the location with maximum similarity
y,x = (np.unravel_index(out.argmax(), out.shape))

# Display product template
plt.figure(figsize=(25,20))
plt.subplot(3, 1, 1)
plt.imshow(temp)
plt.title('Template')
plt.axis('off')

# Display cross-correlation output
plt.subplot(3, 1, 2)
plt.imshow(out)
plt.title('Cross-correlation (white means more correlated)')
plt.axis('off')

# Display image
plt.subplot(3, 1, 3)
plt.imshow(img)
plt.title('Result (blue marker on the detected location)')
plt.axis('off')

# Draw marker at detected location
plt.plot(x, y, 'bx', ms=40, mew=10)
plt.show()
```

#### Interpretation
How does the output of cross-correlation filter look? Was it able to detect the product correctly? Explain what problems there might be with using a raw template as a filter.

**Your Answer:** *Write your solution in this markdown cell.*

---
### 2.2 Zero-mean cross-correlation (6 points)
A solution to this problem is to subtract the mean value of the template so that it has zero mean.

Implement **`zero_mean_cross_correlation`** function in **`filters.py`** and run the code below.

**If your implementation is correct, you should see the blue cross centered over the correct cereal box**

```python
from filters import zero_mean_cross_correlation

# Perform cross-correlation between the image and the template
out = zero_mean_cross_correlation(img_grey, temp_grey)

# Find the location with maximum similarity
y,x = (np.unravel_index(out.argmax(), out.shape))

# Display product template
plt.figure(figsize=(30,20))
plt.subplot(3, 1, 1)
plt.imshow(temp)
plt.title('Template')
plt.axis('off')

# Display cross-correlation output
plt.subplot(3, 1, 2)
plt.imshow(out)
plt.title('Cross-correlation (white means more correlated)')
plt.axis('off')

# Display image
plt.subplot(3, 1, 3)
plt.imshow(img)
plt.title('Result (blue marker on the detected location)')
plt.axis('off')

# Draw marker at detcted location
plt.plot(x, y, 'bx', ms=40, mew=10)
plt.show()

```

You can also determine whether the product is present with appropriate scaling and thresholding.

```python
def check_product_on_shelf(shelf, product):
    out = zero_mean_cross_correlation(shelf, product)

    # Scale output by the size of the template
    out = out / float(product.shape[0]*product.shape[1])

    # Threshold output (this is arbitrary, you would need to tune the threshold for a real application)
    out = out > 0.025

    if np.sum(out) > 0:
        print('The product is on the shelf')
    else:
        print('The product is not on the shelf')

# Load image of the shelf without the product
img2 = io.imread('shelf_soldout.jpg')
img2_grey = io.imread('shelf_soldout.jpg', as_gray=True)

plt.imshow(img)
plt.axis('off')
plt.show()
check_product_on_shelf(img_grey, temp_grey)

plt.imshow(img2)
plt.axis('off')
plt.show()
check_product_on_shelf(img2_grey, temp_grey)
```

---
### 2.3 Normalized Cross-correlation (12 points)
One day the light near the shelf goes out and the product tracker starts to malfunction. The `zero_mean_cross_correlation` is not robust to change in lighting condition. The code below demonstrates this.

```python
from filters import normalized_cross_correlation

# Load image
img = io.imread('shelf_dark.jpg')
img_grey = io.imread('shelf_dark.jpg', as_gray=True)

# Perform cross-correlation between the image and the template
out = zero_mean_cross_correlation(img_grey, temp_grey)

# Find the location with maximum similarity
y,x = (np.unravel_index(out.argmax(), out.shape))

# Display image
plt.imshow(img)
plt.title('Result (red marker on the detected location)')
plt.axis('off')

# Draw marker at detcted location
plt.plot(x, y, 'rx', ms=25, mew=5)
plt.show()
```

A solution is to normalize the pixels of the image and template at every step before comparing them. This is called **normalized cross-correlation**.

The mathematical definition for normalized cross-correlation of $f$ and template $g$ is:
$$(f\star{g})[m,n]=\sum_{i,j} \frac{f[i,j]-\overline{f_{m,n}}}{\sigma_{f_{m,n}}} \cdot \frac{g[i-m,j-n]-\overline{g}}{\sigma_g}$$

where:
- $f_{m,n}$ is the patch image at position $(m,n)$
- $\overline{f_{m,n}}$ is the mean of the patch image $f_{m,n}$
- $\sigma_{f_{m,n}}$ is the standard deviation of the patch image $f_{m,n}$
- $\overline{g}$ is the mean of the template $g$
- $\sigma_g$ is the standard deviation of the template $g$

Implement **`normalized_cross_correlation`** function in **`filters.py`** and run the code below.

```python
from filters import normalized_cross_correlation

# Perform normalized cross-correlation between the image and the template
out = normalized_cross_correlation(img_grey, temp_grey)

# Find the location with maximum similarity
y,x = (np.unravel_index(out.argmax(), out.shape))

# Display image
plt.imshow(img)
plt.title('Result (red marker on the detected location)')
plt.axis('off')

# Draw marker at detcted location
plt.plot(x, y, 'rx', ms=25, mew=5)
plt.show()
```

## Part 3: Separable Filters

### 3.1 Theory (10 points)
Consider an $M_1\times{N_1}$ image $I$ and an $M_2\times{N_2}$ filter $F$. A filter $F$ is **separable** if it can be written as a product of two 1D filters: $F=F_1F_2$.

For example,
$$F=
\begin{bmatrix}
1 & -1 \\
1 & -1
\end{bmatrix}
$$
can be written as a matrix product of
$$F_1=
\begin{bmatrix}
1  \\
1
\end{bmatrix},
F_2=
\begin{bmatrix}
1 & -1
\end{bmatrix}
$$
Therefore $F$ is a separable filter.

Prove that for any separable filter $F=F_1F_2$,
$$I*F=(I*F_1)*F_2$$

**Your Answer:** *Write your solution in this markdown cell. Please write your equations in [LaTex equations](http://jupyter-notebook.readthedocs.io/en/latest/examples/Notebook/Typesetting%20Equations.html).*

### 3.2 Complexity comparison (10 points)
Consider an $M_1\times{N_1}$ image $I$ and an $M_2\times{N_2}$ filter $F$ that is separable (i.e. $F=F_1F_2$).

(i) How many multiplication operations do you need to do a direct 2D convolution (i.e. $I*F$)?

(ii) How many multiplication operations do you need to do 1D convolutions on rows and columns (i.e. $(I*F_1)*F_2$)?

(iii) Use Big-O notation to argue which one is more efficient in general: direct 2D convolution or two successive 1D convolutions?

**Your Answer:** *Write your solution in this markdown cell. Please write your equations in [LaTex equations](http://jupyter-notebook.readthedocs.io/en/latest/examples/Notebook/Typesetting%20Equations.html).*

Now, we will empirically compare the running time of a separable 2D convolution and its equivalent two 1D convolutions. The Gaussian kernel, widely used for blurring images, is one example of a separable filter. Run the code below to see its effect.

```python
# Load image
img = io.imread('dog.jpg', as_grey=True)

# 5x5 Gaussian blur
kernel = np.array(
[
    [1,4,6,4,1],
    [4,16,24,16,4],
    [6,24,36,24,6],
    [4,16,24,16,4],
    [1,4,6,4,1]
])

t0 = time()
out = conv_nested(img, kernel)
t1 = time()
t_normal = t1 - t0

# Plot original image
plt.subplot(1,2,1)
plt.imshow(img)
plt.title('Original')
plt.axis('off')

# Plot convolved image
plt.subplot(1,2,2)
plt.imshow(out)
plt.title('Blurred')
plt.axis('off')

plt.show()
```

In the below code cell, define the two 1D arrays (`k1` and `k2`) whose product is equal to the Gaussian kernel.

```python
# The kernel can be written as outer product of two 1D filters
k1 = None  # shape (5, 1)
k2 = None  # shape (1, 5)

### YOUR CODE HERE
pass
### END YOUR CODE

# Check if kernel is product of k1 and k2
if not  np.all(k1 * k2 == kernel):
    print('k1 * k2 is not equal to kernel')

assert k1.shape == (5, 1), "k1 should have shape (5, 1)"
assert k2.shape == (1, 5), "k2 should have shape (1, 5)"
```

We now apply the two versions of convolution to the same image, and compare their running time. Note that the outputs of the two convolutions must be the same.

```python
# Perform two convolutions using k1 and k2
t0 = time()
out_separable = conv_nested(img, k1)
out_separable = conv_nested(out_separable, k2)
t1 = time()
t_separable = t1 - t0

# Plot normal convolution image
plt.subplot(1,2,1)
plt.imshow(out)
plt.title('Normal convolution')
plt.axis('off')

# Plot separable convolution image
plt.subplot(1,2,2)
plt.imshow(out_separable)
plt.title('Separable convolution')
plt.axis('off')

plt.show()

print("Normal convolution: took %f seconds." % (t_normal))
print("Separable convolution: took %f seconds." % (t_separable))
```

```python
# Check if the two outputs are equal
assert np.max(out_separable - out) < 1e-10
```


# Quantum Approximate Optimization Algorithm

<em> Copyright (c) 2021 Institute for Quantum Computing, Baidu Inc. All Rights Reserved. </em>

## Overview

Quantum Approximate Optimization Algorithm (QAOA) is a quantum algorithm that can run on recent Noisy Intermediate-Scale Quantum (NISQ) devices and has a wide range of applications. QAOA was proposed by Edward Farhi et al. in 2014 [1], and its purpose is to solve combinatorial optimization problems approximately.

### Combinatorial optimization problem

In applied mathematics and theoretical computer science, combinatorial optimization is a type of topic that finds the optimal object in a discrete set of objects. In simple terms, the combinatorial optimization problem means that all solutions of the problem are composed of discrete variables, and the optimal solution is to be found in the discrete solution set. Combinatorial optimization problems involve a wide range of areas and are common in real life, such as the design of aircraft routes and the planning of express delivery routes.

Specifically, a combinatorial optimization problem can be described by $n$ bits and $m$ clauses. Each bit is a binary variable whose value is $0$ or $1$, and we use $z_j$ to denote the value of the $j$th bit. Therefore, the value of these $n$ bits can be represented by the bit string $z=z_1z_2\dots z_n$. Each clause is a restriction on some bits. For example, a clause can require the value of the $2$nd bit to be $0$, or it can require the value of the $3$rd bit and the $5$th bit to be the same. For the $j$th clause, we define a function

$$
C_j(z)=
\begin{cases}
1 & \text{If the value $z$ of $n$ bits satisfies the condition indicated by clause $j$}\\
0 & \text{if the value $z$ of $n$ bits does not satisfy the condition indicated by clause $j$}
\end{cases}.
\tag{1}
$$

For example, if the first clause requires the value of the second bit to be 0, then we have $C_1(z_10z_3\dots z_n)=1$ and $C_1(z_11z_3\dots z_n)=0$.

From the definition of $C_j$ in equation (1), we can define the objective function of the combinatorial optimization problem

$$
C(z)=\sum_{j=1}^m w_jC_j(z),
\tag{2}
$$

where $w_j$ is the weight of the $j$th clause. The combinatorial optimization problem is to find a value $z$ that maximizes the value of the objective function $C(z)$, namely

$$
\underset{z}{\operatorname{argmax}} C(z).
\tag{3}
$$

In theoretical computer science, there is a famous problem called the Boolean satisfiability problem (SAT). This problem asks that, given $n$ bits and $m$ clauses as inputs, if there exists a value these bits can take so that all the $m$ clauses are satisfied simultaneously. An optimization problem related to SAT is MAX-SAT, which asks that what value these $n$ bits should take so that as many as possible clauses are satisfied. It is worth noting that the way we define a combinatorial optimization problem above is to formulate it as MAX-SAT, or more precisely, weighted MAX-SAT.

## Quantum approximate optimization algorithm

Many combinatorial optimization problems are NP-complete or even NP-hard problems, which means that computers may not be able to solve such problems efficiently. An alternative is to find the approximate optimal solution to such problems, and this task can usually be completed efficiently. QAOA is a quantum algorithm that can find an approximate optimal solution to a combinatorial optimization problem.

### Encoding combinatorial optimization problem

For the above combinatorial optimization problem, there are $n$ bits and $m$ clauses. The QAOA algorithm transforms this problem into an optimization problem on an $n$-qubit system. Each computational basis state $|z\rangle \in \{0,1\}^n$ of the quantum system corresponds to a value $z$ of $n$ bits in the original problem. At the same time, for the $j$th clause in the original problem, we define a diagonal Hamiltonian $H_{C_j}$ satisfying

$$
H_{C_j}|z\rangle = C_j(z)|z\rangle.
\tag{4}
$$

Specifically, we can construct the Hamiltonian $H_{C_j}$ through the following equation:

$$
H_{C_j} = \sum_{z\in\{0,1\}^n} C_j(z)|z\rangle\langle z|.
\tag{5}
$$

For example, assuming that $z^{(1)}$ and $z^{(2)}$ are the values satisfying the $j$th clause, then we can define

$$
H_{C_j} = |z^{(1)}\rangle\langle z^{(1)}| + |z^{(2)}\rangle\langle z^{(2)}|.
\tag{6}
$$

Therefore, QAOA encodes the objective function $C$ of the combinatorial optimization problem into the Hamiltonian $H_C$ on a system with $n$ qubits, where

$$
H_C = \sum_{j=1}^m w_jH_{C_j},
\tag{7}
$$

and

$$
H_C|z\rangle = \sum_{j=1}^m w_jH_{C_j}|z\rangle = \sum_{j=1}^m w_jC_j(z)|z\rangle = C(z)|z\rangle .
\tag{8}
$$

It is worth noting that if an optimal solution to the original problem is $z_\text{opt}$, then we have

$$
\langle z_\text{opt}|H_C|z_\text{opt}\rangle = \langle z_\text{opt}|C(z_\text{opt})|z_\text{opt}\rangle = C( z_\text{opt})\langle z_\text{opt}|z_\text{opt}\rangle = C(z_\text{opt}).
\tag{9}
$$

Therefore, an optimal solution of the original combinatorial optimization problem is an eigenstate with the largest eigenvalue of the Hamiltonian $H_C$. In addition, for any quantum state $|\psi\rangle$,

$$
\langle\psi|H_C|\psi\rangle \leq C(z_\text{opt}),
\tag{10}
$$

and the equation takes the equal sign if and only if $|\psi\rangle$ is a superposition state of optimal solutions. It can be obtained from equation (9) and equation (10) that

$$
\max_{|\psi\rangle} \langle\psi|H_C|\psi\rangle = C(z_\text{opt}),
\tag{11}
$$

and finding the optimal solution of the original combinatorial optimization problem is equivalent to finding an eigenstate with the largest eigenvalue of the Hamiltonian $H_C$, that is, finding a quantum state $|\psi\rangle$ such that

$$
H_C|\psi\rangle = C(z_\text{opt})|\psi\rangle.
\tag{12}
$$

When we find such a quantum state $|\psi\rangle$, it is probably not a computational basis state, but a superposition of several computational basis states, each of which is an optimal solution of the original combinatorial optimization problem. Therefore, we can obtain an optimal solution to the original combinatorial optimization problem by performing computational basis measurements on $|\psi\rangle$.

### Finding an approximate optimal solution

Although it is relatively simple to encode the objective function of the combinatorial optimization problem to be solved into the Hamiltonian $H_C$ of a quantum system, finding the quantum state $|\psi\rangle$ representing an optimal solution from the huge Hilbert space according to equation (11) is not easy. In order to find such a quantum state, we need another Hamiltonian

$$
H_B = \sum_{j=1}^n X_j,
\tag{13}
$$

where $X_j$ represents the Pauli $X$ gate acting on the $j$th qubit. The two eigenstates of Pauli $X$ gate are $|+\rangle$ and $|-\rangle$, and their corresponding eigenvalues are $1$ and $-1$, respectively:

$$
\begin{align}
X|+\rangle &=
\begin{bmatrix}
0&1\\1&0
\end{bmatrix}
\frac{1}{\sqrt{2}}
\begin{bmatrix}
1\\1
\end{bmatrix}
= \frac{1}{\sqrt{2}}
\begin{bmatrix}
1\\1
\end{bmatrix}
= |+\rangle,\tag{14}\\
X|-\rangle &=
\begin{bmatrix}
0&1\\1&0
\end{bmatrix}
\frac{1}{\sqrt{2}}
\begin{bmatrix}
1\\-1
\end{bmatrix}
= \frac{1}{\sqrt{2}}
\begin{bmatrix}
-1\\1
\end{bmatrix}
= -|-\rangle.\tag{15}
\end{align}
$$

Therefore, the eigenstate with the largest eigenvalue of $H_B$ is

$$
|s\rangle \equiv \underbrace{|+\rangle\otimes\cdots\otimes|+\rangle}_\text{$n$ terms} = |+\rangle^{\otimes n }.
\tag{16}
$$

We will use this quantum state $|s\rangle$ as the initial state for the algorithm. After constructing the Hamiltonian $H_C$ and $H_B$, we can start to find an approximate optimal solution to the original combinatorial optimization problem. According to the Hamiltonian $H_C$ and $H_B$, respectively define the unitary transformation

$$
U_C(\gamma) = e^{-i\gamma H_C}
\tag{17}
$$

and unitary transformation

$$
U_B(\beta) = e^{-i\beta H_B},
\tag{18}
$$

where $\gamma$ and $\beta$ are adjustable real parameters. Given any integer $p\geq1$ and the parameters $\vec{\gamma}=(\gamma_1,\dots,\gamma_p)$ and $\vec{\beta}=(\beta_1,\dots,\beta_p) $, we define

$$
|\vec{\gamma},\vec{\beta}\rangle = U_B(\beta_p)U_C(\gamma_p)\cdots U_B(\beta_1)U_C(\gamma_1)|s\rangle.
\tag{19}
$$

It can be seen that the integer $p$ represents the number of layers of $U_C, U_B$, that is, the $U_C$ and $U_B$ are alternately applied to the initial state $|s\rangle$ $p$ times. Let us denote $F_p(\vec{\gamma},\vec{\beta})$ as the expected value of the Hamiltonian $H_C$ in the quantum state of equation (19):

$$
F_p(\vec{\gamma},\vec{\beta}) = \langle\vec{\gamma},\vec{\beta}|H_C|\vec{\gamma},\vec{\beta}\rangle .
\tag{20}
$$

By adjusting the parameters $\vec{\gamma},\vec{\beta}$, we can get

$$
M_p = \max_{\vec{\gamma},\vec{\beta}} F_p(\vec{\gamma},\vec{\beta})
\tag{21}
$$

as the approximate optimal solution under a given number of layers $p$. So far, we have transformed the problem of searching for the quantum state $|\psi\rangle$ into a problem of searching for the parameters $\vec{\gamma},\vec{\beta}$. It is worth noting that the search space given by $p$ layers of $U_C, U_B$ is larger than that of $p-1$ layers, so

$$
M_p \geq M_{p-1}.
\tag{22}
$$

In fact, when $p$ is large enough,

$$
\lim_{p\to\infty} M_p = \max_z C(z).
\tag{23}
$$

### Decoding the quantum solution

In the previous paragraph, we simplify the search for the quantum state $|\psi\rangle$ into the search for the parameterized quantum state $|\vec{\gamma},\vec{\beta}\rangle$, but at the same time, we have also reduced the search space. That is to say, given the number of layers $p$, there may be no parameters $\vec{\gamma},\vec{\beta}$ such that $F_p(\vec{\gamma},\vec{\beta}) = C(z_\text{opt})$. Suppose that the parameters $\vec{\gamma}^*$ and $\vec{\beta}^*$ make $F_p$ the largest, that is, $F_p(\vec{\gamma}^*,\vec{\beta}^* ) = M_p$. Then under ideal circumstances, the quantum state $|\vec{\gamma}^*,\vec{\beta}^*\rangle$ contains the information of an optimal solution. But it should be noted that only $p$ layers are used here, so it is very likely that $M_p < C(z_\text{opt})$. Therefore, in general $|\vec{\gamma}^*,\vec{\beta}^*\rangle$ only contains information about an approximate optimal solution. Furthermore, we assume that the quantum state $|\vec{\gamma}^*,\vec{\beta}^*\rangle$ is the superposition state of $l$ computational basis state, namely

$$
|\vec{\gamma}^*,\vec{\beta}^*\rangle = c_1|z^{(1)}\rangle + \cdots + c_l|z^{(l)}\rangle.
\tag{24}
$$

Normally, the larger the probability $|c_j|^2$ that a state $|z^{(j)}\rangle$ is measured on the computational basis, the more likely that the corresponding bit string $z^{(j) }$ is an optimal solution. Thus, we prepare $|\vec{\gamma}^*,\vec{\beta}^*\rangle$ and measure it to get a bit string $z$ and calculate the value of $C(z)$. Repeat this process many times, and we can get a bit string $z$ that makes $C(z)$ close to or even exceed $M_p$.

### Adiabatic theorem

Why do we use the above method to construct the quantum state $|\vec{\gamma},\vec{\beta}\rangle$? QAOA tries to find an approximate optimal solution to an optimization problem. A similar algorithm is the quantum adiabatic algorithm [2] (QAA). The difference is that QAA is designed to find an optimal solution to the optimization problem rather than an approximate one. Similar to QAOA, QAA transforms an optimization problem into a problem of finding the ground state of a Hamiltonian, and uses the adiabatic theorem to solve it. Consider the Hamiltonian

$$
H(t) = (1-\frac tT)H_B + \frac tT H_C,
\tag{25}
$$

of a quantum system. At initial, time $t=0$, and the Hamiltonian of the system is $H(0) = H_B$. As time passes, the Hamiltonian of the system gradually changes from $H_B$ to $H_C$. When $t=T$, the Hamiltonian of the system becomes $H(T) = H_C$. The adiabatic theorem in quantum mechanics tells us that if the system is in an eigenstate of $H_B$ at the beginning, then as long as the evolution time $T$ is long enough, the system will be in the eigenstate of the corresponding energy level of $H_C$ when the Hamiltonian of the system completely evolves to $H_C$. Therefore, if the system is initially in $|s\rangle$, that is, the eigenstate with the largest eigenvalue of $H_B$, after a sufficiently long evolution time $T$, the quantum state of the system will become the eigenstate with the largest eigenvalue of $H_C$. [3]

One way to simulate the evolution of the Hamiltonian $H(t)$ over time $t$ is to alternately apply the unitary transformations $U_C(\gamma)$ and $U_B(\beta)$ on the quantum system, and the accuracy of simulation depends on the value of $\gamma,\beta$. In addition, in order for the evolution of the system to follow the adiabatic theorem, a sufficiently long evolution time is required, so the value of $p$ is required to be large enough. Therefore, combining equation (22) we can deduce the conclusion in equation (23).

_______

## References

[1] Farhi, E., Goldstone, J. & Gutmann, S. A Quantum Approximate Optimization Algorithm. [arXiv:1411.4028 (2014).](https://arxiv.org/abs/1411.4028)

[2] Farhi, E., Goldstone, J., Gutmann, S. & Sipser, M. Quantum computation by adiabatic evolution. [arXiv:quant-ph/0001106 (2000).](https://arxiv.org/abs/quant-ph/0001106)

[3] Duan, R. Quantum Adiabatic Theorem Revisited. [arXiv:2003.03063 (2020).](https://arxiv.org/abs/2003.03063)


# 07 Solution: Sine Series

* *Computational Physics*:  2.5

## Problem: Summing Series: sin(x)

Evaluate the $\sin$ function from its series representation
$$
\sin x = x - \frac{x^3}{3!} + \frac{x^5}{5!} - \frac{x^7}{7!} + \dots
$$

A naive algorithm is to sum the series up to the $N$th term:
$$
\sin x \approx \sum_{n=1}^N \frac{(-1)^{n-1} x^{2n-1}}{(2n - 1)!}
$$

Problems:

- How to decide when to stop summing?
- Division of large terms (overflows!)
- Powers and factorials are very expensive to compute.

Better approach: Build up series terms $a_n$ using previous term $a_{n-1}$ through a recursion:

\begin{align}
a_n &= a_{n-1} \times q_n\\
a_n &= \frac{(-1)^{n-1} x^{2n-1}}{(2n - 1)!} = \frac{(-1)^{n-2} x^{2n-3}}{(2n - 3)!} \frac{-x^2}{(2n - 1)(2n - 2)}\\
a_n & = a_{n-1} \frac{-x^2}{(2n - 1)(2n - 2)}
\end{align}

Accuracy of this approach? Not clear in absolute terms but we can make the assumption that the error is approximately the last term in the sum, $a_N$. Hence we can strive to make the relative error smaller than the machine precision
$$
\left| \frac{a_N}{\sum_{n=1}^N a_n} \right| < \epsilon_m
$$

```python
import numpy as np
```

```python
def sin_series(x, eps=1e-16):
    """Calculate sin(x) to precision eps with recursion.

    Arguments
    ---------
    x : float
        argument of sin(x)
    eps : float, optional
        desired precision

    Returns
    -------
    func_value, N

    where func_value = sin(x) and N is the number
    of terms included in the approximation.
    """
    if x == 0:
        return 0., 1
    a_n = sumN = x   # a_1 is the sum
    n = 2            # start with calculating a_2
    while abs(a_n/sumN) > eps:
        a_n *= -x*x/((2*n - 1)*(2*n - 2))
        sumN += a_n
        n += 1
    return sumN, n-1
```

Bonus problem: the really bad implementation:

```python
import math
def sin_series_bad(x, eps=1e-16):
    """Calculate sin(x) to precision eps... BADLY"""
    if x == 0:
        return 0., 1
    a_n = x
    n = 1
    sumN = 0.
    while True:
        a_n = (-1)**(n-1) * x**(2*n-1) / math.factorial(2*n-1)
        sumN += a_n
        if abs(a_n/sumN) <=  eps:
            break
        n += 1
    return sumN, n
```

```python
sin_series_bad(14*np.pi)
```

    (253.21339842367362, 73)

```python
sin_series(100*np.pi, eps=1e-16)
```

    (6.591899418779782e+118, 277)

Test the implementation against the "exact" numpy function `np.sin()`.

Report
1. `x`
2. maximum `n`
3. `sin_series(x)`
4. relative error `abs(sin_series(x) - sin(x))/abs(sin(x))`

Plot against `x` the quantities above and also `sin(x)`.
* `x` - `sin_series(x)`
* `x` - `sin(x)`
* `x` - max `n`
* `x` - relative error (semilogy plot!)

For a range of numbers look at `Xsmall` and `Xlarge`:

```python
Xsmall = np.pi*np.arange(-2, 2.05, 0.05)
Xlarge = np.pi*np.arange(-50, 50.05, 0.1)
```

Implementation (modularized compared to the problem so that I can use it for `sin_series()` and `sin_series_bad()`):

```python
import numpy as np
def test_sin(x):
    y, nmax = sin_series(x)
    return compare_to_sin(x, y, nmax)

def test_sin_bad(x):
    y, nmax = sin_series_bad(x)
    return compare_to_sin(x, y, nmax)

def compare_to_sin(x, y, nmax):
    y0 = np.sin(x)
    delta = y - y0
    if y0 != 0:
        relative_error = delta/y0
    else:
        relative_error = 0
    # print(x, y, y0, delta, relative_error)
    #return x, y, y0, delta, relative_error
    return x, nmax, y, relative_error, delta
```

The functions do not work as numpy ufuncs on whole arrays; therefore, loop over all input values $x$ like the following code:

```python
results = np.array([test_sin(x) for x in Xsmall])
```

Plot the approximation and the exact solution:

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

```python
def test_plot_sine(X, filename="sine_error.pdf"):
    results = np.array([test_sin(x) for x in X])
    return plot_results(results, filename)

def test_plot_bad_sine(X, filename="sine_bad_error.pdf"):
    results = np.array([test_sin_bad(x) for x in X])
    return plot_results(results, filename)

def plot_results(results, filename):
    fig = plt.figure(figsize=(6, 8))

    ax1 = fig.add_subplot(4,1,1)
    ax1.plot(results[:, 0], results[:, 2], 'k-', lw=1, label="series")
    ax1.plot(results[:, 0], np.sin(results[:, 0]), 'g--', lw=2, label="sin x")
    ax1.legend(loc="best")

    ax2 = fig.add_subplot(4,1,2)
    ax2.plot(results[:, 0], results[:, 1], label="max n")
    ax2.legend(loc="best")

    ax3 = fig.add_subplot(4,1,3)
    ax3.semilogy(results[:, 0], results[:, 3], label="rel.error")
    ax3.legend(loc="best")

    ax4 = fig.add_subplot(4, 1, 4)
    ax4.semilogy(results[:, 0], results[:, 4], label="abs.error")
    ax4.legend(loc="best")

    fig.suptitle("sine series approximation")

    fig.savefig(filename)
    print("saved to file {0}".format(filename))
```

Now test the two ranges of numbers and write to different files:

For a few oscillations near $x=0$ the approximation appears to hold pretty well with relative errors near machine precision.

```python
test_plot_sine(Xsmall, filename="sine_error_Xsmall.pdf")
```

But for larger $|x|$, the approximation breaks down and round-off error completely swamps the result:

```python
test_plot_sine(Xlarge, filename="sine_error_Xlarge.pdf")
```

#### More exploration

```python
test_plot_sine(np.pi*np.arange(-5, 5, 0.05))
```

```python
test_plot_sine(np.pi*np.arange(-10, 10, 0.05))
```

```python
test_plot_sine(np.pi*np.arange(-50, 50, 0.1))
```

```python

```


# Custom Models in pycalphad: Viscosity

## Viscosity Model Background

We are going to take a CALPHAD-based property model from the literature and use it to predict the viscosity of Al-Cu-Zr liquids.

For a binary alloy liquid under small undercooling, Gąsior suggested an entropy model of the form
$$\eta = (\sum_i x_i \eta_i ) (1 - 2\frac{S_{ex}}{R})$$

where $\eta_i$ is the viscosity of the element $i$, $x_i$ is the mole fraction, $S_{ex}$ is the excess entropy, and $R$ is the gas constant.

For more details on this model, see

1. M.E. Trybula, T. Gancarz, W. Gąsior, *Density, surface tension and viscosity of liquid binary Al-Zn and ternary Al-Li-Zn alloys*, Fluid Phase Equilibria 421 (2016) 39-48, [doi:10.1016/j.fluid.2016.03.013](http://dx.doi.org/10.1016/j.fluid.2016.03.013).

2. Władysław Gąsior, *Viscosity modeling of binary alloys: Comparative studies*, Calphad 44 (2014) 119-128, [doi:10.1016/j.calphad.2013.10.007](http://dx.doi.org/10.1016/j.calphad.2013.10.007).

3. Chenyang Zhou, Cuiping Guo, Changrong Li, Zhenmin Du, *Thermodynamic assessment of the phase equilibria and prediction of glass-forming ability of the Al–Cu–Zr system*, Journal of Non-Crystalline Solids 461 (2017) 47-60, [doi:10.1016/j.jnoncrysol.2016.09.031](https://doi.org/10.1016/j.jnoncrysol.2016.09.031).

```python
from pycalphad import Database
```

## TDB Parameters
We can calculate the excess entropy of the liquid using the Al-Cu-Zr thermodynamic database from Zhou et al.

We add three new parameters to describe the viscosity (in Pa-s) of the pure elements Al, Cu, and Zr:
```
   $ Viscosity test parameters
   PARAMETER ETA(LIQUID,AL;0) 2.98150E+02  +0.000281*EXP(12300/(8.3145*T));   6.00000E+03
  N REF:0 !
   PARAMETER ETA(LIQUID,CU;0) 2.98150E+02  +0.000657*EXP(21500/(8.3145*T));   6.00000E+03
  N REF:0 !
  PARAMETER ETA(LIQUID,ZR;0) 2.98150E+02  +4.74E-3 - 4.97E-6*(T-2128) ;   6.00000E+03
    N REF:0 !
```

Great! However, if we try to load the database now, we will get an error. This is because `ETA` parameters are not supported by default in pycalphad, so we need to tell pycalphad's TDB parser that "ETA" should be on the list of supported parameter types.

```python
dbf = Database('alcuzr-viscosity.tdb')
```

### Adding the `ETA` parameter to the TDB parser

```python
import pycalphad.io.tdb_keywords
pycalphad.io.tdb_keywords.TDB_PARAM_TYPES.append('ETA')
```

Now the database will load:

```python
dbf = Database('alcuzr-viscosity.tdb')
```

## Writing the Custom Viscosity Model

Now that we have our `ETA` parameters in the database, we need to write a `Model` class to tell pycalphad how to compute viscosity. All custom models are subclasses of the pycalphad `Model` class.

When the `ViscosityModel` is constructed, the `build_phase` method is run and we need to construct the viscosity model after doing all the other initialization using a new method `build_viscosity`. The implementation of `build_viscosity` needs to do four things:
1. Query the Database for all the `ETA` parameters
2. Compute their weighted sum
3. Compute the excess entropy of the liquid
4. Plug all the values into the Gąsior equation and return the result

Since the `build_phase` method sets the attribute `viscosity` to the `ViscosityModel`, we can access the property using `viscosity` as the output in pycalphad caluclations.

```python
from tinydb import where
import sympy
from pycalphad import Model, variables as v

class ViscosityModel(Model):
    def build_phase(self, dbe):
        super(ViscosityModel, self).build_phase(dbe)
        self.viscosity = self.build_viscosity(dbe)

    def build_viscosity(self, dbe):
        if self.phase_name != 'LIQUID':
            raise ValueError('Viscosity is only defined for LIQUID phase')
        phase = dbe.phases[self.phase_name]
        param_search = dbe.search
        # STEP 1
        eta_param_query = (
            (where('phase_name') == phase.name) & \
            (where('parameter_type') == 'ETA') & \
            (where('constituent_array').test(self._array_validity))
        )
        # STEP 2
        eta = self.redlich_kister_sum(phase, param_search, eta_param_query)
        # STEP 3
        excess_energy = self.GM - self.models['ref'] - self.models['idmix']
        #liquid_mod = Model(dbe, self.components, self.phase_name)
        ## we only want the excess contributions to the entropy
        #del liquid_mod.models['ref']
        #del liquid_mod.models['idmix']
        excess_entropy = -excess_energy.diff(v.T)
        ks = 2
        # STEP 4
        result = eta * (1 - ks * excess_entropy / v.R)
        self.eta = eta
        return result
```

## Performing Calculations

Now we can create an instance of `ViscosityModel` for the liquid phase using the `Database` object we created earlier. We can verify this model has a `viscosity` attribute containing a symbolic expression for the viscosity.

```python
mod = ViscosityModel(dbf, ['CU', 'ZR'], 'LIQUID')
print(mod.viscosity)
```

    (1 + 0.240543628600637*(LIQUID0CU*LIQUID0ZR*(75.3798 - 9.6125*log(T))*(LIQUID0CU - LIQUID0ZR) + LIQUID0CU*LIQUID0ZR*(105.895 - 13.6488*log(T))*(LIQUID0CU - LIQUID0ZR)**3 + LIQUID0CU*LIQUID0ZR*(392.8485 - 51.3121*log(T)) + LIQUID0CU*LIQUID0ZR*(LIQUID0CU - LIQUID0ZR)**2*(36.8512*log(T) - 270.5305))/(1.0*LIQUID0CU + 1.0*LIQUID0ZR))*(0.000657*LIQUID0CU*exp(2585.84400745685/T) + LIQUID0ZR*(0.01531616 - 4.97e-6*T))

Finally we calculate and plot the viscosity.

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

mod = ViscosityModel(dbf, ['CU', 'ZR'], 'LIQUID')

temp = 2100
# NOTICE: we need to tell pycalphad about our model for this phase
models = {'LIQUID': mod}
res = calculate(dbf, ['CU', 'ZR'], 'LIQUID', P=101325, T=temp, model=models, output='viscosity')

fig = plt.figure(figsize=(6,6))
ax = fig.gca()
ax.scatter(res.X.sel(component='ZR'), 1000 * res.viscosity.values)
ax.set_xlabel('X(ZR)')
ax.set_ylabel('Viscosity (mPa-s)')
ax.set_xlim((0,1))
ax.set_title('Viscosity at {}K'.format(temp));
```

We repeat the calculation for Al-Cu.

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

temp = 1300
models = {'LIQUID': ViscosityModel}  # we can also use Model class
res = calculate(dbf, ['CU', 'AL'], 'LIQUID', P=101325, T=temp, model=models, output='viscosity')

fig = plt.figure(figsize=(6,6))
ax = fig.gca()
ax.scatter(res.X.sel(component='CU'), 1000 * res.viscosity.values)
ax.set_xlabel('X(CU)')
ax.set_ylabel('Viscosity (mPa-s)')
ax.set_xlim((0,1))
ax.set_title('Viscosity at {}K'.format(temp));
```

```python

```


# Silvia Control
Evaluation od espresso machine control theory based upon [Control Bootcamp](https://www.youtube.com/playlist?list=PLMrJAkhIeNNR20Mz-VpzgfQs5zrYi085m) by Steve Brunton.

## Boundary DIagram

## System Dynamics

### Differential equations
The change of internal energy $\frac{dU}{dt}$ of an incompresssible substance is as follows where $c$ is the specific heat.

$$ \frac{dU}{dt} = m . c $$

Heat transfer $Q$ is:

$$ Q = \int_{0}^{\Delta{}T} \dot{Q} dt $$

The heat rate required to increase the boiler temperture is:

$$ \dot{Q}_{net} = c . m . \frac{dT_{boiler}}{dt} $$

The convection gain, $\dot{Q}_{conv}$  is:

$$ \dot{Q}_{conv} = h_{conv} . A . (T_{amb} - T_{boiler}) $$

The net heat input to the boiler, $\dot{Q}_{net}$ is:

$$ \dot{Q}_{net} = \dot{Q}_{heat} + \dot{Q}_{conv} $$

So differetial temperature equation is:
$$ \frac{dT_{boiler}}{dt} = \frac{u}{cm} + \frac{hA}{cm}(T_{amb} - T_{boiler}) $$

### Finite time equations

The heat required to increase the boiler temperture is:

$$ Q_{net} = c . m . (T_{boiler}^{(1)} - T_{boiler}^{(0)}) . \Delta{}t $$

The temperture of the boiler, $T_{boiler}$ after heating is therefore:
$$ T_{boiler}^{(1)} = \frac{ (\dot{Q}_{heat} + \dot{Q}_{conv}) . \Delta{}t }{ c . m } + T_{boiler}^{(0)} $$

$$ \Delta{}T_{boiler} = \frac{ (\dot{Q}_{heat} + h_{conv} . A . (T_{amb} - T_{boiler})) . \Delta{}t }{ c . m } $$

### Notes
The heat input $\dot{Q}_{heat}$ to the system equals the electrical power $P_{heat}$ of the element:
$$ \dot{Q}_{heat} = P_{heat} $$

### State
$$ \vec{x} = \begin{bmatrix} x_0 \end{bmatrix} = \begin{bmatrix} T \end{bmatrix} $$

### Control
Looking at the equations  on the form:
$$ \dot{x} = Ax + Bu $$

To account for the constant term, define new variable $z$:
$$ z = x + \alpha $$
where:
$$ \alpha = \frac{hA}{cm}T_{amb} $$

The A matrix defines the system:
$$ A = \begin{bmatrix} \frac{hA}{cm} \end{bmatrix} $$

The B matrix is the control:
$$ B = \begin{bmatrix} \frac{1}{cm} \end{bmatrix} $$

Total, equation:
$$ \dot{x} = -\frac{hA}{cm}T + \frac{1}{cm}u + \frac{hA}{cm}T_{amb} $$
$$ \dot{z} = \begin{bmatrix} -\frac{hA}{cm} \end{bmatrix} z + \begin{bmatrix} \frac{1}{cm} \end{bmatrix} u $$

## System Properties

|Property |Quantity |Reference |
|:-        |:-        |:-         |
|Boiler Mass |- $kg$ |
|Boiler Specific Heat Capacity |375 $\frac{J}{kg.K}$ |[Engineering Toolbox](https://www.engineeringtoolbox.com/specific-heat-capacity-d_391.html) |
|Water Mass |- $kg$ |
|Water Specific Heat Capacity |4182 $\frac{J}{kg.K}$ |[Engineering Toolbox](https://www.engineeringtoolbox.com/specific-heat-capacity-d_391.html) |
| Natural Convection Coefficient | 10 $\frac{W}{m^2.K}$ |

```python
import sympy as sp
from sympy.utilities.lambdify import lambdify
import numpy as np
import matplotlib.pyplot as plt
import control.matlab as ctrlm
import control as ctrl
from control import place
import slycot
from scipy import integrate

# State
T, dT = sp.symbols("T \dot{T}")
# Properties
m, c, h, T_amb, a = sp.symbols("m, c, h, T_amb, a")
# Control
u = sp.symbols("u")
```

## Define System Matricies
Define function for the temperature rate

```python
z = sp.Matrix([T - h * a * T_amb / (c * m) ])
u = sp.Matrix([u])
A = sp.Matrix([-h * a / (c * m)])
B = sp.Matrix([1 / (c * m)])
A
```

$\displaystyle \left[\begin{matrix}- \frac{a h}{c m}\end{matrix}\right]$

```python
T_rate = A*z + B*u
sp.Eq(dT, T_rate, evaluate=False)
```

$\displaystyle \dot{T} = \left[\begin{matrix}- \frac{a h \left(T - \frac{T_{amb} a h}{c m}\right)}{c m} + \frac{u}{c m}\end{matrix}\right]$

```python
r = 0.035
l = 0.09
area = 2 * 3.14 * r**2 + 2 * 3.14 * r * l

properties = {
    'm': 3,
    'c': 375,
    'h': 10,
    'T_amb': 20,
    'a': area
}
area
```

    0.027475000000000003

## State space representation

```python
# Redefine matricies in numpy form
A_np = np.array(A.subs(properties))
B_np = np.array(B.subs(properties))
C_np = np.array([1])
D_np = np.zeros((1))
A_np, B_np
```

    (array([[-0.000244222222222222]], dtype=object),
     array([[1/1125]], dtype=object))

```python
ss_model = ctrl.ss(A_np, B_np, C_np, D_np)
ss_model
```

    A = [[-0.00024422]]

    B = [[0.00088889]]

    C = [[1.]]

    D = [[0.]]

```python
def convert_x_z(data, output, properties):
    h = properties['h']
    a = properties['a']
    T_amb = properties['T_amb']
    m = properties['m']
    c = properties['c']
    if output == "x":
        return data + h * a * T_amb / (c * m)
    else:
        return data - h * a * T_amb / (c * m)
```

## Design LQR Controller

```python
Q = np.eye(A.shape[0])
R = 0.001
K = ctrl.lqr(ss_model, Q, R)[0]
K
```

    matrix([[31.34922014]])

```python
def heatMass(t, x, properties, uf):
    dx = np.empty([1])
    h = properties['h']
    a = properties['a']
    T_amb = properties['T_amb']
    m = properties['m']
    c = properties['c']
    u = uf(x)
    dx[0] = (u + h * a * (- x)) / (c * m)
    t_log.append(t)
    u_log.append(u.item(0))
    return dx
```

```python
t_lims = (0, 6000)
tspan = np.arange(t_lims[0], t_lims[1], 1)
z0 = np.array([convert_x_z(0, 'z', properties)])  # Start temperature
wr = np.array([100])  # Target temperature
# uf = lambda x: -K @ (x - wr)
uf = lambda x: x * 0 + 100

t_log = []
u_log = []
# x = integrate.odeint(heatMass, x0, tspan, args=(properties, uf))
sol = integrate.solve_ivp(heatMass, t_lims, z0, method="DOP853", args=(properties, uf), t_eval=tspan)
sol.message
```

    'The solver successfully reached the end of the integration interval.'

```python
plt.plot(sol.t, convert_x_z(sol.y[0], "x", properties), linewidth=2, label='T')
plt.scatter(t_log, u_log, linewidth=1, label='u', c="orange")
plt.legend()
plt.xlabel('Time (s)')
plt.ylabel('State')
```

```python
plt.scatter(t_log, u_log, linewidth=1, label='u')
plt.xlabel('Time (s)')
plt.ylabel('Heat')

plt.legend()
plt.show()
```

## Simulate with control library

```python
t_sample = 5
t_sim = np.arange(0, 6000, t_sample)
u_sim = t_sim * 0 + 100
t_step, y_step, x_step = ctrl.forced_response(ss_model, T=t_sim, U=u_sim, X0=z0)
plt.plot(t_step, convert_x_z(y_step, "x", properties), label='y')
plt.plot(t_step, convert_x_z(x_step, "x", properties), label='x')
plt.plot(t_step, u_sim, '--', label='u')
plt.legend()
plt.xlabel('Time (s)')
```

# Compare to measures response

```python
import pickle

with open('dataset_01.pickle', 'rb') as f:
    measured_data = pickle.load(f)

power = 1100  # W

plt.plot(measured_data["t"], measured_data["y"], label='y')
plt.plot(measured_data["t"], measured_data["u"], label='u')
plt.legend()
plt.xlabel('Time (s)')
plt.xlabel('Duty [%], Temperature [degC]')
```

## Simulate using measured input

```python
t_sim, y_sim, x_sim = ctrl.forced_response(
    ss_model, T=measured_data["t"], U=measured_data["u"]*power/100, X0=measured_data["y"][0]
)
plt.plot(measured_data["t"], measured_data["y"], label='y_measured')
plt.plot(measured_data["t"], measured_data["u"], label='u')
plt.plot(measured_data["t"], convert_x_z(y_sim, "x", properties), label='y_simulated')
plt.legend()
plt.xlabel('Time (s)')
```

Try changing the properties.

```python
properties = {
    'm': 2,
    'c': 50,
    'h': 3,
    'T_amb': 20,
    'a': 0.03
}
A_np = np.array(A.subs(properties))
B_np = np.array(B.subs(properties))
ss_model = ctrl.ss(A_np, B_np, C_np, D_np)
t_sim, y_sim, x_sim = ctrl.forced_response(
    ss_model, T=measured_data["t"], U=measured_data["u"], X0=measured_data["y"][0]
)
plt.plot(measured_data["t"], measured_data["y"], label='y_measured')
plt.plot(measured_data["t"], measured_data["u"], label='u')
plt.plot(measured_data["t"], convert_x_z(y_sim, "x", properties), label='y_simulated')
plt.legend()
plt.xlabel('Time (s)')
```

The delay in the response is not being modelled as the boiler is assumed to be at constant temperature.