```python
import numpy as np
from numpy.random import normal, uniform
from scipy.stats import multivariate_normal as mv_norm
from collections import OrderedDict
import matplotlib.pyplot as plt
from matplotlib import cm
from mpl_toolkits.mplot3d import Axes3D
from mpl_toolkits import mplot3d
%matplotlib inline
```

## Functions to Generate the Training and Test Datasets

####  Details of target function generation
The target function at each node is generated as follows:
$T = \mathbf{a}^T\phi(\mathbf{X}) + Z$, where

$\mathbf{X} = [X_1, X_2, \ldots, X_N]^T$ denotes the random data point,

$\phi(\mathbf{X}) = [1, X_1, X_2, \ldots, X_N]^T$ denotes the feature vector obtained from data point,

$\mathbf{a} = [a_0, a_1, \ldots, a_N]^T$ denotes the weight vector,

$Z$ denotes Gaussian noise with zero mean and $T$ denotes the target value.

For simplicity we assume $Z \sim \mathcal{N}(0, \beta^{-1})$, where $\beta$ denotes the precision. Hence the target values $T \sim \mathcal{N}(\mathbf{a}^T\phi(\mathbf{X}), \beta^{-1})$

Therefore the likelihood of $T = t$ given $\mathbf{X} = \mathbf{x}$ denoted by $p(t|\mathbf{x}, \mathbf{a})$ has the Gaussian distribution $\mathcal{N}(\mathbf{a}^T\phi(\mathbf{x}), \beta^{-1})$ whose likelihood is given by $G(t, \mathbf{a}^T\phi(\mathbf{x}), \beta^{-1})$

```python
# x_vec = [x1, x2, ... , xi] and xi is available to node i only
def real_function(a_vec, noise_sigma, X):

    N = X.shape[0]
    N_samples = X.shape[1]

    #Evaluates the real function
    f_value = a_vec[0]
    for i in range(0, N):
        f_value += a_vec[i+1]*X[i,:]
    if noise_sigma==0:
        # Recovers the true function
        return f_value
    else:
        return f_value + normal(0, noise_sigma, N_samples)
```

#### Details of data points generation across the network
Data point $\mathbf{X} = [X_1, X_2, \ldots, X_N]^T$ is an $N$ dimensional vector, where each $X_i \sim Unif[l_i, u_i]$.

```python
# generate training set for each node
def generate_training_set(l_vec, u_vec, a_vec, noise_sigma, N_samples):
    # generates N_samples copies of X which are uniformly distributed over [l,u]
    N = len(l_vec)
    X = np.zeros((N, N_samples), dtype=float)
    for i in range(0,N):
        X[i, :] = uniform(l_vec[i], u_vec[i], N_samples)
    # Evaluate the real function for training example inputs
    t = real_function(a_vec, noise_sigma, X)

    return X, t
```

## Training and Testing Procedure

### Training at each node without cooperation

We consider a network of $N$ nodes. We generate $N$ datasets network wide.

For node $i$:

Each node $i$'s local and private dataset is denoted by $\mathcal{D}_i = \{(\mathbf{X}_i^{(j)}, t^{(j)}), j \in \{1,2, \ldots, N_{0}\}\}$, where each $\mathbf{X}_i^{(j)}$ is an $N$ dimensional data point.

Using the given dataset $\mathcal{D}_i$ at node $i$, we want to able to predict $t$ given a new input $\mathbf{x}$, i.e, make a prediction based the following predictive distribution
\begin{align}
p(t|\mathbf{x}, \mathcal{D}_i)
\end{align}

The predictive distribution can be obtained as follows
\begin{align}
p(t|\mathbf{x}, \mathcal{D}_i) &= \int p(t, \mathbf{a}|\mathbf{x}, \mathcal{D}_i)d\mathbf{a}  \\
& = \int p(t|\mathbf{x}, \mathbf{a}, \mathcal{D}_i)p(\mathbf{a}|\mathcal{D}_i)d\mathbf{a}   \\
& = \int p(t|\mathbf{x}, \mathbf{a})p(\mathbf{a}|\mathcal{D}_i)d\mathbf{a}
\end{align}

We train each node using the dataset $\mathcal{D}_i$ to obtain $p(\mathbf{a}|\mathcal{D}_i)$. We obtain the posterior distribution on weight vector $\mathbf{a}$ is a Bayesian fashion, i.e., we start with a prior on $\mathbf{a}$ given by
\begin{align}
p(\mathbf{a}) = G(\mathbf{a}, \boldsymbol{\mu}_0, \boldsymbol{\Sigma}_0)
\end{align}

For simplicity we consider $\boldsymbol{\mu}_0 = 0$ and $\boldsymbol{\Sigma}_0 = \alpha^{-1}I$.

We update the posterior distribution on $\mathbf{a}$ in an online fashion or sequential fashion as we observe the data. Let $\boldsymbol{\mu}^{(k)}_i$ and $\boldsymbol{\Sigma}^{(k)}_i$ denote the mean and covariance matrix of the posterior distribution after observing $k$ samples from $\mathcal{D}_i$. Then, after observing $k+1$th point $(\mathbf{x}_i^{(k+1)}, t_i^{(k+1)})$ we use Bayes rule (for more details on Bayesian linear regression please refer to Bishop's treatment of the Bayesian approach to linear regression.) to obtain $\boldsymbol{\mu}^{(k+1)}_i$ and $\boldsymbol{\Sigma}^{(k+1)}_i$ as follows

\begin{align}
(\boldsymbol{\Sigma}^{(k+1)}_i)^{-1}
&= (\boldsymbol{\Sigma}^{(k)}_i)^{-1} + \beta \phi(\mathbf{x}_i^{(k+1)})^T\phi(\mathbf{x}_i^{(k+1)})
\\
\boldsymbol{\mu}^{(k+1)}_i
&= \boldsymbol{\Sigma}^{(k+1)}_i\left((\boldsymbol{\Sigma}^{(k)}_i)^{-1} \boldsymbol{\mu}_i^{(k)} + \beta \phi(\mathbf{x}_i^{(k+1)})^T t_i^{(k+1)} \right)
\end{align}

Update using the above equations until we have looped through the entire local datasets.

### Training at each node with peer-to-peer cooperation

Again we want to train each node using the dataset $\mathcal{D}_i$ and cooperation with neighbors in the graph given by social interaction matrix $\mathbf{W}$ to obtain $p^{(k)}(\mathbf{a})$ after each node has observed $k$ training samples.

We obtain the posterior distribution on weight vector $\mathbf{a}$ is a Bayesian fashion, i.e., we start with a prior on $\mathbf{a}$ given by
\begin{align}
p^{(0)}(\mathbf{a}) = G(\mathbf{a}, \boldsymbol{\mu}_0, \boldsymbol{\Sigma}_0)
\end{align}

For simplicity we consider $\boldsymbol{\mu}_0 = 0$ and $\boldsymbol{\Sigma}_0 = \alpha^{-1}I$.

$\underline{\text{Local Bayesian Update Step:}}$

We update the posterior distribution on $\mathbf{a}$ in an online fashion or sequential fashion as we observe the data. Let $\boldsymbol{\mu}^{(k)}_i$ and $\boldsymbol{\Sigma}^{(k)}_i$ denote the mean and covariance matrix of the posterior distribution after observing $k$ samples from $\mathcal{D}_i$. Then, after observing $k+1$th point $(\mathbf{x}_i^{(k+1)}, t_i^{(k+1)})$ we use Bayesian update to obtain $\boldsymbol{\mu}^{(k+1)}_i$ and $\boldsymbol{\Sigma}^{(k+1)}_i$ as follows

\begin{align}
(\boldsymbol{\Sigma}^{(k+1)}_i)^{-1}
&= (\boldsymbol{\Sigma}^{(k)}_i)^{-1} + \beta \phi(\mathbf{x}_i^{(k+1)})^T\phi(\mathbf{x}_i^{(k+1)})
\\
\boldsymbol{\mu}^{(k+1)}_i
&= \boldsymbol{\Sigma}^{(k+1)}_i\left((\boldsymbol{\Sigma}^{(k)}_i)^{-1} \boldsymbol{\mu}_i^{(k)} + \beta \phi(\mathbf{x}_i^{(k+1)})^T t_i^{(k+1)} \right)
\end{align}

$\underline{\text{Consensus Step:}}$

The merged covariance matrix $\overline{\boldsymbol{\Sigma}}^{(k+1)}_i$ for node $i$ is given as
\begin{align}
(\overline{\boldsymbol{\Sigma}}^{(k+1)}_i)^{-1}  =  \sum_{j = 1}^N W_{ij}(\boldsymbol{\Sigma}_j^{(k+1)})^{-1}.
\end{align}

The merged mean value for node $i$ is given as
\begin{align}
\overline{\boldsymbol{\mu}}^{(k+1)}_i =  \overline{\boldsymbol{\Sigma}}^{(k+1)}_i \sum_{j=1}^N W_{ij}(\boldsymbol{\Sigma}_j^{(k+1)})^{-1}\mu_j .
\end{align}

Update using the above equations until we have looped through the entire local datasets.

### Prediction on the test dataset at each node

The predictive distribution on plugging in the values gives us
\begin{align}
p(t| \mathbf{x}) &= \int p(t| \mathbf{x}, \mathbf{a}) p^{(N_0)}(\mathbf{a})d\mathbf{a}
\\
& = \int G(t, \mathbf{a}^T\phi(\mathbf{x}), \beta^{-1}) G(\mathbf{a}, \overline{\boldsymbol{\mu}}^{(N_0)}_i, \overline{\boldsymbol{\Sigma}}^{(N_0)}_i) d\mathbf{a}
\\
& = G(t, (\overline{\boldsymbol{\mu}}^{(N_0)}_i)^T\phi(\mathbf{x}), \overline{\boldsymbol{\Sigma}}^{\ast}_i),
\end{align}

where
\begin{align}
\overline{\boldsymbol{\Sigma}}^{\ast}_i = \beta^{-1} + \phi(\mathbf{x})^T\overline{\boldsymbol{\Sigma}}^{(N_0)}_i \phi(\mathbf{x})
\end{align}

## Initialize the Linear Bayes Class Object

#### Details of each node and its posterior distribution

Each node has access to $\mathbf{X}_i = [X_{1i}, X_{2i}, \ldots, X_{iN}]$ which an $N$ dimensional data point. However $\mathbf{X}_i \in \mathcal{X}_i \subset \mathbb{R}^N$, where $\mathcal{X}_i$ denotes the local data space.

```python
class LinearSeqBayes(object):
    """
    A class that holds parameter prior/posterior and handles
    the hyper-parameter updates with new data

    Note:  variables starting with "_vec" indicate Nx1 dimensional
        column vectors, those starting with "_mat" indicate
        matrices, and those starting with "_arr" indicate
        1xN dimensional arrays.

    Args:
        meam0_arr (np.array): prior mean vector of size 1xM
        covar0_mat (np.ndarray): prior covariance matrix of size MxM
        beta (float): known real-data noise precision

    """
    def __init__(self, mean0_arr, covar0_mat, beta):
        self.prior = mv_norm(mean=mean0_arr, cov=covar0_mat)
        self.meanPrev_vec = mean0_arr.reshape(mean0_arr.shape + (1,)) #reshape to column vector
        self.covarPrev_mat = covar0_mat
        self.beta = beta

        self.meanCurrent_vec = self.meanPrev_vec
        self.covarCurrent_mat = self.covarPrev_mat
        self.posterior = self.prior
        self.prediction = self.prior

    def get_phi_mat(self, X):
        N = X.shape[0]
        phi_mat = np.ones((X.shape[0]+1, X.shape[1]))
        for i in range(0,N):
            phi_mat[i,:] = X[i,:]
        return phi_mat

    def get_phi(self, x_vec):
        """
        Note that the other terms in x_vec are not from other nodes
        in the network. These are local N dimensional data points
        If some dimensions are not seen at node i they are set to zero
        """
        N = len(x_vec)
        phi_vec = np.ones((1, N+1))
        for i in range(0,N):
            phi_vec[:, i] = x_vec[i]
        return phi_vec

    def set_posterior(self, x_vec, t):
        """
        Updates current mean vec and covariance matrix given x and t value
        """
        phi_vec = self.get_phi(x_vec)

        self.covarCurrent_mat = np.linalg.inv(np.linalg.inv(self.covarPrev_mat) + self.beta*phi_vec.T.dot(phi_vec))
        self.meanCurrent_vec = self.covarCurrent_mat.dot(np.linalg.inv(self.covarPrev_mat).dot(self.meanPrev_vec)) + \
                                      self.covarCurrent_mat.dot(self.beta*phi_vec.T.dot(t))

        self.posterior = mv_norm(mean=self.meanCurrent_vec.flatten(), cov=self.covarCurrent_mat)

    def merge_PosteriorParams(self, W_vec, meanCurrent_dict, covarCurrent_mat_dict):

        N = len(W_vec)
        dummy_mean = np.zeros((N+1,1), dtype = float)
        dummy_covar =  np.zeros((N+1,N+1), dtype = float)

        for i in range(0,N):
            dummy_mean += np.linalg.inv(covarCurrent_mat_dict[i]).dot(meanCurrent_dict[i])*W_vec[i]
            dummy_covar +=  np.linalg.inv(covarCurrent_mat_dict[i])*W_vec[i]

        self.covarCurrent_mat = np.linalg.inv(dummy_covar)
        self.meanCurrent_vec = self.covarCurrent_mat.dot(dummy_mean)

    def update_prevPosteriorParams(self):
        # update the previous mean and covariance to new updated one using one sample (x_vec,t)
        self.covarPrev_mat = self.covarCurrent_mat
        self.meanPrev_vec = self.meanCurrent_vec

    def predict_test_set(self,X):
        N_samples = X.shape[1]
        x_mat = self.get_phi_mat(X)

        predictions = []
        for idx in range(0,N_samples):
            x = x_mat[:,idx]
            sig_sq_x = 1/self.beta + x.T.dot(self.covarCurrent_mat.dot(x))
            mean_x = self.meanCurrent_vec.T.dot(x)
            predictions.append(normal(mean_x.flatten(), np.sqrt(sig_sq_x)))
        return np.array(predictions)

    def compute_mse(self, t, predictions):
        N = len(t)
        err = np.array(t-predictions)
        err = np.square(err)
        return sum(err)/N

    def make_scatter(self, x1_arr, x2_arr, t_arr, real_parms, samples=None, stdevs=None):
        """
        A helper function to plot noisy data, the true function,
        and optionally a set of lines specified by the nested array of
        weights of size NxM where N is number of lines, M is 2 for
        this simple model
        """
        fig = plt.figure()
        ax = fig.add_subplot(111, projection='3d')

        ax.scatter(x1_arr, x2_arr, t_arr, alpha=0.5)
        ax.set_xlabel('x_1')
        ax.set_ylabel('x_2')
        ax.set_zlabel('t')

        x1, x2 = np.mgrid[-1:1:.01, -1.5:1.5:.01]
        x = np.stack((x1,x2))
        ax.plot_surface(x1, x2, real_function(a_vec, 0, x), cmap=cm.coolwarm)

        _ = plt.title('Real Data from Noisy Linear Function')
```

### Bayesian Linear Regression for single node

```python
# Real function parameters
N_train = 500

a_0 = -0.3
a_1 = 0.5
a_2 = 0.8
a_vec = np.array([a_0, a_1, a_2])

l1 = -1
u1 = 1
l2 = -1.5
u2 = 1.5

l_vec = np.array([l1, l2])
u_vec = np.array([u1, u2])

noise_sigma = 0.8
beta = 1/noise_sigma**2
# Generate input features from uniform distribution
np.random.seed(20) # Set the seed so we can get reproducible results

# generates N training samples
[X_train_mat, t_train_vec] = generate_training_set(l_vec, u_vec, a_vec, noise_sigma, N_train)
N_test = int(N_train/5)
[X_test_mat,t_test_vec] = generate_training_set(l_vec, u_vec, a_vec, noise_sigma, N_test)

mse_vec = np.zeros((N_train), dtype = float)
```

```python
alpha = 2.0
mean0_vec = np.array([0., 0., 0.])
covar0_mat = 1/alpha*np.identity(3)

linbayes = LinearSeqBayes(mean0_vec, covar0_mat, beta)
```

```python
linbayes.make_scatter(X_train_mat[0,:], X_train_mat[1,:], t_train_vec, real_parms = [a_0, a_1, a_2])
```

#### Main Training loop: Training averaged over multiple sample paths

```python
max_runs = 500

avg_mse_vec = np.zeros((N_train), dtype = float)

for t in range(0, max_runs):

    # generates N training samples
    [X_train_mat, t_train_vec] = generate_training_set(l_vec, u_vec, a_vec, noise_sigma, N_train)
    N_test = int(N_train/5)
    [X_test_mat, t_test_vec] = generate_training_set(l_vec, u_vec, a_vec, noise_sigma, N_test)

    mse_vec = np.zeros((N_train), dtype = float)

    alpha = 2.0
    mean0_vec = np.array([0., 0., 0.])
    covar0_mat = 1/alpha*np.identity(3)

    linbayes = LinearSeqBayes(mean0_vec, covar0_mat, beta)

    for n in range(0, N_train):
        linbayes.set_posterior(X_train_mat[:,n], t_train_vec[n])
        linbayes.update_prevPosteriorParams()
        predictions_vec = linbayes.predict_test_set(X_test_mat)
        mse_vec[n] = linbayes.compute_mse(t_test_vec, predictions_vec.flatten())

    avg_mse_vec += mse_vec

avg_mse_vec = avg_mse_vec/max_runs

avg_mse_vec_1node = avg_mse_vec
```

```python
plt.plot(np.linspace(0, N_train, num=N_train), avg_mse_vec_1node,'k', label='Mean Squared Error for Central Node')
plt.xlabel(r'Epoch', fontsize = 12)
plt.ylabel(r'MSE', fontsize = 12)
plt.legend()
plt.ylim([0.8, 3.2])
#plt.xlim([0,500])
plt.savefig('MSEVsIter_1node_LearningGlobal.eps', dpi = 450)
plt.show()
```

### Bayesian Linear Regression for two nodes without cooperation

```python
# Real function parameters
N_train = 500

a_0 = -0.3
a_1 = 0.5
a_2 = 0.5
a_vec = np.array([a_0, a_1, a_2])

l1 = -1
u1 = 1
l2 = -1.5
u2 = 1.5

l_vec = np.array([l1, l2])
u_vec = np.array([u1, u2])

l1_vec = np.array([l1, 0])
u1_vec = np.array([u1, 0])

l2_vec = np.array([0, l2])
u2_vec = np.array([0, u2])

noise_sigma = 0.8
beta = 1/noise_sigma**2
# Generate input features from uniform distribution
np.random.seed(20) # Set the seed so we can get reproducible results

# generates N training samples for node 1
[X1_train_mat, t1_train_vec] = generate_training_set(l1_vec, u1_vec, a_vec, noise_sigma, N_train)

# generates N training samples for node 2
[X2_train_mat, t2_train_vec] = generate_training_set(l2_vec, u2_vec, a_vec, noise_sigma, N_train)

# common test set
N_test = int(N_train/5)
[X_test_mat, t_test_vec] = generate_training_set(l_vec, u_vec, a_vec, noise_sigma, N_test)

mse_vec_node1 = np.zeros((N_train), dtype = float)
mse_vec_node2 = np.zeros((N_train), dtype = float)
```

```python
alpha = 2.0
mean0_vec = np.array([0., 0., 0.])
covar0_mat = 1/alpha*np.identity(3)

linbayes_node1 = LinearSeqBayes(mean0_vec, covar0_mat, beta)
linbayes_node2 = LinearSeqBayes(mean0_vec, covar0_mat, beta)
```

```python
linbayes_node1.make_scatter(X1_train_mat[0,:], X1_train_mat[1,:], t1_train_vec, real_parms = [a_0, a_1, a_2])
```

```python
linbayes_node2.make_scatter(X2_train_mat[0,:], X2_train_mat[1,:], t2_train_vec, real_parms = [a_0, a_1, a_2])
```

#### Main Training loop: Training averaged over multiple sample paths

```python
max_runs = 500

avg_mse_vec_node1 = np.zeros((N_train), dtype = float)
avg_mse_vec_node2 = np.zeros((N_train), dtype = float)

for t in range(0, max_runs):

    # generates N training samples for node 1
    [X1_train_mat, t1_train_vec] = generate_training_set(l1_vec, u1_vec, a_vec, noise_sigma, N_train)

    # generates N training samples for node 2
    [X2_train_mat, t2_train_vec] = generate_training_set(l2_vec, u2_vec, a_vec, noise_sigma, N_train)

    # common test set
    N_test = int(N_train/5)
    [X_test_mat, t_test_vec] = generate_training_set(l_vec, u_vec, a_vec, noise_sigma, N_test)

    mse_vec_node1 = np.zeros((N_train), dtype = float)
    mse_vec_node2 = np.zeros((N_train), dtype = float)

    alpha = 2.0
    mean0_vec = np.array([0., 0., 0.])
    covar0_mat = 1/alpha*np.identity(3)

    linbayes_node1 = LinearSeqBayes(mean0_vec, covar0_mat, beta)
    linbayes_node2 = LinearSeqBayes(mean0_vec, covar0_mat, beta)

    for n in range(0, N_train):
        linbayes_node1.set_posterior(X1_train_mat[:,n], t1_train_vec[n])
        linbayes_node1.update_prevPosteriorParams()
        predictions_vec_node1 = linbayes_node1.predict_test_set(X_test_mat)
        mse_vec_node1[n] = linbayes_node1.compute_mse(t_test_vec, predictions_vec_node1.flatten())

        linbayes_node2.set_posterior(X2_train_mat[:,n], t2_train_vec[n])
        linbayes_node2.update_prevPosteriorParams()
        predictions_vec_node2 = linbayes_node2.predict_test_set(X_test_mat)
        mse_vec_node2[n] = linbayes_node2.compute_mse(t_test_vec, predictions_vec_node2.flatten())

    avg_mse_vec_node1 += mse_vec_node1
    avg_mse_vec_node2 += mse_vec_node2

avg_mse_vec_node1 = avg_mse_vec_node1/max_runs
avg_mse_vec_node2 = avg_mse_vec_node2/max_runs

avg_mse_vec_node1_NoCoop = avg_mse_vec_node1
avg_mse_vec_node2_NoCoop = avg_mse_vec_node2
```

```python
mse_central, = plt.plot(np.linspace(0, N_train, num=N_train), 1.27821171*np.ones((N_train), dtype = float), linestyle= '--', color = [0, 0,0],label='Mean Squared Error at Central Node')

mse_node1, = plt.plot(np.linspace(0, N_train, num=N_train),  avg_mse_vec_node1_NoCoop, color = '#e41a1c',label='Mean Squared Error at Node 1')
mse_node2, = plt.plot(np.linspace(0, N_train, num=N_train),  avg_mse_vec_node2_NoCoop, color = '#377eb8', label='Mean Squared Error at Node 2')
plt.xlabel(r'Number of communication rounds', fontsize=12)
plt.ylabel(r'MSE', fontsize=12)
plt.legend(fontsize=12)
plt.ylim([0.8, 3.2])
plt.savefig('MSEVsIter_2nodes_LearningNoCooperation_centralNode.eps', dpi = 450)
plt.show()
```

### Bayesian Linear Regression for two nodes with cooperation

```python
# Real function parameters
N_train = 500
N = 2

W = np.array([np.array([0.9, 0.1]), np.array([0.6, 0.4])])

a_0 = -0.3
a_1 = 0.5
a_2 = 0.5
a_vec = np.array([a_0, a_1, a_2])

l1 = -1
u1 = 1
l2 = -1.5
u2 = 1.5

l_vec = np.array([l1, l2])
u_vec = np.array([u1, u2])

l1_vec = np.array([l1, 0])
u1_vec = np.array([u1, 0])

l2_vec = np.array([0, l2])
u2_vec = np.array([0, u2])

noise_sigma = 0.8
beta = 1/noise_sigma**2
# Generate input features from uniform distribution
np.random.seed(20) # Set the seed so we can get reproducible results
```

#### Main Training Loop: Training averaged over multiple sample paths

```python
max_runs = 500

avg_mse_vec_node1 = np.zeros((N_train), dtype = float)
avg_mse_vec_node2 = np.zeros((N_train), dtype = float)

for t in range(0, max_runs):

    # generates N training samples for node 1
    [X1_train_mat, t1_train_vec] = generate_training_set(l1_vec, u1_vec, a_vec, noise_sigma, N_train)

    # generates N training samples for node 2
    [X2_train_mat, t2_train_vec] = generate_training_set(l2_vec, u2_vec, a_vec, noise_sigma, N_train)

    # common test set
    N_test = int(N_train/5)
    [X_test_mat, t_test_vec] = generate_training_set(l_vec, u_vec, a_vec, noise_sigma, N_test)

    mse_vec_node1 = np.zeros((N_train), dtype = float)
    mse_vec_node2 = np.zeros((N_train), dtype = float)

    alpha = 2.0
    mean0_vec = np.array([0., 0., 0.])
    covar0_mat = 1/alpha*np.identity(3)

    linbayes_node1 = LinearSeqBayes(mean0_vec, covar0_mat, beta)
    linbayes_node2 = LinearSeqBayes(mean0_vec, covar0_mat, beta)

    for n in range(0, N_train):

        # perform local bayesian update at each node
        linbayes_node1.set_posterior(X1_train_mat[:,n], t1_train_vec[n])
        linbayes_node2.set_posterior(X2_train_mat[:,n], t2_train_vec[n])

        # initialize the dictionaries with current posterior parameters
        mean_dict[0] = linbayes_node1.meanCurrent_vec
        mean_dict[1] = linbayes_node2.meanCurrent_vec
        covar_mat_dict[0] = linbayes_node1.covarCurrent_mat
        covar_mat_dict[1] = linbayes_node2.covarCurrent_mat

        # perform the consensus step
        linbayes_node1.merge_PosteriorParams(W[0], mean_dict, covar_mat_dict)
        linbayes_node2.merge_PosteriorParams(W[1], mean_dict, covar_mat_dict)

        # update the local posteriors with merged posteriors
        linbayes_node1.update_prevPosteriorParams()
        linbayes_node2.update_prevPosteriorParams()

        # evaluate on the test dataset
        predictions_vec_node1 = linbayes_node1.predict_test_set(X_test_mat)
        mse_vec_node1[n] = linbayes_node1.compute_mse(t_test_vec, predictions_vec_node1.flatten())
        predictions_vec_node2 = linbayes_node2.predict_test_set(X_test_mat)
        mse_vec_node2[n] = linbayes_node2.compute_mse(t_test_vec, predictions_vec_node2.flatten())

    avg_mse_vec_node1 += mse_vec_node1
    avg_mse_vec_node2 += mse_vec_node2

avg_mse_vec_node1 = avg_mse_vec_node1/max_runs
avg_mse_vec_node2 = avg_mse_vec_node2/max_runs
```

```python

mse_central, = plt.plot(np.linspace(0, N_train, num=N_train), 1.27821171*np.ones((N_train), dtype = float), linestyle= '--', color = [0, 0,0],label='Mean Squared Error at Central Node')

mse_node1, = plt.plot(np.linspace(0, N_train, num=N_train), avg_mse_vec_node1, color = '#e41a1c', label='Mean Squared Error at Node 1')
mse_node2, = plt.plot(np.linspace(0, N_train, num=N_train), avg_mse_vec_node2, color = '#377eb8', label='Mean Squared Error at Node 2')

plt.xlabel(r'Number of communication rounds', fontsize=12)
plt.ylabel(r'MSE', fontsize=12)
plt.legend(fontsize=12)
plt.ylim([0.8, 3.2])
plt.savefig('MSEVsIter_2nodes_LearningWithCoop_centralNode.eps', dpi = 450)
plt.show()
```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```


$\textbf{HERA Memo A. Primary Beam Simulation}$

$\textbf{Introduction}$

Modeling antenna primary beam of drift-scan interferometer is one of chief challanges in 21-cm cosmological observations. However, recent promising developments of modeling primary beam have come through CST simulation, using a drone & holographic beam mapping, satelite and through visibility data by tracking of single primary (citatio). In this work, we consider three basic beam models; Gaussian beam, Airy disk beam and beam model based on Geometric Optics.

references
https://www.researchgate.net/publication/335028403_Electrical_and_electromagnetic_co-simulations_of_the_HERA_Phase_I_receiver_system_including_the_effects_of_mutual_coupling_and_impact_on_the_EoR_window/figures?lo=1
https://www.jstor.org/stable/10.1086/683467?seq=1#metadata_info_tab_contents
https://www.researchgate.net/publication/304965023_Holographic_Beam_Mapping_of_the_CHIME_Pathfinder_Array

$\textbf{Section. 1.1 : Gaussian Primary Beam Model}$

T
The simplest primary beam model is Gaussain beam, it a good approximation of main lobe. It is a good enough to capture antenna-to-antenna variation due to pointing errors and spectral variation of ful l-width-half-maximum. A two dimensional Gaussian beam centered at ($\theta_c,\phi_c$) and   measure at angular co-ordinate $(\theta,\phi)$
$$
\begin{equation}
        \textit{A}(\theta,\phi,\nu)= \frac{1}{\sqrt{2\pi((\sigma(\nu) + \varepsilon_{\sigma}(\nu))^2}}\textit{e}^{-\frac{(\theta - (\theta_c + \varepsilon_{\theta}))^2 + (\phi+ -(\phi_c\varepsilon_{\phi}))^2}{2(\sigma(\nu) + \varepsilon_{\sigma}(\nu))^2}}
\end{equation}
$$
where $\varepsilon_{\theta}$ and $\varepsilon_{\phi}$ are pointing errors, and $\varepsilon_{\sigma})$ is the beam size/shape error and, is given by
$$
\begin{equation}
\varepsilon_{\sigma}(\nu) = \kappa\sigma(\nu)
\end{equation}
$$
where $\kappa$ is fractional error of the full-width-half-maximum . $\sigma$ is approximated from FWHM of ariy disk,
$$
\begin{equation}
        \sigma \approx 0.44\frac{\lambda}{\textit{D}}
\end{equation}
$$
. Here $\textit{D}$ dish diameter.

reference

https://courses.lumenlearning.com/austincc-physics2/chapter/27-6-limits-of-resolution-the-rayleigh-criterion/

https://stackoverflow.com/questions/31573572/healpy-from-data-to-healpix-map

```python
%run beam_simulator.py
%matplotlib inline
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
import time
import get_ants_response
from scipy.integrate import quad as qd
from scipy import special as sp

```

```python

```

```python
#1d Gaussain with and without beam size and point errors

freq_mid = 150e6
RA_0,DEC_0 = 21.41100,-30.72100
d_diam =14.0
sigma_fwhm = sigma_func(d_diam,freq_mid)

theta = np.arange(-np.deg2rad(90),np.deg2rad(90),0.01)
gpbeam = [gaussian_beam(0.0,0.0,theta[i],0.0,sigma_fwhm,0.0,0.0,0.0) for i in range(theta.size)]

plt.figure(figsize=(10,10))
plt.title(r'D=14.0 m,$\nu$ =150 MHz, Phase Center $=0^o$ ')
plt.plot(np.rad2deg(theta),gpbeam)
plt.xlabel(r'$\theta$ [deg]')
plt.ylabel('Power Beam')

```

```python

```

```python
theta, phi = np.arange(- np.deg2rad(90),np.deg2rad(90),0.01),np.arange(-np.deg2rad(90),np.deg2rad(90),0.01)
gaussian_pbeam =np.zeros((len(phi),len(theta)),dtype=np.float32)
for phi_i in range(len(phi)):
    for theta_j in range(len(theta)):

        #print theta_j,phi_i, gaussin_2d[phi_i][theta_j],gaussian_beam(theta[theta_j],phi[phi_i],sigma_fwhm,0.0,0.0,0.0)

        gaussian_pbeam[phi_i][theta_j] = gaussian_beam(0.0,0.0,theta[theta_j],phi[phi_i],sigma_fwhm,0.0,0.0,0.0)

```

```python
extent =[np.rad2deg(theta[0]),np.rad2deg(theta[-1]),np.rad2deg(phi[-1]),np.rad2deg(phi[0])]

plt.figure(figsize=(10,10))
plt.imshow(gaussian_pbeam,extent=extent)
plt.xlabel('Co-latitude [Deg]')
plt.ylabel('Azimuth [Deg]')
plt.colorbar(label='Power Beam')
plt.show()

plt.figure(figsize=(10,10))
plt.imshow(gaussian_pbeam,extent=extent, norm=LogNorm())
plt.xlabel('Co-latitude [Deg]')
plt.ylabel('Azimuth [Deg]')
plt.colorbar(label='Power Beam')
plt.show()
```

$\textbf{Section. 1.2 : Airy Disk Primary Beam Model}$

If define the size of feed cage as $\textit{d}$, parabolic dish reflector with $\textit{D}$, and $\lambda$ as observing wavelength, then assuming a circular aperture with uniform illumination; the Airy disk beam model  centered at origin, measured as function of co-latitude and a frequency $\nu$ is
$$
\begin{equation}
        \textit{A}(\theta,\nu)= \frac{2\textit{J}_1(\textit{k}\textit{a}\sin(\theta)) − \textit{b}\textit{J}_1(\textit{k}\textit{b}\textit{a}\sin(\theta)}{\textit{a}\sin(\theta )(1 − \textit{b}^2 )}
\end{equation}
$$
where $\textit{k} = 2\pi\nu/\textit{c}$ and $\textit{a}=\textit{D}/2$ is the beam radius [1]. $\textit{J}_1$ is Bessel function of first order and  $\textit{b} = \textit{d}/\textit{D}$ is a blocking factor.

Adopting the paramitization in Orezs el at. 2018, the air disk model becomes
$$
\begin{equation}
        \textit{A}(\theta,\phi,\nu)=\Big( \frac{2\Big[\textit{J}_1(\text{k}\sqrt{\textit{a}_{\theta}^2(\theta - (\theta_{\textrm{c}}+\varepsilon_{\theta}))^2 + \textit{a}_{\phi}^2(\phi-(\phi_{\textrm{c}} + \varepsilon_{\phi}))^2}) − \textit{b}\textit{J}_1(\textit{b}\text{k}\sqrt{\textit{a}_{\theta}^2(\theta - (\theta_{\textrm{c}}+\varepsilon_{\theta}))^2 + \textit{a}_{\phi}^2(\phi-(\phi_{\textrm{c}} + \varepsilon_{\phi}))^2}\Big]}{(1 − \textit{b}^2 )\text{k}\sqrt{\textit{a}_{\theta}^2(\theta - (\theta_{\textrm{c}}+\varepsilon_{\theta}))^2 + \textit{a}_{\phi}^2(\phi-(\phi_{\textrm{c}} + \varepsilon_{\phi}))^2}}\Big)^2
\end{equation}
$$
where $\textit{a}_{\theta}$ and $\textit{a}_{phi}$ are major and semi-major parameters of eliptical airy disk drawn from a Gaussian distribution with mean $\textit{a}$ and centimeter level standard deviation. $\theta$ and $\phi$ angular co-ordinate of a 2D airy disk centered at $(\theta_{\textit{c}},\phi_{\textit{c}})$, and $\varepsilon_{\theta}$ and $ \varepsilon_{\phi}$ are pointing errors.

reference

[1] Wilson Rohlfs Hultemeister. ’Tools of Radio Astronomy (5th Ed)’.

[2] https://arxiv.org/pdf/1809.09728.pdf

```python
#1d airy disk for phi =0 deg

airy_disk_1d = [param_airydisk(0.0,0.0,theta[theta_i],0.0,a_x,a_y,q_0x,q_0y,d_diam,0.5,freq_mid) for theta_i in range(theta.size)]

plt.figure(figsize=(10,10))
plt.title(r'D=14.0 m,$\nu$ =150 MHz, Phase Center $=0^o$ ')
plt.plot(np.rad2deg(theta),airy_disk_1d)
plt.xlabel(r'$\theta$[deg]')
plt.ylabel('Power Beam')

```

```python
#parametized airydisk

a_x = 6.0 #np.random.normal(6.0,0.01)
a_y = 6.0 #np.random.normal(6.0,0.01)
q_0x,q_0y = 0.0, 0.0
d_block = 0.5
param_2d_airydisk = np.zeros((len(phi),len(theta)))

for phi_i in range(phi.size):
    for theta_i in range(theta.size):

        param_2d_airydisk[phi_i][theta_i] = param_airydisk(0.0,0.0,theta[theta_i],phi[phi_i],a_x,a_y,q_0x,q_0y,d_diam,d_block,freq_mid)

extent =[np.rad2deg(theta[0]),np.rad2deg(theta[-1]),np.rad2deg(phi[-1]),np.rad2deg(phi[0])]
plt.figure(figsize=(10,10))
plt.imshow(param_2d_airydisk,extent=extent)
plt.xlabel('Co-latitude [Deg]')
plt.ylabel('Azimuth [Deg]')
plt.colorbar(label='Power Beam')
plt.show()

plt.figure(figsize=(10,10))
plt.imshow(param_2d_airydisk,extent=extent, norm=LogNorm(),)
plt.xlabel('Co-latitude [Deg]')
plt.ylabel('Azimuth [Deg]')
plt.colorbar(label='Power Beam')
plt.show()

```

$\textbf{Section. 1.3 : Geometrical Optics Beam Model}$

Suppose a parabolic dish is place at the $\textrm{xz-plane}$ such that the vertex of the dish coincide with the $\textrm{xz-plane}$ origin, with a focus length $\textit{h}_{\textrm{feed}}$ above the origin and the shape of the dish is give by $\textit{z}= \frac{\textit{x}^2}{4\textit{h}_{\textrm{feed}}}$,see Figure below.

The schematic diagram depict the HERA-like antenna element in $\textrm{xz-plane}$]{The schematic diagram depict the HERA-like antenna element in $\textrm{xz-plane}$ with light ray coming from a radio source at far-field distance $\mathbf{h}$, striking the dish surface at $(\textit{x},\textit{z})$ and bouncing to the feed at height $\textit{h}_{\textrm{feed}}$ above the dish vertex

The total path traveled by a light ray from height $\mathbf{h}_{\textrm{far}}$ (far-field distance $|\mathbf{h}_{\textrm{far}}|=2\frac{\textit{D}^2}{\lambda}$) straight above the dish, reflecting from the dish surface at point $\textit{P}(\textit{x},\textit{z})$ to focus at $\textit{F}(0,\textit{h}_{\textrm{feed}})$ is

$$
\begin{equation}
    \textit{L}_{\textrm{Total}}(\textit{x},\textit{z},\theta,\Delta \textit{h}_{\textrm{feed}})= \textit{x}\sin(\theta) -\textit{z}\cos(\theta) + |\mathbf{h}_{\textrm{far}}| + \sqrt{ \textit{x}^2 +(\Delta \textit{h}_{\textrm{feed}} -\textit{z})^2}
\end{equation}
$$
where $\Delta \textit{h}_{\textrm{feed}} =\sqrt{\delta \textit{x}^2 +(\textit{h}_{\textrm{feed}}-\delta \textit{z})^2}$
. The instrumental angular response model for antenna $\textit{a}$ with a dish diameter $\textit{D}$ observing at frequency $\nu$ is
$$
\begin{equation}
    \textit{A}_{\textit{a}}(\theta,\nu,\Delta \textit{h}_{\textrm{feed}}) = \frac{|\textit{E}_{\textit{a}}(\theta,\nu,\Delta \textit{h}_{\textrm{feed}})|^2}{\textit{Z}_0}
\end{equation}
$$
where is characteristic impedance of free space, $\textit{Z}_0=377$ Ohms .The electric field intensity measured at co-altitude angle $\theta$ is

$$
\begin{equation}
    \textit{E}(\theta,\nu,\Delta \textit{h}_{\textrm{feed}})=  \int_{-\textit{D}/2}^{\textit{D}/2}\textit{E}_{\textrm{ds}}(\textit{x},\textit{z},\nu) \textit{e}^{j\psi(\textit{x},\textit{z},\theta,\Delta \textit{h}_{\textrm{feed}})}\sqrt{1+ \Big(\frac{\textit{x}}{4\textit{h}_{\textrm{feed}}}\Big)^2}d\textit{x}
\end{equation}
$$
where

$$
\begin{equation}
    \psi = 2\pi\frac{\textit{L}_{\textrm{Total}}(\textit{x},\textit{z},\theta,\Delta \textit{h}_{\textrm{feed}})}{\lambda}
\end{equation}
$$
$\textit{E}_{\textrm{ds}}(\textit{x},\textit{z},\nu) =\textit{D}(\textit{x},\textit{z})\textit{E}_{\textit{src}}(\nu)$ is the product of the $\textit{D}(\textit{x},\textit{z})$ dish surface roughness function and $\textit{E}_{\textit{src}}(\nu)$ is the amplitude of radio source coming from direction $\theta$ and is related to the radio source flux density by
$$
\begin{equation}
    \textit{E}_{\textit{src}}(\nu) = \sqrt{\textit{c}\mu_0 \textit{F}_{\nu}}
\end{equation}
$$

here $\textit{c}$ the speed of light in vacuum and $\mu_0$ is the permeability of free space. $\textit{F}_{\nu}$ is sum of all radio sources contributions from location $\theta$ in the sky. Assuming that the dish surface is symmetric about $\textit{z}$-axis, and rotating the about $\textit{z}$-axis for full azimuth angle $\phi$, then the instrumental angular response is

$$
\begin{equation}
     \textit{A}_{\textit{a}}(\theta,\phi,\nu,\Delta \textit{h}_{\textrm{feed}})= \textit{A}_{\textit{a}}(\theta,\nu,\Delta \textit{h}_{\textrm{feed}})\textit{e}^{j\phi}
\end{equation}
$$

```python
h_feed = 4.48 # HERA feed height in meters
freq= 150  #mid frequency in MHz
dx =0.0 #lateral positional displacement in meters
dy =0.0 # axial positional displacemt in meters

beam_zenith = [geomtrical_optics_beam_model(theta[theta_i],d_diam,h_feed,freq,dy,dx) for theta_i in range(theta.size)] #plot of  normalize power pattern in dB

plt.figure(figsize=(10,10))
plt.plot(np.rad2deg(theta),beam_zenith)
plt.xlabel(r'$\theta$[deg]')
plt.ylabel('Power Pattern')

```

$\textbf{Comparing Beam Models}$

```python

theta = np.arange(-np.deg2rad(25),np.deg2rad(25),0.001)

airy_disk_12 = [param_airydisk(0.0,0.0,theta[theta_i],0.0,6.0,6.0,q_0x,q_0y,12.0,0.0,150e6) for theta_i in range(theta.size)]
airy_disk_14 = [param_airydisk(0.0,0.0,theta[theta_i],0.0,7.0,7.0,q_0x,q_0y,14.0,0.0,350e6) for theta_i in range(theta.size)]

sigma_fwhm_12 = sigma_func(12.0,150e6)
sigma_fwhm_14 = sigma_func(14.0,350e6)
gpbeam_12 = [gaussian_beam(0.0,0.0,theta[i],0.0,sigma_fwhm_12,0.0,0.0,0.0) for i in range(theta.size)]
gpbeam_14 = [gaussian_beam(0.0,0.0,theta[i],0.0,sigma_fwhm_14,0.0,0.0,0.0) for i in range(theta.size)]

#plot all beam model
plt.figure(figsize=(10,10))
plt.plot(np.rad2deg(theta),gpbeam_12,label='v=150 MHZ,D=12.0m,Gaussian Beam')
plt.plot(np.rad2deg(theta),airy_disk_12,label='v=150 MHZ,D=12.0m,Air-disk Beam')
plt.plot(np.rad2deg(theta),gpbeam_14,label='v=350 MHZ,D=14.0m,Gaussian Beam')
plt.plot(np.rad2deg(theta),airy_disk_14,label='v=350 MHZ,D=14.0m,Air-disk Beam')
#plt.plot(np.rad2deg(theta),beam_zenith, label ='Geo-Optics Beam')
plt.xlabel(r'$\theta$ [degree]')
plt.ylabel('Power Beam')
plt.legend(loc='best')

```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```


# Linear Cascaded Control

**NOTE** - completing this exercise without looking at the solution could take a few hours. Be prepared and good luck!

In this lesson we will be working with a simplified model of the 2D drone you worked with in the first lesson. We will be directly setting the vehicle's collective thrust through $u_1$ and the moment about the x axis, $M_x$ through $u_2$. We will ignore propeller rotation rates as well as the yaw-inducing moment $M_z$.

The state of this 2D drone can be described by the following vector:

$$X = [z , y, \phi, \dot{z}, \dot{y},\dot{\phi}]$$

```python
%matplotlib inline
%config InlineBackend.figure_format = 'retina'

%load_ext autoreload
%autoreload 2

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

import trajectories2
import simulate
import plotting2

pylab.rcParams['figure.figsize'] = 10, 10
```

#### TODO 1 - Review Simplified `Drone2D` class
Walk through this code to refamiliarize youself with how state is represented, how controls are set, and what the underlying dynamics are.

```python
class Drone2D:
    """
    Simulates the dynamics of a drone confined to
    motion in the y-z plane.
    """
    def __init__(self,
                 I_x = 0.1, # moment of inertia around the x-axis
                 m = 0.2,   # mass of the vehicle
                ):

        self.I_x = I_x
        self.m = m

        self.u1 = 0.0 # collective thrust
        self.u2 = 0.0 # moment about the x axis
        self.g = 9.81

        # z, y, phi, z_dot, y_dot, phi_dot
        self.X = np.array([0.0,0.0,0.0,0.0,0.0,0.0])

    @property
    def y_dot_dot(self):
        phi = self.X[2]
        return self.u1 / self.m * np.sin(phi)

    @property
    def z_dot_dot(self):
        phi = self.X[2]
        return self.g - self.u1*np.cos(phi)/self.m

    @property
    def phi_dot_dot(self):
        return self.u2 / self.I_x

    def advance_state(self, dt):

        X_dot = np.array([self.X[3],
                        self.X[4],
                        self.X[5],
                        self.z_dot_dot,
                        self.y_dot_dot,
                        self.phi_dot_dot])

        # Change in state will be
        self.X = self.X + X_dot * dt
        return self.X

    def set_controls(self, u1, u2):
        self.u1 = u1
        self.u2 = u2
```

### Linear Cascading Controller

In this section, you will create the controller described in class. The schematic description of the cascading controller looks like this:

The controller architecture uses three **PD** controllers to control the drone (note that you will not need an I term for the controllers used in this exercise).

One PD controller is used to generate the thrust and control the altitude while two PD controllers are used to control the lateral motion. The first receives the desired lateral position information containing $y$ , $\dot{y}$ and (optionally) $\ddot{y}$. Using this information as well as the current vehicle state, it outputs the commanded $\phi$ roll angle.

The second PD controller is used to set a torque value $M_x$ to achieve the desired angle. In most cases, the inner controller responsible for roll angle operates at a higher frequency than the ones that are used to control the position.

#### TODO - Implement 2D controller with LINEAR control equations.

The linear equations of motion are shown below.

$$\begin{align}
\ddot{z} &= g - \frac{u_1}{m}
\\
\\
\ddot{y} &= g \phi
\\
\\
\ddot{\phi} &= \frac{u_2}{I_x}
\end{align}$$

These equations can be solved for $u_1$, $\phi_{\text{command}}$, and $u_2$.

$$\begin{align}
u_1 &= m(g - \bar{u_1})
\\
\\
\phi_{\text{command}} &= \frac{\ddot{y}_{\text{target}}}{g}
\\
\\
u_2 &= I_x \bar{u}_2
\end{align}$$

The first equation will be useful when implementing `altitude_controller`, the second when implementing `lateral_controller`, and the third when implementing `attitude_controller`.

Note that $\ddot{y}_{\text{target}}$ is like $\bar{u}_1$ or $\bar{u}_2$. It comes from PD control on the controller's inputs.

```python
class LinearCascadingController:

    def __init__(self,
                 m,   # needed to convert u1_bar to u1
                 I_x, # needed to convert u2_bar to u2
                 z_k_p=1.0,
                 z_k_d=1.0,
                 y_k_p=1.0,
                 y_k_d=1.0,
                 phi_k_p=1.0,
                 phi_k_d=1.0):

        self.z_k_p = z_k_p
        self.z_k_d = z_k_d
        self.y_k_p = y_k_p
        self.y_k_d = y_k_d
        self.phi_k_p = phi_k_p
        self.phi_k_d = phi_k_d

        self.g = 9.81
        self.I_x = I_x
        self.m = m

    def altitude_controller(self,
                    z_target,
                    z_actual,
                    z_dot_target,
                    z_dot_actual,
                    z_dot_dot_target,
                    phi_actual, # unused parameter. Ignore for now.
                    ):
        """
        A PD controller which commands a thrust (u_1)
        for the vehicle.
        """

        # TODO (recommended to do AFTER attitude)
        #   Implement feedforward PD control to calculate
        #   u_1_bar and then use the linear math from above
        #   to transform u_1_bar into u_1 and then return u_1
        e = z_target - z_actual
        e_dot = z_dot_target - z_dot_actual
        u_1_bar = self.z_k_p * e \
                + self.z_k_d * e_dot \
                + z_dot_dot_target
        u_1 = self.m * (self.g - u_1_bar)

        return u_1

    def lateral_controller(self,
                        y_target,
                        y_actual,
                        y_dot_target,
                        y_dot_actual,
                        u_1=None, # unused parameter. Ignore for now.
                        y_dot_dot_ff=0.0,
                        ):
        """
        A PD controller which commands a target roll
        angle (phi_commanded).
        """

        # TODO (recommended to do AFTER attitude)
        #   Implement feedforward PD control to calculate
        #   y_dot_dot_target and then use the linear math from above
        #   to transform y_dot_dot_target into phi_commanded
        #   and then return phi_commanded
        e = y_target - y_actual
        e_dot = y_dot_target - y_dot_actual
        y_dot_dot_target = self.y_k_p * e \
                         + self.y_k_d * e_dot \
                         + y_dot_dot_ff
        phi_commanded = y_dot_dot_target / self.g
        return phi_commanded

    def attitude_controller(self,
                            phi_target,
                            phi_actual,
                            phi_dot_actual,
                            phi_dot_target=0.0
                           ):
        """
        A PD controller which commands a moment (u_2)
        about the x axis for the vehicle.
        """

        # TODO (recommended to do FIRST)
        #   Implement PD control to calculate u_2_bar
        #   and then use the linear math from above to
        #   transform u_2_bar into u_2 and then return u_2
        e = phi_target - phi_actual
        e_dot = phi_dot_target - phi_dot_actual
        u_2_bar = self.phi_k_p * e \
                + self.phi_k_d * e_dot
        u_2 = self.I_x * u_2_bar
        return u_2
```

The flight path we'll use to test our controller is a figure 8 described as follows:
$$
\begin{align}
z & = a_z \sin{\omega_z t} \\
y & = a_y \cos{\omega_y t}
\end{align}
$$

where $\omega_y = \omega_z / 2$.

> NOTE - you can find the code that generates this trajectory in the file called `trajectories.py`, which you can access by clicking on the Jupyter logo in the top left corner of this notebook.

```python
# TESTING CELL
#
# Note - this cell will only give nice output when your code
#  is working AND you've tuned your parameters correctly.
#  you might find it helpful to come up with a strategy
#  for first testing the inner loop controller and THEN
#  testing the outer loop.
#
# Run this cell when you think your controller is ready!
#
# You'll have to tune the controller gains to get good results.

#### CONTROLLER GAINS (TUNE THESE) ######

z_k_p   = 0.1
z_k_d   = 1.0
y_k_p   = 1.0
y_k_d   = 1.0
phi_k_p = 100.0
phi_k_d = 10.0

#########################################

drone = Drone2D()

# INSTANTIATE CONTROLLER
linear_controller = LinearCascadingController(
    drone.m,
    drone.I_x,
    z_k_p=z_k_p,
    z_k_d=z_k_d,
    y_k_p=y_k_p,
    y_k_d=y_k_d,
    phi_k_p=phi_k_p,
    phi_k_d=phi_k_d
)

# TRAJECTORY PARAMETERS (you don't need to change these)
total_time = 100.0
omega_z = 1.0       # angular frequency of figure 8

# GENERATE FIGURE 8
z_traj, y_traj, t = trajectories2.figure_8(omega_z, total_time, dt=0.02)
z_path, z_dot_path, z_dot_dot_path = z_traj
y_path, y_dot_path, y_dot_dot_path = y_traj

# SIMULATE MOTION
linear_history     = simulate.zy_flight(z_traj,
                                        y_traj,
                                        t,
                                        linear_controller,
                                        inner_loop_speed_up=10)
# PLOT RESULTS
plotting2.plot_zy_flight_path(z_path, y_path, linear_history)
```

If everything is working correctly you should see a blue figure 8 that (nearly) overlaps with a red figure 8.

[Solution](/notebooks/Linear%20Controller%20Solution.ipynb)

```python

```


# Almgren and Chriss Model For Optimal Execution of Portfolio Transactions

### Introduction

We consider the execution of portfolio transactions with the aim of minimizing a combination of risk and transaction costs arising from permanent and temporary market impact. As an example, assume that you have a certain number of stocks that you want to sell within a given time frame. If you place this sell order directly to the market as it is, transaction costs may rise due to temporary market impact. On the other hand, if you split up into pieces in time, cost may rise due to volatility in the stock price.

[Almgren and Chriss](https://cims.nyu.edu/~almgren/papers/optliq.pdf) provided a solution to this problem by assuming the permanent and temporary market impact functions are linear functions of the rate of trading, and that stock prices follow a discrete arithmetic random walk.

In this notebook, we will take a look at the model used by Almgren and Chriss to solve the optimal liquidation problem. We will start by stating the formal definitions of *trading trajectory*, *trading list*, and *trading strategy* for liquidating a single stock.

### Trading Trajectory, Trading List, and Trading Strategy

We define trading trajectory, trading list, and trading strategy just as Almgren and Chriss did in their [paper](https://cims.nyu.edu/~almgren/papers/optliq.pdf). Suppose we hold $X$ shares of a stock that we want to liquidate before time $T$. Divide $T$ into $N$ intervals of length $\tau=\frac{T}{N}$ and define:

- $t_k = k\tau$ to be discrete times, where $k = 0,..,N$.

- A **trading trajectory** to be the list $(x_0,..,x_N)$, where $x_k$ is the number of shares we plan to hold at time $t_k$. We require that our initial position $x_0 = X$, and that at liquidation time $T$, $x_N = 0$.

- A **trading list** to be $(n_1,..,n_N)$, $n_k =  x_{k-1} - x_k$ as the number of shares that we will sell between times $t_{k-1}$ and $t_k$.

- A **trading strategy** as a rule for determining $n_k$ from the information available at time $t_{k-1}$.

Below, we can see a visual example of a trading trajectory, for $N = 12$.

## Price Dynamics

We will assume that the stock price evolves according to a discrete arithmetic random walk:

\begin{equation}
S_k = S_{k-1} + \sigma \tau^{1/2} \xi_k
\end{equation}

for $k = 1,..,N$ and where:

\begin{equation}
S_k = \text{ stock price at time $k$}\hspace{21.6cm}\\
\sigma = \text{ standard deviation of the fluctuations in stock price}\hspace{16.3cm}\\
\tau = \text{ length of discrete time interval}\hspace{20.2cm}\\
\xi_k = \text{ draws from independent random variables}\hspace{17.8cm}
\end{equation}

We will denote the initial stock price as $S_0$. The role of $\xi_k$ is to simulate random price fluctuations using random numbers drawn from a Normal Gaussian distribution with zero mean and unit variance. The code below shows us what this price model looks like, for an initial stock price of $S_0 =\$50$ dollars, a standard deviation of price fluctuations of $\sigma = 0.379$, and a discrete time interval of $\tau = 1$.

```python
%matplotlib inline

import matplotlib.pyplot as plt

import utils

# Set the default figure size
plt.rcParams['figure.figsize'] = [17.0, 7.0]

# Set the number of days to follow the stock price
n_days = 60

# Plot the stock price as a function of time
utils.plot_price_model(seed = 1, num_days = n_days)
```

## Market Impact

As we learned previously the price of a stock is affected by market impact that occurs every time we sell a stock. In their model, Almgren and Chriss distinguish between two types of market impact, permanent and temporary market impact. We will now add these two factors into our price model.

### Permanent Impact

Permanent market impact refers to changes in the equilibrium price of a stock as a direct function of our trading. Permanent market impact is called *permanent* because its effect persists for the entire liquidation period, $T$. We will denote the permanent price impact as $g(v)$, and will add it to our price model:

\begin{equation}
S_k = S_{k-1} + \sigma \tau^{1/2} \xi_k - \tau g\left(\frac{n_k}{\tau}\right)
\end{equation}

Here, we assumed the permanent impact function, $g(v)$, is a linear function of the trading rate, $v = n_k / \tau$. We will take $g(v)$ to have the form:

\begin{equation}
g(v) = \gamma \left(\frac{n_k}{\tau}\right)
\end{equation}

where $\gamma$ is a constant and has units of ($\$$/$share^2$). Replacing this in the above equation we get:

\begin{equation}
S_k = S_{k-1} + \sigma \tau^{1/2} \xi_k - \gamma n_k
\end{equation}

With this form, we can see that for each $n$ shares that we sell, we will depress the stock price permanently by $n\gamma$, regardless of the time we take to sell the stocks.

### Temporary Impact

Temporary market impact refers to temporary imbalances in supply and demand caused by our trading. This leads to temporary price movements away from equilibrium. Temporary market impact is called *temporary* because its effect
dissipates by the next trading period. We will denote the temporary price impact as $h(v)$. Given this, the actual stock price at time $k$ is given by:

\begin{equation}
\tilde{S_k} = S_{k-1} - h\left(\frac{n_k}{\tau}\right)
\end{equation}

Where, we have again assumed the temporary impact function, $h(v)$, is a linear function of the trading rate, $v = n_k / \tau$. We will take $h(v)$ to have the form:

\begin{equation}
h(v) = \epsilon \mbox{ sign}(n_k) + \eta \left(\frac{n_k}{\tau}\right)
\end{equation}

where $\epsilon$ and $\eta$ are constants with units (\$/share) and (\$ time/share${}^2$), respectively. It is important to note that $h(v)$ does not affect the price $S_k$.

## Capture

We define the **Capture** to be the total profits resulting from trading along a particular trading trajectory, upon completion of all trades. We can compute the capture via:

\begin{equation}
\sum\limits_{k=1}^{N} n_k \tilde{S_k} = X S_0 + \sum\limits_{k=1}^{N} \left(\sigma \tau^{1/2} \xi_k - \tau g\left(\frac{n_k}{\tau}\right)\right) x_k - \sum\limits_{k=1}^{N} n_k h\left(\frac{n_k}{\tau}\right)
\end{equation}

As we can see this is the sum of the product of the number of shares $n_k$ that we sell in each time interval, times the effective price per share $\tilde{S_k}$ received on that sale.

## Implementation Shortfall

We define the **Implementation Shortfall** as the total cost of trading and is given by:

\begin{equation}
I_s = X S_0 - \sum_{k = 1}^N n_k \tilde{S_k}
\end{equation}

This is what we seek to minimize when determining the best trading strategy!

Note that since $\xi_k$ is random, so is the implementation shortfall. Therefore, we have to frame the minimization problem in terms of the expectation value of the shortfall and its corresponding variance. We'll refer to $E(x)$ as the expected shortfall and $V(x)$ as the variance of the shortfall. Simplifying the above equation for $I_s$, is easy to see that:

\begin{equation}
E(x) = \sum\limits_{k=1}^{N} \tau x_k g\left(\frac{n_k}{\tau}\right) + \sum\limits_{k=1}^{N} n_k h\left(\frac{n_k}{\tau}\right)
\end{equation}

and

\begin{equation}
V(x) = \sigma^2 \sum\limits_{k=1}^{N} \tau {x_k}^2
\end{equation}

The units of $E(x)$ are dollars and the units of $V(x)$ are dollars squared. So now, we can reframe our minimization problem in terms of $E(x)$ and $V(x)$.
For a given level of variance of shortfall, $V(x)$, we seek to minimize the expectation of shortfall, $E(x)$. In the next section we will see how to solve this problem.

## Utility Function

Our goal now is to find the strategy that has the minimum expected shortfall $E(x)$ for a given maximum level of variance $V(x) \ge 0$. This constrained optimization problem can be solved by introducing a Lagrange multiplier $\lambda$. Therefore, our problem reduces to finding the trading strategy that minimizes the **Utility Function** $U(x)$:

\begin{equation}
U(x) = E(x) + \lambda V(x)
\end{equation}

The parameter $\lambda$ is referred to as **trader’s risk aversion** and controls how much we penalize the variance relative to the expected shortfall.

The intuition of this utility function can be thought of as follows. Consider a stock which exhibits high price volatility and thus a high risk of price movement away from the equilibrium price. A risk averse trader would prefer to trade a large portion of the volume immediately, causing a known price impact, rather than risk trading in small increments at successively adverse prices. Alternatively, if the price is expected to be stable over the liquidation period, the trader would rather split the trade into smaller sizes to avoid price impact. This trade-off between speed of execution and risk of price movement is ultimately what governs the structure of the resulting trade list.

# Optimal Trading Strategy

Almgren and Chriss solved the above problem and showed that for each value
of risk aversion there is a uniquely determined optimal execution strategy. The details of their derivation is discussed in their [paper](https://cims.nyu.edu/~almgren/papers/optliq.pdf). Here, we will just state the general solution.

The optimal trajectory is given by:

\begin{equation}
x_j = \frac{\sinh \left( \kappa \left( T-t_j\right)\right)}{ \sinh (\kappa T)}X, \hspace{1cm}\text{ for } j=0,...,N
\end{equation}

and the associated trading list:

\begin{equation}
n_j = \frac{2 \sinh \left(\frac{1}{2} \kappa \tau \right)}{ \sinh \left(\kappa T\right) } \cosh \left(\kappa \left(T - t_{j-\frac{1}{2}}\right)\right) X, \hspace{1cm}\text{ for } j=1,...,N
\end{equation}

where $t_{j-1/2} = (j-\frac{1}{2}) \tau$. The expected shortfall and variance of the optimal trading strategy are given by:

In the above equations $\kappa$ is given by:

\begin{align*}
&\kappa = \frac{1}{\tau}\cosh^{-1}\left(\frac{\tau^2}{2}\tilde{\kappa}^2 + 1\right)
\end{align*}

where:

\begin{align*}
&\tilde{\kappa}^2 = \frac{\lambda \sigma^2}{\tilde{\eta}} = \frac{\lambda \sigma^2}{\eta \left(1-\frac{\gamma \tau}{2 \eta}\right)}
\end{align*}

```python

```

DRL-for-Optimal-Execution-of-Portfolio-Transactions/Almgren and Chriss Model.ipynb


Out-of-the-box outside-of-the-box thinking with

BraketLab

---

Developed for the ODD-seminar at the CCSE by Audun Skau Hansen ✉ at the Department of Chemistry, The Hylleraas Centre for Quantum Molecular Sciences, University of Oslo (2021)

```python
from IPython.display import IFrame
IFrame("https://uio.padlet.org/audunskauhansen/xswuiajgknxn414z", width = 1000, height= 800)
```

Quantum theory is riddled with abstract and complex topics, which for many students takes some time to digest. Familiar themes from linear algebra may be obfuscated by unfamiliar notation, and enforcing the rules and logic of quantum mechanics requires the students to actively recollect and understand theory.

BraketLab is a tool for accelerated learning of quantum theory. It provides an algebraic framework where the notation and logic of quantum mechanics are automatically enforced, thus allowing students to build intuition by means of exploration. With an object-oriented focus and operator overloading, the abstract concepts of quantum mechanics can be made more concrete and tangible, expressed in a language familiar to students competent in programming. It may not be the path into quantum theory for everyone, but in many ways it opens new perspectives, thus making the content more accessible to a larger audience.

This Notebook will take you on a tour through most of the functionality of BraketLab as of december 2021. Let's begin by importing our required modules:

```python
import numpy as np # Numpy for numerics
import sympy as sp # We need sympy for defining our functions
import matplotlib.pyplot as plt # Matplotlib for visualization

import braketlab as bk # <- pip install braketlab (if you don't have it)

sp.init_printing() # <- pretty display of mathematical expressions
%matplotlib notebook
```

## Bra-ket notation

Quantum mechanics is commonly expressed in Bra-Ket notation, often named Dirac notation, after Paul Dirac who introduced the formalism in a surprisingly short paper back in 1939. This notation allows for a very compact and convenient mathematical treatment of quantum theory.

It is built around the concept of a Hilbert space, in which a certain linear vector space is combined with a specific inner product. This space is spanned by so-called kets:
\begin{equation}
\vert a \rangle = \sum_{i=1}^3 \vert u_i \rangle a_i,
\end{equation}
which, in fact, is the same thing as a vector
\begin{equation}
\mathbf{a}
  =
  \begin{pmatrix}
    a_1 \\
    a_2 \\
    a_3
  \end{pmatrix}
  =
  \begin{pmatrix}
    1 \\
    0 \\
    0
  \end{pmatrix}
  a_1
  +
  \begin{pmatrix}
    0 \\
    1 \\
    0
  \end{pmatrix}
  a_2
  +
  \begin{pmatrix}
    0 \\
    0 \\
    1
  \end{pmatrix}
  a_3
  =
  \mathbf{u}_1a_1 + \mathbf{u}_2a_2 + \mathbf{u}_3a_3
  =
  \sum_{i=1}^3 \mathbf{u}_ia_i
\end{equation}
A feature of the kets that many may find unfamiliar is that they can be constructed from functions.

Where you would normally have Euclidean vectors such as:

```python
a = bk.ket([1.0,2], name = "a") # <- just like np.array([1.0, 2])
b = bk.ket([0,-2], name = "b")
bk.show(a,b) # <- visualize
```

the kets allows you to define something like

```python
x = sp.symbols("x") # <- symbolic 'x' from sympy

a = bk.ket( x*sp.exp(-x**2), name = "a" )
b = bk.ket( sp.exp(-2*x**2), name = "b" )
bk.show(a,b)
```

Of course, the functions can be of arbitrary dimensionality, such as two:

```python
x,y = sp.symbols("x y")
a = bk.ket( x*sp.exp(-.2*(x**2 + y**2)), name = "a") # A 2D ket
bk.show(a)
```

...or five (which is hard to visualize):

```python
x,y,z,p,q = sp.symbols("x y z p q")

a = bk.ket( sp.exp(-(2*x**2 + y**2 + z**2 + p**2 + q**2)) )
```

You may furthermore evaluate these functions in points:

```python
a(.1,0,2,.1,.1)
```

...or over ranges and grids:

```python
a(np.linspace(-1,1,100), 0,0,0,0)
```

(which in this case corresponds to computing the integral $\int_{\mathbb{R}^n} \delta(\mathbf{r}-\mathbf{r}') a(\mathbf{r}') d\mathbf{r}'$)

## Bra-ket algebra

Just as you can do all sorts of algebra on Euclidean vectors:

```python
a = bk.ket([ 2,4], name = "a")
b = bk.ket([-2,3], name = "b")

bk.show(a, b, a-b, a+b)
```

the same can be done to the more abstract kets:

```python
a = bk.ket( y*sp.exp(-2*(x**2 + y**2))/(1+.1*x**2) )
b = bk.ket( .1*(y**2+x**2)*sp.sin(x**2)*sp.exp(-.5*y**2 -.5*x**2))

bk.show(.1*a+2*b)
```

You are probably familiar with the inner product most commonly associated with Euclidean vectors, the dot-product:

```python
a = bk.ket([1,0])
b = bk.ket([0,1])

a.bra@b # <- dot product (these are orthogonal so should be zero)
```

...but you may not be used with the notation used here. Let's have a look. For any ket:

```python
a = bk.ket(y*sp.exp(-2*(x**2 + y**2))/(1+.1*x**2), name = "a")
a
```

we may define a bra:

```python
a.bra
```

Whenever a bra runs into a ket on its right, they form a so called bra-ket - the inner product:

```python
a.bra@a
```

The inner product is thus a mapping of two kets onto a complex number:
\begin{equation}
\langle a \vert b \rangle \in \mathbb{C}.
\end{equation}

Hilbert space is spanned by all square normalizeable functions in $\mathbb{R}^3$,
\begin{equation}
\{f_n\} \hspace{1cm} f_n \in L^2,
\end{equation}
provided the inner product
\begin{equation}
\langle f \vert g \rangle := \int_{\mathbb{R}^3} f(\mathbf{r})^*g(\mathbf{r}) d\mathbf{r}.
\end{equation}

## Orthonormality - normality and orthogonality

Quantum mechanics deals with distributions evolving in time according to a wave equation. As would be the case with most waves, the distributions will then at some point oscillate between positive and negative values, thus calling for a different probability interpretation than the one we normally use.

Rather than having the distribution $P$ normalized to one over $\mathbb{R}^3$:
\begin{equation}
\int_{\mathbb{R}^3} P(\mathbf{x}) d\mathbf{x} = 1,
\end{equation}
we require our wavefunction, represented by $\vert \Psi \rangle$, to be square-normalized to one, meaning that

\begin{equation}
\langle \Psi \vert \Psi \rangle = 1
\end{equation}

Using BraketLab, we satistfy this condition by
\begin{equation}
\vert \Psi \rangle_{normalized} = \frac{1}{ \sqrt{\langle \Psi \vert \Psi \rangle} }\vert \Psi \rangle
\end{equation}
in the following way:

```python
psi = bk.ket( 2*sp.cos(2*x) * sp.exp(-.2*x**2), name = "$\\psi$" ) # <- some ket in Hilbert space

psi_normalized = (psi.bra@psi)**-.5*psi #normalization

psi_normalized.__name__ = "$\\psi_n$"
bk.show(psi, psi_normalized)
print("<psi   | psi  > = ", psi.bra@psi)
print("<psi_n | psi_n> = ", psi_normalized.bra@psi_normalized)
```

Being orthogonal to another function does not neccessarily mean that they do not have regions where they are both non-zero:

```python
psi_a = bk.ket( sp.cos(4*x) * sp.exp(-2*x**2), name = "$\\psi_a$" )
psi_b = bk.ket( sp.sin(3*x) * sp.exp(-2*x**2), name = "$\\psi_b$" )

bk.show(psi_a, psi_b)

print("<psi_a | psi_b > = ", psi_a.bra@psi_b, "(orthogonal)")
```

yet, we call integrals like the one above overlap integrals. You may be shocked to learn that quantum chemistry is more often than not carried out in a non-orthogonal basis where these are non-zero.

Behind the scenes

Inner products may be numerically estimated using a range of methods depending on the basis and the dimensionality.

For 1D integrals, BraketLab in general utilize quadrature methods. In higher dimensions, the integrals are computed using a combination of zero-variance Monte-Carlo methods [1], including (1) importance sampling, (2) interpolated control variates and (3) coordinate transforms. Repeated calculations of identical quantities is avoided by using the lru_cache decorator.

In the long run, BraketLab will have basis-specific integrators, analytical and numerical, with fallback to multipurpose numerical methods where required.

[1] Caffarel, M. (2019). Evaluating two-electron-repulsion integrals over arbitrary orbitals using zero variance Monte Carlo: Application to full configuration interaction calculations with Slater-type orbitals. The Journal of Chemical Physics, 151(6), 064101.

## Outer products and operators

Kets can be combined in many ways. For instance, the product of two kets is a new ket in a space often called Liouville space:

```python
psi_a = bk.ket( sp.exp(-.1*x**2), name = "a")
psi_b = bk.ket( x*sp.exp(-.2*x**2), name = "b")
bk.show(psi_a, psi_b)

ab = psi_a@psi_b
ab
```

```python
bk.show(ab)
```

```python
ab.ket_sympy_expression
```

In addition to familiar operations such as addition, subtraction, division and multiplication, kets may be acted upon by several other operators. You'll encounter such an operator if you happen to multiply a ket with a bra to its right, the so-called mapping operator:

```python
a = bk.ket( sp.exp(-4*x**2), name = "a")
b = bk.ket( x*sp.exp(-6*x**2), name = "b")
a@b.bra
```

The trace of the mapping operator is called a projection operator:

```python
bk.trace(a@b.bra)
```

Operators in general act on kets, resulting in new kets:
\begin{equation}
\hat{\Omega} \vert a \rangle = \vert b \rangle
\end{equation}

Many other operators are of interest in quantum mechanics, such as translation, differentiation, multiplication by a variable.

Certain operators called Hermitian operators represent observable quantities in quantum mechanics. Operators are thus a cruical part of the algebra og quantum theory. Several operators are available in BraketLab, let's look at some of them:

```python
psi = bk.ket( sp.exp(-4*(x+3)**2))

T = bk.get_translation_operator(np.array([2.1]))

# Apply the translation operator one, two and three times
Tpsi = T*psi
TTpsi = T*T*psi
TTTpsi = T*T*T*psi

bk.show(psi, Tpsi, TTpsi, TTTpsi)
```

```python

a = bk.ket( sp.exp(-x**2), name = "a(x)")

D = bk.get_differential_operator(order = [1])

Da = D*a
Da.__name__ = "$\\frac{d}{dx} a(x)$"

bk.show(a, Da)
```

It generalizes to many dimensions:

```python

a = bk.ket( x*sp.exp(-(x**2 + y**2)**.5))

D = bk.get_differential_operator(order = [1,1])

D2a = D*a

bk.show(a)
bk.show(D2a)
```

The Hamiltonian operator of the electronic Hydrogen-equation is the Hermitian operator representing the energy of the Hydrogen system. It consists of the potential energy term:

```python
V = bk.onebody_coulomb_operator()
V
```

and the kinetic term

```python
T = bk.kinetic_operator()
T
```

Together, they describe the total energy of the electron in the Hydrogen atom, which in textbooks is introduced as the time-independent Schrödinger equation. Operators, like matrices, have eigenfunctions and eigenvalues. For Hermitian operators, the eigenfunctions and eigenvalues constitute the possible outcomes of measurements.

The eigenfunctions of the Hydrogen-atom are the so-called spherical harmonics multiplied with a radial factor:

```python
n,l,m = 1,0,0

psi_0 = bk.basisbank.get_hydrogen_function(n,l,m)
psi_0.get_ket_sympy_expression()
```

We can of course verify that they are eigenfunctions, in which case they should be proportional to eachother:

```python
x_,y_,z_ = np.random.uniform(-1,1,(3,30))

Hpsi_0 = V*psi_0 + T*psi_0

Hpsi_0(x_,y_,z_)/psi_0(x_,y_,z_)
```

...and the factor of proportionality is the eigenvalue. The eigenvalue is in this case the ground state energy, confirmed by computing $\langle H \rangle = \langle \psi_0 \vert \hat{H} \vert \psi_0 \rangle $

```python
print("E_0 = %.2f Hartrees" % (psi_0.bra@(V*psi_0) + psi_0.bra@(T*psi_0)))
```

Other functions does not satisfy this proportionality for Hydrogen, albeit the energy may not be too far off:

```python
x,y,z = sp.symbols("x y z")

psi_trial = bk.ket( sp.exp(-.5*(x**2 +y**2 + z**2)) )
psi_trial = (psi_trial.bra@psi_trial)**-.5*psi_trial #normalize
print("E_0 = %.2f Hartrees" % (psi_trial.bra@(V*psi_0) + psi_trial.bra@(T*psi_0)))

Hpsi_trial = V*psi_trial + T*psi_trial

Hpsi_trial(x_,y_,z_)/psi_trial(x_,y_,z_)
```

Behind the scenes

With BraketLab, you'll surf through Hilbert-space on the joint power of sympy and numpy. Analytical derivation is easily performed with sympy, whereby integrals are computed numerically. In this sense, BraketLab is simply a wrapping of this two excellent modules into a framework very similar to what you would find in a book on quantum theory.

Eigenfunctions of system-specific Hamiltonians are generally called stationary states, and while their mathematical formulations may be intimidating, we mostly look them up in tables (or generate them on the fly) when we need them. For Hydrogen, these are:

```python
Nt = 200
t = np.linspace(-1,1,Nt)*15
for n in range(1,4):
    for l in range(n):
        for m in range(-l, l+1):
            psi_nlm = bk.basisbank.get_hydrogen_function(n,l,m)
            px = psi_nlm(t[:, None],t[None, :], .0*np.random.uniform(-1,1,(Nt,Nt)))
            p = (np.conjugate(px)*px).real
            plt.figure(figsize = (3,3))
            plt.imshow(p)
            plt.title("n,l,m=%i,%i,%i" % (n,l,m))
            plt.show()
```

## Superposition

With a complete basis, we may resolve the identity of any function in this basis by projection.

For instance, the quantum harmonic oscillator has eigenfunctions in the shape of sinusoids enveloped by gaussians:

```python
bk.show( bk.basisbank.get_harmonic_oscillator_function(0),
         bk.basisbank.get_harmonic_oscillator_function(1),
         bk.basisbank.get_harmonic_oscillator_function(2),
         bk.basisbank.get_harmonic_oscillator_function(3),
       )
```

If we conjure up another function, not present in this basis and slightly offset from the basis centre:

```python
psi_sto = bk.ket( sp.exp( - sp.sqrt((x-1)**2) ) , name = "sto")
bk.show(psi_sto)
```

...the Harmonic oscillator eigenbasis is complete, meaning that with sufficiently many functions, any other L2-function can be represented as a linear combination / superposition of states by projection:

```python
p = bk.basisbank.get_harmonic_oscillator_function(0)
for i in range(1,15):
    p += bk.basisbank.get_harmonic_oscillator_function(i)

P = bk.trace(p@p.bra) # <- form a projection operator

psi_ho = P*psi_sto #<- project psi_sto onto the HO-basis

bk.show(psi_sto, psi_ho) #<- compare the original to the projection
```

(Notice, however, how poorly the gaussians manage to capture the pointy peak, or "cusp" of the exponential function. This is a well-known issue which has stuck with quantum chemistry ever since the 1950s when Boys introduced Gaussian type orbitals in place of Slater type orbitals, which in turn was introduced in the 1930s in order to avoid complications with the radial part of the hydrogen-solution.)

## Time evolution

As time progresses, the function will evolve as a wave in the potential of the harmonic oscillator. With the known eigenbasis and eigenvalues of the Harmonic Oscillator, we may now watch what happens as time starts to flow:

```python
psi_ho.run(dt = 0.02)
```

What happens here is that the full solution evolves according to:
\begin{equation}
\vert \Psi(x,t) \rangle = \sum_n \vert \psi_n(x) \rangle c_n e^{-i\frac{E_n}{\hbar}t},
\end{equation}
as we expect for a time-independent potential.

## Measurement

Measuring stuff in quantum mechanics consists of projecting the wavefunction into the basis of the eigenfunctions of the Hermitian operator being observed:
\begin{equation}
\vert \Psi \rangle_{\Omega} = \big{(} \sum_i \vert i \rangle \langle i \vert \big{)} \vert \Psi \rangle = \sum_i \vert i \rangle \langle i \vert \Psi \rangle = \sum_i \vert i \rangle c_i,
\end{equation}
where the square of the coefficients $\vert c_i \vert^2$are interpreted as the probability of finding the system in the corresponding state.

BraketLab (do not yet fully) support measurement of the kets, for instance position:

```python
ms = psi_ho.measure(repetitions = 1000)
```

```python
ns, bins = np.histogram(ms)
bins = .5*(bins[1:] + bins[:-1])
plt.figure()
plt.plot(bins, ns, ".")
plt.show()
```

Behind the scenes

A given Hermitian operator may be measured on a ket by providing it's eigenbasis to the measurement-method of the ket. Note that this is not fully implemented yet. Currently, when calling the measurement method, a Metropolis-Hastings random walk is performed in the probability distribution of choice (discrete in the case of a finite eigenbasis). By default, a measurement corresponds to measuring the position.

## Atoms and molecules

From a quantum perspective, many-electron atoms may be seen as pertubed version of the Hydrogen atom. More electrons entering the system furthermore shifts us away from the Hydrogen-like solutions. Beyond atoms, we may consider molecules as perturbed versions of atoms. This motivates us to explore many-body quantum physics through the lens of independent particles, combined with the variational principle, stating that any trial wavefunction yields an upper estimate of the ground state energy of the system in question:
\begin{equation}
E_0 \leq \frac{\langle \Psi_{trial} \vert \hat{H} \vert \Psi_{trial} \rangle}{\langle \Psi_{trial} \vert \Psi_{trial} \rangle}
\end{equation}

Just keep in mind that the further away from the the hydrogen-case we move, the less reliable estimate is provided from the hydrogenlike, single-particle picture.

Helium is a famous case, which was close to perfectly solved for by Egil Hylleraas at the golden age of quantum physics almost a hundred years ago. The problem illustrates the many facets of the role of electronic correlation in quantum chemistry (see for instance Tew, D. P., Klopper, W., & Helgaker, T. (2007). Electron correlation: The many‐body problem at the heart of chemistry. Journal of computational chemistry, 28(8), 1307-1320.)

The Hamiltonian of Helium is (in atomic units, i.e.
$\hbar = 4 \pi \epsilon_0 = m_e = q_e = 1$):
\begin{equation}
\hat{H} = -\frac{1}{2} \big{(} \nabla_1^2 + \nabla_2^2 \big{)} - \frac{2}{r_1} - \frac{2}{r_2} + \frac{1}{r_{12}}
\end{equation}
The ground state energy has been experimentally determined to be about -2.9 Hartrees.

In the spirit of our current discussion, let's check how close we may get with a product of the Hydrogen-functions:
\begin{equation}
\vert \Psi_{trial} \rangle = \vert \psi_{h,0} \rangle \otimes \vert \psi_{h,0} \rangle
\end{equation}

If we neglect the two-body term, we can compute the remaining contributions with BraketLab in the following way:

```python
psi_0 = bk.basisbank.get_hydrogen_function(1,0,0)

E = 0

# both contributions to the kinetic energy
E_kin = psi_0.bra@(bk.kinetic_operator()*psi_0) * 2

# both contributions to the potential energy from the core
Z = 2
E_nuc = psi_0.bra@(bk.onebody_coulomb_operator()*psi_0) * Z * 2

print("E_0 < %.2f Hartrees" % (E_kin + E_nuc), " ( with inter-electronic repulsion neglected)")
```

Not surprisingly, our energy is below the experimental result, because we neglected the repulsion between the electrons. It is actually twice what we would find for a ionized Helium atom with only one electron. Let's now add the inter-electronic contribution (but be a bit patient, this is a 6-dimensional integral):

```python
psi_he = psi_0@psi_0 #the product wavefunction

E_vv = psi_he.bra@(bk.twobody_coulomb_operator()*psi_he)

print("E_0 < %.2f Hartrees" % (E_kin + E_nuc + E_vv), " ( with inter-electronic repulsion included)")
```

We are now quite deep into it, so you may wonder what is going on here. Notice what happened to the variables of the ket-ket product function:

```python
psi_he.ket_sympy_expression
```

This function in Liouville space now describes the state of two electrons. When acting on this ket, the twobody Coulomb operator produces a new ket:

```python
Jpsi_he = bk.twobody_coulomb_operator()*psi_he
Jpsi_he.ket_sympy_expression
```

Now the inner product includes also division by the distance between the integration coordinates of electron one and two. In general many-body quantum theory, these kinds of integrals are known as two-particle integrals, while in quantum chemistry they are mostly referred to as electron repulsion integrals (ERIs for short). BraketLab can deal with these implicitly as above, or explicitly as follows:

```python
bk.eri_mci(psi_0, psi_0, psi_0, psi_0)
```

In turn, this facilitates a very modular approach to implementing traditional quantum chemistry methods such as Hartree-Fock or DFT (if you have the time to wait for the integrals). It furthermore allows for thinking outside of the box of independent particles, gaussian type orbitals and slater determinants, which is why I have called BraketLab out-of-the-box outside-of-the-box thinking.

It's out-of-the-box since you may simply do

```python
pip install braketlab
```

...to get it on your home computer. The outside-of-the-box-thinking-part is up to you, but there are not many confines here (*) so feel free to explore. I'd love to get your feedback and proposals for features, just keep in mind that this is a work in progress. Thank you for your attention!

(*) Yet, many bugs and missing features.

(By the way, also check out BubbleBox for learning molecular dynamics and thermodynamics)


# Bessel and related functions

Series expansions are used in a variety of circumstances:
- When we need a tractable approximation to some ugly equation
- To transform between equivalent ways of looking at a problem (e.g. time domain vs frequency domain)
- When they are (part of) a solution to a particular class of differential equation

For approximations, there is an important divide between getting the best fit *near a point* (e.g. Taylor series) and getting the best fit *over an interval*. This notebook deals with one example of the latter; there is a separate notebook for Taylor expansions and others for Fourier, Legendre, etc.

## Fitting over an interval

What is the best (tractable) series approximating my function across some range of values? What matters is an overall best fit (e.g. least-squares deviation) across the range, and we can't tolerate wild divergences as with the Taylor series.

There are various series which are useful in different contexts, but a common property is that the terms are *orthogonal* over some interval $[-L,L]$. If $f(t)$ is a real-valued function their *inner product* is defined as

$$ \langle f(m t),f(n t) \rangle   \colon =\int _{-L}^L f(m t) f(n t) \,  dt $$

For orthogonal functions, this is non-zero if $m=n$ and zero if $m \ne n$, i.e.

$$\langle f(m t),f(n t) \rangle = a \delta_{mn}$$

where $\delta$ is the Kronecker delta.  If $a = 1$ the functions are said to be orthonormal.

## Bessel and Neumann Functions

These are a type of decaying oscillation, with all the functions mutually orthogonal over the range $[-1,1]$. [Boas](#refs) comments in section 12.12 that "there are whole books on Bessel functions", but explaining them at less than book-length seems more challenging.

They are solutions to the differential equation

$$ x^2 y'' + x y' + (x^2 - \alpha^2)y = 0 $$

where $\alpha$ can be an arbitrary complex constant. The most important cases are when $\alpha$ is integer or half-integer.

With integer $\alpha$ they tend to crop up in the sort of problems which naturally use cylindrical coordinates, such as protoplanetary disks and spiral galaxies. These cases are also known as cylindrical harmonics because they appear in the solution to Laplace's equation $\nabla^2 \phi = 0$ in cyclindrical coordinates.

Spherical Bessel functions with half-integer $\alpha$ appear in the solution to the Helmholtz equation $\nabla^2A + k^2A = 0$ in spherical coordinates.

### Variations

There are several related but distinct functions, and inconsistencies in names and symbols make things appear even more complex. This seems to be the general pattern (based on Wikipedia, with fingers crossed). Unfortunately this also demonstrates that Jupyter is really bad at table formatting.

| Type | ........ First kind .....| ...... Second kind .....|
|------|------------------------|---------------------------|
| Bessel | $\mathcal{J}_{\alpha}$ | $Y_{\alpha}$ or $N_n$ |
| Modified Bessel | $\mathcal{I}_{\alpha}$ | $\mathcal{K}_{\alpha}$ |
| Hankel | $\mathcal{H}_{\alpha}^{(1)} = \mathcal{J}_{\alpha} + i \mathcal{Y}_{\alpha}$ | $\mathcal{H}_{\alpha}^{(2)} = \mathcal{J}_{\alpha} - i \mathcal{Y}_{\alpha}$ |
| Spherical Bessel | $\mathcal{j}_n$ | $y_n$ or $n_n$ |
| Spherical Hankel | $\mathcal{h}_n^{(1)} = \mathcal{h}_n + i \mathcal{y}_n$ | $\mathcal{h}_n^{(1)} = \mathcal{h}_n - i \mathcal{y}_n$ |

There are many relevant Python functions in the `scipy.special` library, so be sure to check the documentation before you try to use them.

### Bessel functions of the first kind

These are finite at $x = 0$ for integer or positive $\alpha$. The generator function is

$$ \mathcal{J}_{\alpha}(x) = \sum_{m=0}^{\infty} \frac{(-1)^m}{m!\ \Gamma(m+\alpha+1)} \left( \frac{x}{2} \right)^{2m+\alpha} $$

Clearly it is easier to leave the computer to deal with this.

Unlike, for example, Legendre polynomials, Bessel functions don't have a simple algebraic representation. SymPy just displays the symbol, as in this rather boring example:

```python
from IPython.display import Math
from sympy import besselj, latex
from sympy.abc import x

for i in range(3):
    l_i = latex(besselj(i, x))
    display(Math('J_{} = {}'.format(i, l_i)))
```

$\displaystyle J_0 = J_{0}\left(x\right)$

$\displaystyle J_1 = J_{1}\left(x\right)$

$\displaystyle J_2 = J_{2}\left(x\right)$

Let's plot the first few Bessel functions of the first kind. I lifted the code from [here](https://stackoverflow.com/questions/16778845/python-plotting-bessel-functions-of-the-first-kind-with-a-float-argument).

```python
import numpy as np
import matplotlib.pyplot as plt
from math import factorial

from ipywidgets import interact, interactive, fixed, interact_manual, Layout
import ipywidgets as w

plt.rcParams.update({'font.size': 16})

import scipy.special as sp

xlims = (-10, 10)
x = np.linspace(xlims[0], xlims[1], 1000)

plt.figure(figsize=(9, 9))
for v in range(0, 6):
    plt.plot(x, sp.jv(v, x))

plt.xlim(xlims)
# plt.ylim((-0.5, 1.1))
plt.legend(('$\mathcal{J}_0(x)$', '$\mathcal{J}_1(x)$', '$\mathcal{J}_2(x)$',
           '$\mathcal{J}_3(x)$', '$\mathcal{J}_4(x)$', '$\mathcal{J}_5(x)$'),
           loc = 0)
plt.xlabel('$x$')
plt.ylabel('$\mathcal{J}_n(x)$')
plt.title('Plots of the first six Bessel Functions')
plt.grid(True)
```

### Bessel functions of the second kind

These are variously known as Neumann functions or Weber functions. They diverge at $x=0$.

```python
xlims = (0, 15)
x = np.linspace(xlims[0], xlims[1], 1000)

plt.figure(figsize=(9, 9))
for v in range(0, 6):
    plt.plot(x, sp.yn(v, x))

plt.xlim(xlims)
plt.ylim((-1, 0.6))
plt.legend(('$Y_0(x)$', '$Y_1(x)$', '$Y_2(x)$', '$Y_3(x)$',
            '$Y_4(x)$', '$Y_5(x)$'), loc = 0)
plt.xlabel('$x$')
plt.ylabel('$Y_n(x)$')
plt.title('Plots of the first six Neumann Functions')
plt.grid(True)
```

## References

- Boas, "Mathematical methods in the physical sciences", 3rd ed, section 12.12
- MathWorld, http://mathworld.wolfram.com/BesselFunction.html
- Wikipedia, https://en.wikipedia.org/wiki/Bessel_function
- Binney & Tremaine, "Galactic Dynamics", 2nd ed, appendix C.7
- Mathews & Walker, "Mathematical Methods of Physics", 2nd ed, section 7.2

```python

```


```python
%reload_ext autoreload
%aimport trochoid
%autoreload 1
```

```python
import math
import numpy as np
# %matplotlib notebook
import matplotlib.pyplot as plt

plt.style.use('seaborn-colorblind')
plt.style.use('seaborn-whitegrid')

plt.rcParams['figure.figsize'] = 800/72,800/72
plt.rcParams["font.size"] = 21
# plt.rcParams["legend.loc"] = 'upper right'

import os
import sys
sys.path.append(os.getcwd())# 作業ティレクトリをパスに追加\n",
from trochoid import *
```

## トロコイド

定円の半径を $r_c$、動円の半径を $r_m$、回転角を $\theta$、描画点の半径を $r_d$ とする

外トロコイド:

\begin{cases}
x=(r_{c}+r_{m})\cos \theta -r_{d}\cos \left({\cfrac {r_{c}+r_{m}}{r_{m}}}\theta \right),\\y=(r_{c}+r_{m})\sin \theta -r_{d}\sin \left({\cfrac {r_{c}+r_{m}}{r_{m}}}\theta \right),
\end{cases}

内トロコイド:

\begin{cases}
x=(r_{c}-r_{m})\cos \theta +r_{d}\cos \left({\cfrac  {r_{c}-r_{m}}{r_{m}}}\theta \right),\\y=(r_{c}-r_{m})\sin \theta -r_{d}\sin \left({\cfrac  {r_{c}-r_{m}}{r_{m}}}\theta \right),
\end{cases}

## 一般形トロコイド

描画円がなぞる曲線を$\boldsymbol{p}$とし，接線方向ベクトルは$\boldsymbol{t}$,法線方向ベクトルは$\boldsymbol{n}$とする

円の転がってきたパスの長さ$s$は

\begin{align}
s &= \sum_0^n \sqrt{dx[i]^2 + dy[i]^2}
\end{align}

と表される
描画円は滑らずに転がるため
\begin{align}
    r_m\theta &= s
\end{align}

したがって，
\begin{align}
\theta & = s/r_m \\
d\theta &= ds/r_m ,
\end{align}

となる．

点$p[i]$における接線ベクトル$t[i]$,法線ベクトル$n[i]$は

\begin{align}
t[i] &= \frac{p[i+1]-p[i-1]}{2} \\
n[i] &= \frac{t[i+1]-t[i-1]}{2} \\
&= \frac{p[i+2]-p[i-2]}{4}
\end{align}

描画円の中心の位置$p_m[i]$は単位法線ベクトル$n_u[i]$を用いて，
\begin{align}
p_m[i] &= p[i] + r_m n_u[i]
\end{align}

となる．ただし，$n_u[i]$は曲線の凹凸によって向きが反転する．これを避けるため，$n_u[i]$は改めて，
\begin{align}
n_u[i] &= R（\frac{\pi}{2}） t_u[i]
\end{align}
とする．ここで$R$はの回転行列である．

描画点の座標$p_d[i]$は

\begin{align}
p_d &= p_m[i] + r_d R(\theta[i]) e_x
\end{align}

となる．ここで$e_x$はx軸方向の単位ベクトルである

```python
# trocoid along sin-curve
fig = plt.figure()
fig.add_subplot(1, 1, 1)
sq_info = {}
x=np.linspace(0,10,num=150)
y=np.sin(x)
plt.plot(x,y)
x,y = trochoid(px=x,py=y,rm=0.1,rd=0.1)
plt.plot(x,y)
fig.axes[0].set_aspect('equal', 'box')
# plt.savefig("masic.pdf")
```

```python
# trocid on path
fig = plt.figure()
fig.add_subplot(1, 1, 1)
sq_info = {}

x0,y0 = polygon(64, 1)
plt.plot(x0,y0)
x1,y1 = ctrochoid(rc=0.9, rm=0.99*1/3, rd=0.4,n=1024,outer=False)

print(lcm(9,4))
# print(plot_trochoid(rc=0.95, rm=0.3, rd=0.4,outer=False,rmax=0.98,n=100))
plt.plot(x1,y1)

# x,y=trochoid(rc=1.2, rm=1/3, rd=1/3,outer=False,n=100)
# plt.plot(x,y)
n = 7
# col_inverse = a[:, ::-1]
rm = path_length(x1,y1)/(2*np.pi)/n
x,y = trochoid(px=np.tile(x1,n),py=np.tile(y1,n),rm=rm,rd=0.02*rm)
plt.plot(x,y)

# x,y = ptrochoid(px=np.tile(x1,2),py=np.tile(y1,2),rm=0.1,rd=0.4)
# plt.plot(x,y)

# x2,y2 = trochoid(rc=0.9, rm=0.15/2, rd=1.2,n=1024)
# plt.plot(x2,y2)

# x,y = ptrochoid(px=np.tile(x2,12),py=np.tile(y2,12),rm=3,rd=0.4)
# plt.plot(x,y)

fig.axes[0].set_aspect('equal', 'box')
# plt.savefig("trocids002.pdf")

```

```python
# trocid on path
fig = plt.figure()
fig.add_subplot(1, 1, 1)

x0,y0 = polygon(64, 1)
plt.plot(x0,y0)
x1,y1 = ctrochoid(rc=1, rm=1/6, rd=0.8*1/6,n=1024,outer=False)
plt.plot(x1,y1)
m = 7
n = 11/m
rm = path_length(x1,y1)/(2*np.pi)/n

fig.axes[0].set_aspect('equal', 'box')
plt.savefig("trocids002.pdf")

```

```python
def demo_trochoid(px, py, rm, rd, right=True, rmax=None, orient=0, *args, **kwargs):
    x = np.zeros(len(px))
    y = np.zeros(len(py))
    s = 0
    theta = 0

    rot = np.pi/2
    if right is True:
        rot = -rot
    r_mat = np.matrix(
        [[np.cos(rot), -np.sin(rot)],
        [np.sin(rot), np.cos(rot)],]
    )

    for i in range(len(px)):
        ds = 0
        if i > 0:
            ds = np.linalg.norm(np.array([px[i]-px[i-1], py[i]-py[i-1]]))

        d_theta = ds/rm

        s = s+ds

        theta = theta + d_theta

        if (i - 1) < 0 :
            t = np.array([px[i+1]-px[i], py[i+1]-py[i]])
        elif (i+1) >= len(px):
            t = np.array([px[i]-px[i-1], py[i]-py[i-1]])
        else :
            t = np.array([px[i+1]-px[i-1], py[i+1]-py[i-1]])*0.5

        t = t/(np.linalg.norm(t) + 1e-9)
        n = np.dot(r_mat, np.reshape(t, (2, 1)))
        p_m = np.array([[px[i]], [py[i]]]) + rm*n

        r_ort = np.matrix(
            [[np.cos(theta), -np.sin(theta)],
            [np.sin(theta), np.cos(theta)],]
        )

        p_d = p_m + rd*np.dot(r_ort, np.array([[1], [0]]))
        x[i] = p_d.item(0)
        y[i] = p_d.item(1)

        if i %10 == 0:
            p_m=np.reshape(p_m,(1,-1))
            plt.plot(*polygon(n=32,r=rm,cx=p_m.item(0),cy=p_m.item(1),color='gray',alpha=0.5) )
            plt.plot([p_m.item(0),x[i]],[p_m.item(1),y[i]],'r-o')

#     info = {'rot': i}
    return (x,y)
```

```python
# trocoid along sin-curve
fig = plt.figure()
fig.add_subplot(1, 1, 1)
sq_info = {}
x=np.linspace(0,10,num=150)
y=np.sin(x)
plt.plot(x,y)
x,y = demo_trochoid(px=x,py=y,rm=0.2,rd=0.3)
plt.plot(x,y)
fig.axes[0].set_aspect('equal', 'box')
plt.savefig("torocoid_sin.pdf")
```