# VIB: Theory

**Notation**
* $x$ be our input source,
* $y$ be our target
* $z$ be our latent representation

### Mutual Information
Mutual information (MI) measures the amount of information obtained about one random variable after observing another random variable. Formally given two random variables $x$ and $y$ with joint distribution $p(x,y)$ and marginal densities $p(x)$ and $p(y)$ their MI is defined as the KL-divergence between the joint density and the product of their marginal densities
$$\begin{align}
I(x;y)&=I(y;x)\\
&=KL\Big(p(x,y)||p(x)p(y)\Big)\\
&=\mathbb{E}_{(x,y)\sim p(x,y)}\bigg[\log\frac{p(x,y)}{p(x)p(y)}\bigg]\\
&=\int dxdyp(x,y)\log\frac{p(x,y)}{p(x)p(y)}
\end{align}$$

### Information Bottlenecks
IB regards supervised learning as a representation learning problem, seeking a stochastic map from input data
$x$ to some latent representation $z$ that can still be used to predict the labels $y$ , under a constraint on its total complexity.

We assume our joint distribution $p(x,y,z)$ can be factorised as follows:
$$p(x,y,z)=p(z\mid x,y)p(y\mid x)p(x)=p(z\mid x)p(y\mid x)p(x)$$
which corresponds to the following Markov Chain
$$y\rightarrow x\rightarrow z$$

Our goal is to learn an encoding that is maximally informative about our target $y$ measured by $I(y;z)$. We could always ensure a maximally informative representation by taking the identity encoding $x=z$ which is not useful. Instead we apply a constraint such that the objective is
$$\begin{alignat}{3}
    &\underset{}{\text{max }} & \quad & I(y;z)\\
    &\text{subject to } & \quad & I(x;z)\leq I_c
\end{alignat}$$
where $I_c$ is the information constraint. The Lagrangian of the above constrained optimisation problem which we would like to **maximise** is
$$\begin{align}
    L_{IB}&=I(y;z)-\beta \big(I(x;z)-I_c\big)\\
    &=I(y;z)-\beta I(x;z)
\end{align}$$
where $\beta\geq0$ is a Lagrange multiplier.
* Intuitively the first term encourages $z$ to be predictive of $y$, whilst the second term encourages $z$ to "forget" $x$.
* In essence, IB principle explicitly enforces the learned representation $z$ to only preserve the information in $x$ that is useful to the prediction of $y$, i.e., the minimal sufficient statistics of $x$ w.r.t. $y$.

### Variational Information Bottlenecks

**The first term**

We can write out the terms in the objective as
$$I(y;z)=\int dydz p(y,z)\log \frac{p(y,z)}{p(y)p(z)}=\int dydz p(y,z)\log \frac{p(y\mid z)}{p(y)}$$
where $p(y\mid z)$ is defined as
$$p(y\mid z)=\int dx \frac{p(x,y,z)}{p(z)}=\int dx \frac{p(z\mid x)p(y\mid x)p(x)}{p(z)}$$
which is intractable. Let $q(y\mid z)$ be a variational approximation to $p(y\mid z)$. By using the KL divergence we can obtain a lower bound on $I(y;z)$
$$KL\Big(p(y\mid z)|| q(y\mid z)\Big)\geq0\Longrightarrow \int dy p(y\mid z)\log p(y\mid z)\geq \int dy p(y\mid z)\log q(y\mid z)$$
Hence we have that
$$\begin{align}
    I(y;z)&= \int dydz p(y,z)\log p(y\mid z) - \int dy p(y)\log p(y)\\
    &\geq \int dydz p(y, z)\log q(y\mid z) - \int dy p(y)\log p(y)\\
    &=\int dxdydz p(z\mid x)p(y\mid x)p(x)\log q(y\mid z)
\end{align}$$
where the entropy of the labels $H(y)=- \int dy p(y)\log p(y)$ is independent of our optimisation and so can be ignored.

**The second term**

We can write out the second term in the objective as
$$I(x;z)=\int dxdz p(x,z)\log \frac{p(x,z)}{p(x)p(z)}=\int dxdz p(x,z)\log \frac{p(z\mid x)}{p(z)}$$
Let $q(z)$ be a variational approximation to the marginal $p(z)$. By using the KL divergence we can obtain an upper bound on $I(x;z)$ as
$$KL\Big(p(z)|| q(z)\Big)\geq0\Longrightarrow \int dz p(z)\log p(z)\geq \int dz p(z)\log q(z)$$
Hence we have
$$\begin{align}
    I(x;z)&=\int dxdz p(x,z)\log p(z\mid x) - \int dz p(z)\log p(z)\\
    &\leq\int dxdz p(x,z)\log p(z\mid x) - \int dz p(z)\log q(z)\\
    &=\int dxdz p(x)p(z\mid x)\log \frac{p(z\mid x)}{q(z)}
\end{align}$$

### Loss Function
Combining the above two bounds we can rewrite the Lagrangian which we would like to **maximise** as
$$\begin{align}
    L_{IB}&=I(y;z)-\beta I(x;z)\\
    &\geq \int dxdydz p(z\mid x)p(y\mid x)p(x)\log q(y\mid z) -\beta\int dxdz p(x)p(z\mid x)\log \frac{p(z\mid x)}{q(z)}\\
    &=\int dxdydz p(z\mid x)p(y,x)\log q(y\mid z) -\beta\int dxdydz p(z\mid x)p(x,y)KL\Big(p(z\mid x)||q(z)\Big)\\
    &=\mathbb{E}_{(x,y)\sim p(x,y), z\sim p(z\mid x)}\bigg[\log q(y\mid z)-\beta KL\Big(p(z\mid x)||q(z)\Big)\bigg]\\
    &=J_{IB}
\end{align}$$

To compute the lower bound in practice make the following assumptions:

* We approximate $p(x,y)=p(x)p(y\mid x)$ using the empirical data distribution $p(x,y)=\frac{1}{n}\sum^{n}_{i=1}\delta_{x_i}(x)\delta_{y_i}(y)$ such that
$$\begin{align}
J_{IB}&= \int dxdydz p(z\mid x)p(y\mid x)p(x)\log q(y\mid z) -\beta\int dxdz p(x)p(z\mid x)\log \frac{p(z\mid x)}{q(z)}\\
&\approx \frac{1}{n}\sum^{n}_{i=1}\bigg[\int dz p(z\mid x_i)\log q(y_i\mid z)-\beta\int dz p(z\mid x_i)\log \frac{p(z\mid x_i)}{q(z)}\bigg]\\
&=\frac{1}{n}\sum^{n}_{i=1}\bigg[\int dz p(z\mid x_i)\log q(y_i\mid z)- \beta KL\Big(p(z\mid x_i)|| q(z)\Big) \bigg]
\end{align}$$

* By using an encoder parameterised as multivariate Gaussian
$$p_\phi(z\mid x)=\mathcal{N}\bigg(z;\boldsymbol{\mu}_\phi(x), \boldsymbol{\Sigma}_\phi(x)\bigg)$$
then we can use the reparameterisation trick such that $z=g_\phi(\epsilon,x)$ which is a deterministic function of $x$ and the Gaussian random variable $\epsilon\sim p(\epsilon)=\mathcal{N}(0,I)$.

* We assume that our choice of parameterisation of $p(z\mid x)$ and $q(z)$ allow for computation of an analytic KL-divergence,

Thus the final objective we would **minimise** is
$$J_{IB}=\frac{1}{n}\sum^{n}_{i=1}\Bigg[\beta KL\Big(p(z\mid x_i)|| q(z)\Big) - \mathbb{E}_{\epsilon\sim p(\epsilon)}\Big[\log q\big(y_i\mid g_\phi(\epsilon,x)\big)\Big]\Bigg]$$
where we have that
* $p_\phi(z\mid x)$ is the encoder parameterised as a multivariate Gaussian
$$p_\phi(z\mid x)=\mathcal{N}\bigg(z;\boldsymbol{\mu}_\phi(x), \boldsymbol{\Sigma}_\phi(x)\bigg)$$
* $q_\theta(y\mid z)$ is the decoder parameterised as an independent Bernoulli for each element $y_j$ of $y$ (for binary data)
$$q_\theta(y_j\mid z)=\text{Ber}\Big(\mu_\theta(z)\Big)$$
* $q(z)$ is the approximated latent marginal often fixed to a standard normal.
$$q_\theta(z)=\mathcal{N}\Big(z;\mathbf{0},\mathbf{I}_k\Big)$$

By using our parameterisation of the decoder $q_\theta(y\mid z)$ as an indepenedent Bernoulli we have that
$$-\log q_\theta(y\mid z)=-\Big[y\log \hat{y} + (1-y)\log(1-\hat{y})\Big]$$
i.e. this is the Binary Cross Entropy loss.

### Connection to Variational Autoencoder
The VAE is a special case of an unsupervised version of VIB with $\beta=1.0$ as they consider the objective
$$L=I(x;z)-\beta I(i;z)$$
where the aim is to take our data $x$ and maximise the mutual information contained in some encoding $z$, while restricting how much information we allow our representation to contain about the identity of each data element in our sample $i$. While this objective takes the same mathematical form as that of a Variational Autoencoder, the interpretation of the objective is very different:
* In the VAE, the model starts life as a generative model with a defined prior $p(z)$ and stochastic decoder $p(x|z)$ as part of the model, and the encoder $q(z|x)$ is created to serve as a variational approximation to the true posterior $p(z|x) = \frac{p(x|z)}{p(z)p(x)}$.
* In the VIB approach, the model is originally just the stochastic encoder $p(z|x)$, and the decoder $q(x|z)$ is the variational approximation to the true $p(x|z) = \frac{p(z|x)p(x)}{p(z)}$ and $q(z)$ is the variational approximation to the marginal $p(z) =\int dx p(x)p(z|x)$.

### References
* Original Deep VIB paper: https://arxiv.org/abs/1612.00410

# VIB: Code

The code is almost identical to my VAE implementation found here: [torch_vae](https://github.com/udeepam/vae/blob/master/notebooks/vae.ipynb)

**References:**
* https://github.com/makezur/VIB_pytorch
* https://github.com/sungyubkim/DVIB
* https://github.com/1Konny/VIB-pytorch

```python
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import numpy as np
import matplotlib.pyplot as plt
import time
from collections import defaultdict

import torch
import torch.nn as nn
from torch.nn import functional as F
import torch.utils.data as data_utils

# Device Config
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# Fix random seeds for reproducibility
seed = 73
torch.manual_seed(seed)
np.random.seed(seed)
```

## Load MNIST Dataset

```python
# import torchvision
# from torchvision import transforms
# from torchvision.datasets import MNIST
# # 60000 tuples with 1x28x28 image and corresponding label
# data = MNIST('data',
#              train=True,
#              download=True,
#              transform = transforms.Compose([transforms.ToTensor()]))
# # Split data into images and labels
# x_train = data.train_data
# y_train = data.train_labels
# # Scale images from [0,255] to [0,+1]
# x_train = x_train.float() / 255
# # Save as .npz
# np.savez_compressed('data/mnist_train',
#                     a=x_train,
#                     b=y_train)

# # 10000 tuples with 1x28x28 image and corresponding label
# data = MNIST('data',
#              train=False,
#              download=True,
#              transform = transforms.Compose([transforms.ToTensor()]))
# # Split data into images and labels
# x_test = data.test_data
# y_test = data.test_labels
# # Scale images from [0,255] to [0,+1]
# x_test = x_test.float() / 255
# # Save as .npz
# np.savez_compressed('data/mnist_test',
#                     a=x_test,
#                     b=y_test)
```

```python
# Load MNIST data locally
train_data = np.load('data/mnist_train.npz')
x_train = torch.Tensor(train_data['a'])
y_train = torch.Tensor(train_data['b'])
n_classes = len(np.unique(y_train))

test_data = np.load('data/mnist_test.npz')
x_test = torch.Tensor(test_data['a'])
y_test = torch.Tensor(test_data['b'])
```

```python
# Visualise data
plt.rcParams.update({'font.size': 16})
fig, axes = plt.subplots(1,4, figsize=(35,35))
imx, imy = (28,28)
labels   = [0,1,2,3]
for i, ax in enumerate(axes):
    visual = np.reshape(x_train[labels[i]], (imx,imy))
    ax.set_title("Example Data Image, y="+str(int(y_train[labels[i]])))
    ax.imshow(visual, vmin=0, vmax=1)
plt.show()
```

## Models

```python
class DeepVIB(nn.Module):
    def __init__(self, input_shape, output_shape, z_dim):
        """
        Deep VIB Model.

        Arguments:
        ----------
        input_shape : `int`
            Flattened size of image. (Default=784)
        output_shape : `int`
            Number of classes. (Default=10)
        z_dim : `int`
            The dimension of the latent variable z. (Default=256)
        """
        super(DeepVIB, self).__init__()
        self.input_shape  = input_shape
        self.output_shape = output_shape
        self.z_dim  = z_dim

        # build encoder
        self.encoder = nn.Sequential(nn.Linear(input_shape, 1024),
                                     nn.ReLU(inplace=True),
                                     nn.Linear(1024, 1024),
                                     nn.ReLU(inplace=True)                                    )
        self.fc_mu  = nn.Linear(1024, self.z_dim)
        self.fc_std = nn.Linear(1024, self.z_dim)

        # build decoder
        self.decoder = nn.Linear(self.z_dim, output_shape)

    def encode(self, x):
        """
        x : [batch_size,784]
        """
        x = self.encoder(x)
        return self.fc_mu(x), F.softplus(self.fc_std(x)-5, beta=1)

    def decode(self, z):
        """
        z : [batch_size,z_dim]
        """
        return self.decoder(z)

    def reparameterise(self, mu, std):
        """
        mu : [batch_size,z_dim]
        std : [batch_size,z_dim]
        """
        # get epsilon from standard normal
        eps = torch.randn_like(std)
        return mu + std*eps

    def forward(self, x):
        """
        Forward pass

        Parameters:
        -----------
        x : [batch_size,28,28]
        """
        # flattent image
        x_flat = x.view(x.size(0), -1)
        mu, std = self.encode(x_flat)
        z = self.reparameterise(mu, std)
        return self.decode(z), mu, std
```

## Training

```python
# Hyperparameters
beta   = 1e-3
z_dim  = 256
epochs = 200
batch_size = 128
learning_rate = 1e-4
decay_rate = 0.97
```

```python
# Create DatatLoader
train_dataset    = data_utils.TensorDataset(x_train, y_train)
train_dataloader = torch.utils.data.DataLoader(train_dataset,
                                               batch_size=batch_size,
                                               shuffle=True)

# Loss function: Cross Entropy Loss (CE) + beta*KL divergence
def loss_function(y_pred, y, mu, std):
    """
    y_pred : [batch_size,10]
    y : [batch_size,10]
    mu : [batch_size,z_dim]
    std: [batch_size,z_dim]
    """
    CE = F.cross_entropy(y_pred, y, reduction='sum')
    KL = 0.5 * torch.sum(mu.pow(2) + std.pow(2) - 2*std.log() - 1)
    return (beta*KL + CE) / y.size(0)

# Initialize Deep VIB
vib = DeepVIB(np.prod(x_train[0].shape), n_classes, z_dim)

# Optimiser
optimiser = torch.optim.Adam(vib.parameters(), lr=learning_rate)
scheduler = torch.optim.lr_scheduler.ExponentialLR(optimizer=optimiser, gamma=decay_rate)

# Send to GPU if available
vib.to(device)

print("Device: ", device)
print(vib)
```

    Device:  cuda
    DeepVIB(
      (encoder): Sequential(
        (0): Linear(in_features=784, out_features=1024, bias=True)
        (1): ReLU(inplace=True)
        (2): Linear(in_features=1024, out_features=1024, bias=True)
        (3): ReLU(inplace=True)
      )
      (fc_mu): Linear(in_features=1024, out_features=256, bias=True)
      (fc_std): Linear(in_features=1024, out_features=256, bias=True)
      (decoder): Linear(in_features=256, out_features=10, bias=True)
    )

```python
# Training
measures = defaultdict(list)
start_time = time.time()

# put Deep VIB into train mode
vib.train()

for epoch in range(epochs):
    epoch_start_time = time.time()

    # exponential decay of learning rate every 2 epochs
    if epoch % 2 == 0 and epoch > 0:
        scheduler.step()

    batch_loss = 0
    batch_accuracy = 0
    for _, (X,y) in enumerate(train_dataloader):
        X = X.to(device)
        y = y.long().to(device)

        # Zero accumulated gradients
        vib.zero_grad()

        # forward pass through Deep VIB
        y_pred, mu, std = vib(X)

        # Calculate loss
        loss = loss_function(y_pred, y, mu, std)
        # Backpropogation: calculating gradients
        loss.backward()
        # Update parameters of generator
        optimiser.step()

        # Save loss per batch
        batch_loss += loss.item()*X.size(0)
        # Save accuracy per batch
        y_pred = torch.argmax(y_pred,dim=1)
        batch_accuracy += int(torch.sum(y == y_pred))

    # Save losses per epoch
    measures['total_loss'].append(batch_loss / len(train_dataloader.dataset))
    # Save accuracy per epoch
    measures['accuracy'].append(batch_accuracy / len(train_dataloader.dataset))

    print("Epoch: {}/{}...".format(epoch+1, epochs),
          "Loss: {:.4f}...".format(measures['total_loss'][-1]),
          "Accuracy: {:.4f}...".format(measures['accuracy'][-1]),
          "Time Taken: {:,.4f} seconds".format(time.time()-epoch_start_time))

print("Total Time Taken: {:,.4f} seconds".format(time.time()-start_time))
```

    Epoch: 1/200... Loss: 0.8247... Accuracy: 0.8544... Time Taken: 2.9338 seconds
    Epoch: 2/200... Loss: 0.3168... Accuracy: 0.9435... Time Taken: 2.6235 seconds
    Epoch: 3/200... Loss: 0.2235... Accuracy: 0.9614... Time Taken: 3.1089 seconds
    Epoch: 4/200... Loss: 0.1736... Accuracy: 0.9713... Time Taken: 2.7176 seconds
    Epoch: 5/200... Loss: 0.1419... Accuracy: 0.9778... Time Taken: 2.8584 seconds
    Epoch: 6/200... Loss: 0.1211... Accuracy: 0.9825... Time Taken: 2.6173 seconds
    Epoch: 7/200... Loss: 0.1039... Accuracy: 0.9865... Time Taken: 2.6863 seconds
    Epoch: 8/200... Loss: 0.0908... Accuracy: 0.9896... Time Taken: 2.6867 seconds
    Epoch: 9/200... Loss: 0.0811... Accuracy: 0.9913... Time Taken: 2.6113 seconds
    Epoch: 10/200... Loss: 0.0731... Accuracy: 0.9932... Time Taken: 3.1410 seconds
    Epoch: 11/200... Loss: 0.0650... Accuracy: 0.9948... Time Taken: 2.7592 seconds
    Epoch: 12/200... Loss: 0.0604... Accuracy: 0.9954... Time Taken: 2.6326 seconds
    Epoch: 13/200... Loss: 0.0557... Accuracy: 0.9961... Time Taken: 3.3420 seconds
    Epoch: 14/200... Loss: 0.0525... Accuracy: 0.9970... Time Taken: 2.6378 seconds
    Epoch: 15/200... Loss: 0.0486... Accuracy: 0.9973... Time Taken: 2.5848 seconds
    Epoch: 16/200... Loss: 0.0454... Accuracy: 0.9980... Time Taken: 2.5640 seconds
    Epoch: 17/200... Loss: 0.0442... Accuracy: 0.9976... Time Taken: 2.7317 seconds
    Epoch: 18/200... Loss: 0.0419... Accuracy: 0.9980... Time Taken: 2.6942 seconds
    Epoch: 19/200... Loss: 0.0395... Accuracy: 0.9984... Time Taken: 3.0209 seconds
    Epoch: 20/200... Loss: 0.0382... Accuracy: 0.9984... Time Taken: 2.6198 seconds
    Epoch: 21/200... Loss: 0.0366... Accuracy: 0.9986... Time Taken: 2.7759 seconds
    Epoch: 22/200... Loss: 0.0366... Accuracy: 0.9984... Time Taken: 2.7001 seconds
    Epoch: 23/200... Loss: 0.0337... Accuracy: 0.9990... Time Taken: 3.1175 seconds
    Epoch: 24/200... Loss: 0.0338... Accuracy: 0.9988... Time Taken: 2.7052 seconds
    Epoch: 25/200... Loss: 0.0327... Accuracy: 0.9990... Time Taken: 2.7418 seconds
    Epoch: 26/200... Loss: 0.0327... Accuracy: 0.9989... Time Taken: 3.0871 seconds
    Epoch: 27/200... Loss: 0.0315... Accuracy: 0.9990... Time Taken: 2.8472 seconds
    Epoch: 28/200... Loss: 0.0305... Accuracy: 0.9992... Time Taken: 3.4106 seconds
    Epoch: 29/200... Loss: 0.0309... Accuracy: 0.9989... Time Taken: 2.7489 seconds
    Epoch: 30/200... Loss: 0.0291... Accuracy: 0.9992... Time Taken: 2.6132 seconds
    Epoch: 31/200... Loss: 0.0287... Accuracy: 0.9992... Time Taken: 3.1964 seconds
    Epoch: 32/200... Loss: 0.0279... Accuracy: 0.9994... Time Taken: 2.7221 seconds
    Epoch: 33/200... Loss: 0.0274... Accuracy: 0.9994... Time Taken: 2.6423 seconds
    Epoch: 34/200... Loss: 0.0281... Accuracy: 0.9991... Time Taken: 2.9033 seconds
    Epoch: 35/200... Loss: 0.0274... Accuracy: 0.9991... Time Taken: 3.2232 seconds
    Epoch: 36/200... Loss: 0.0269... Accuracy: 0.9993... Time Taken: 3.1736 seconds
    Epoch: 37/200... Loss: 0.0262... Accuracy: 0.9994... Time Taken: 2.5905 seconds
    Epoch: 38/200... Loss: 0.0271... Accuracy: 0.9991... Time Taken: 3.3423 seconds
    Epoch: 39/200... Loss: 0.0260... Accuracy: 0.9993... Time Taken: 2.6030 seconds
    Epoch: 40/200... Loss: 0.0256... Accuracy: 0.9994... Time Taken: 2.6783 seconds
    Epoch: 41/200... Loss: 0.0257... Accuracy: 0.9992... Time Taken: 2.7412 seconds
    Epoch: 42/200... Loss: 0.0247... Accuracy: 0.9994... Time Taken: 3.0694 seconds
    Epoch: 43/200... Loss: 0.0255... Accuracy: 0.9991... Time Taken: 2.8609 seconds
    Epoch: 44/200... Loss: 0.0245... Accuracy: 0.9993... Time Taken: 2.8332 seconds
    Epoch: 45/200... Loss: 0.0247... Accuracy: 0.9994... Time Taken: 3.2771 seconds
    Epoch: 46/200... Loss: 0.0246... Accuracy: 0.9992... Time Taken: 2.6079 seconds
    Epoch: 47/200... Loss: 0.0241... Accuracy: 0.9994... Time Taken: 3.1023 seconds
    Epoch: 48/200... Loss: 0.0240... Accuracy: 0.9994... Time Taken: 2.9019 seconds
    Epoch: 49/200... Loss: 0.0237... Accuracy: 0.9993... Time Taken: 2.8837 seconds
    Epoch: 50/200... Loss: 0.0241... Accuracy: 0.9993... Time Taken: 3.3029 seconds
    Epoch: 51/200... Loss: 0.0235... Accuracy: 0.9995... Time Taken: 3.0055 seconds
    Epoch: 52/200... Loss: 0.0235... Accuracy: 0.9993... Time Taken: 2.7285 seconds
    Epoch: 53/200... Loss: 0.0236... Accuracy: 0.9993... Time Taken: 2.5974 seconds
    Epoch: 54/200... Loss: 0.0229... Accuracy: 0.9993... Time Taken: 2.9562 seconds
    Epoch: 55/200... Loss: 0.0231... Accuracy: 0.9994... Time Taken: 2.6335 seconds
    Epoch: 56/200... Loss: 0.0230... Accuracy: 0.9993... Time Taken: 2.6220 seconds
    Epoch: 57/200... Loss: 0.0229... Accuracy: 0.9992... Time Taken: 2.6912 seconds
    Epoch: 58/200... Loss: 0.0224... Accuracy: 0.9994... Time Taken: 2.8508 seconds
    Epoch: 59/200... Loss: 0.0220... Accuracy: 0.9995... Time Taken: 2.6053 seconds
    Epoch: 60/200... Loss: 0.0222... Accuracy: 0.9994... Time Taken: 2.5654 seconds
    Epoch: 61/200... Loss: 0.0220... Accuracy: 0.9995... Time Taken: 2.6095 seconds
    Epoch: 62/200... Loss: 0.0217... Accuracy: 0.9995... Time Taken: 2.6336 seconds
    Epoch: 63/200... Loss: 0.0217... Accuracy: 0.9995... Time Taken: 2.6273 seconds
    Epoch: 64/200... Loss: 0.0220... Accuracy: 0.9994... Time Taken: 3.3488 seconds
    Epoch: 65/200... Loss: 0.0221... Accuracy: 0.9994... Time Taken: 2.6145 seconds
    Epoch: 66/200... Loss: 0.0217... Accuracy: 0.9992... Time Taken: 2.8320 seconds
    Epoch: 67/200... Loss: 0.0214... Accuracy: 0.9994... Time Taken: 2.6069 seconds
    Epoch: 68/200... Loss: 0.0213... Accuracy: 0.9995... Time Taken: 2.5644 seconds
    Epoch: 69/200... Loss: 0.0208... Accuracy: 0.9996... Time Taken: 2.7144 seconds
    Epoch: 70/200... Loss: 0.0210... Accuracy: 0.9996... Time Taken: 2.9346 seconds
    Epoch: 71/200... Loss: 0.0211... Accuracy: 0.9995... Time Taken: 2.6813 seconds
    Epoch: 72/200... Loss: 0.0212... Accuracy: 0.9993... Time Taken: 2.6847 seconds
    Epoch: 73/200... Loss: 0.0211... Accuracy: 0.9995... Time Taken: 2.9722 seconds
    Epoch: 74/200... Loss: 0.0214... Accuracy: 0.9993... Time Taken: 2.6538 seconds
    Epoch: 75/200... Loss: 0.0207... Accuracy: 0.9997... Time Taken: 3.3534 seconds
    Epoch: 76/200... Loss: 0.0208... Accuracy: 0.9994... Time Taken: 2.6201 seconds
    Epoch: 77/200... Loss: 0.0204... Accuracy: 0.9996... Time Taken: 2.6122 seconds
    Epoch: 78/200... Loss: 0.0205... Accuracy: 0.9996... Time Taken: 2.5787 seconds
    Epoch: 79/200... Loss: 0.0207... Accuracy: 0.9994... Time Taken: 2.5820 seconds
    Epoch: 80/200... Loss: 0.0207... Accuracy: 0.9993... Time Taken: 2.6216 seconds
    Epoch: 81/200... Loss: 0.0204... Accuracy: 0.9994... Time Taken: 2.5788 seconds
    Epoch: 82/200... Loss: 0.0204... Accuracy: 0.9995... Time Taken: 3.0649 seconds
    Epoch: 83/200... Loss: 0.0202... Accuracy: 0.9996... Time Taken: 2.6127 seconds
    Epoch: 84/200... Loss: 0.0201... Accuracy: 0.9995... Time Taken: 2.5784 seconds
    Epoch: 85/200... Loss: 0.0200... Accuracy: 0.9995... Time Taken: 3.3494 seconds
    Epoch: 86/200... Loss: 0.0203... Accuracy: 0.9996... Time Taken: 3.3574 seconds
    Epoch: 87/200... Loss: 0.0199... Accuracy: 0.9995... Time Taken: 2.6292 seconds
    Epoch: 88/200... Loss: 0.0204... Accuracy: 0.9994... Time Taken: 3.3624 seconds
    Epoch: 89/200... Loss: 0.0205... Accuracy: 0.9993... Time Taken: 3.3769 seconds
    Epoch: 90/200... Loss: 0.0198... Accuracy: 0.9995... Time Taken: 2.5880 seconds
    Epoch: 91/200... Loss: 0.0194... Accuracy: 0.9996... Time Taken: 2.6509 seconds
    Epoch: 92/200... Loss: 0.0199... Accuracy: 0.9994... Time Taken: 2.8752 seconds
    Epoch: 93/200... Loss: 0.0197... Accuracy: 0.9996... Time Taken: 2.6407 seconds
    Epoch: 94/200... Loss: 0.0195... Accuracy: 0.9997... Time Taken: 2.8048 seconds
    Epoch: 95/200... Loss: 0.0196... Accuracy: 0.9995... Time Taken: 2.6742 seconds
    Epoch: 96/200... Loss: 0.0196... Accuracy: 0.9995... Time Taken: 2.6301 seconds
    Epoch: 97/200... Loss: 0.0193... Accuracy: 0.9996... Time Taken: 2.7376 seconds
    Epoch: 98/200... Loss: 0.0194... Accuracy: 0.9995... Time Taken: 2.6650 seconds
    Epoch: 99/200... Loss: 0.0197... Accuracy: 0.9995... Time Taken: 2.6346 seconds
    Epoch: 100/200... Loss: 0.0192... Accuracy: 0.9996... Time Taken: 2.7169 seconds
    Epoch: 101/200... Loss: 0.0194... Accuracy: 0.9995... Time Taken: 3.3288 seconds
    Epoch: 102/200... Loss: 0.0196... Accuracy: 0.9995... Time Taken: 3.3768 seconds
    Epoch: 103/200... Loss: 0.0196... Accuracy: 0.9995... Time Taken: 3.3494 seconds
    Epoch: 104/200... Loss: 0.0190... Accuracy: 0.9996... Time Taken: 2.6361 seconds
    Epoch: 105/200... Loss: 0.0189... Accuracy: 0.9996... Time Taken: 2.6597 seconds
    Epoch: 106/200... Loss: 0.0192... Accuracy: 0.9994... Time Taken: 2.6189 seconds
    Epoch: 107/200... Loss: 0.0192... Accuracy: 0.9996... Time Taken: 3.0845 seconds
    Epoch: 108/200... Loss: 0.0190... Accuracy: 0.9996... Time Taken: 2.6658 seconds
    Epoch: 109/200... Loss: 0.0190... Accuracy: 0.9996... Time Taken: 2.5523 seconds
    Epoch: 110/200... Loss: 0.0187... Accuracy: 0.9998... Time Taken: 2.9323 seconds
    Epoch: 111/200... Loss: 0.0189... Accuracy: 0.9996... Time Taken: 2.8170 seconds
    Epoch: 112/200... Loss: 0.0188... Accuracy: 0.9996... Time Taken: 2.5783 seconds
    Epoch: 113/200... Loss: 0.0191... Accuracy: 0.9995... Time Taken: 3.3629 seconds
    Epoch: 114/200... Loss: 0.0191... Accuracy: 0.9995... Time Taken: 2.6130 seconds
    Epoch: 115/200... Loss: 0.0189... Accuracy: 0.9996... Time Taken: 2.6526 seconds
    Epoch: 116/200... Loss: 0.0189... Accuracy: 0.9995... Time Taken: 4.0172 seconds
    Epoch: 117/200... Loss: 0.0190... Accuracy: 0.9996... Time Taken: 2.6815 seconds
    Epoch: 118/200... Loss: 0.0186... Accuracy: 0.9997... Time Taken: 2.5753 seconds
    Epoch: 119/200... Loss: 0.0186... Accuracy: 0.9996... Time Taken: 2.9361 seconds
    Epoch: 120/200... Loss: 0.0188... Accuracy: 0.9996... Time Taken: 2.6108 seconds
    Epoch: 121/200... Loss: 0.0186... Accuracy: 0.9996... Time Taken: 2.6475 seconds
    Epoch: 122/200... Loss: 0.0188... Accuracy: 0.9996... Time Taken: 3.3376 seconds
    Epoch: 123/200... Loss: 0.0182... Accuracy: 0.9997... Time Taken: 2.6128 seconds
    Epoch: 124/200... Loss: 0.0185... Accuracy: 0.9996... Time Taken: 2.7202 seconds
    Epoch: 125/200... Loss: 0.0185... Accuracy: 0.9995... Time Taken: 2.6614 seconds
    Epoch: 126/200... Loss: 0.0186... Accuracy: 0.9995... Time Taken: 2.6227 seconds
    Epoch: 127/200... Loss: 0.0185... Accuracy: 0.9996... Time Taken: 2.6026 seconds
    Epoch: 128/200... Loss: 0.0182... Accuracy: 0.9996... Time Taken: 2.6591 seconds
    Epoch: 129/200... Loss: 0.0184... Accuracy: 0.9996... Time Taken: 2.6267 seconds
    Epoch: 130/200... Loss: 0.0182... Accuracy: 0.9997... Time Taken: 2.6503 seconds
    Epoch: 131/200... Loss: 0.0183... Accuracy: 0.9996... Time Taken: 2.5881 seconds
    Epoch: 132/200... Loss: 0.0183... Accuracy: 0.9997... Time Taken: 2.8815 seconds
    Epoch: 133/200... Loss: 0.0185... Accuracy: 0.9996... Time Taken: 2.8744 seconds
    Epoch: 134/200... Loss: 0.0183... Accuracy: 0.9996... Time Taken: 2.5883 seconds
    Epoch: 135/200... Loss: 0.0180... Accuracy: 0.9996... Time Taken: 2.7730 seconds
    Epoch: 136/200... Loss: 0.0183... Accuracy: 0.9995... Time Taken: 2.6282 seconds
    Epoch: 137/200... Loss: 0.0181... Accuracy: 0.9996... Time Taken: 2.6488 seconds
    Epoch: 138/200... Loss: 0.0182... Accuracy: 0.9996... Time Taken: 3.1817 seconds
    Epoch: 139/200... Loss: 0.0182... Accuracy: 0.9996... Time Taken: 2.5606 seconds
    Epoch: 140/200... Loss: 0.0180... Accuracy: 0.9997... Time Taken: 2.6162 seconds
    Epoch: 141/200... Loss: 0.0182... Accuracy: 0.9996... Time Taken: 2.8601 seconds
    Epoch: 142/200... Loss: 0.0181... Accuracy: 0.9995... Time Taken: 2.6141 seconds
    Epoch: 143/200... Loss: 0.0181... Accuracy: 0.9996... Time Taken: 2.6489 seconds
    Epoch: 144/200... Loss: 0.0178... Accuracy: 0.9997... Time Taken: 2.5955 seconds
    Epoch: 145/200... Loss: 0.0178... Accuracy: 0.9997... Time Taken: 2.5702 seconds
    Epoch: 146/200... Loss: 0.0180... Accuracy: 0.9996... Time Taken: 3.3677 seconds
    Epoch: 147/200... Loss: 0.0181... Accuracy: 0.9996... Time Taken: 2.6410 seconds
    Epoch: 148/200... Loss: 0.0178... Accuracy: 0.9998... Time Taken: 2.7881 seconds
    Epoch: 149/200... Loss: 0.0183... Accuracy: 0.9995... Time Taken: 2.6617 seconds
    Epoch: 150/200... Loss: 0.0182... Accuracy: 0.9995... Time Taken: 2.6785 seconds
    Epoch: 151/200... Loss: 0.0182... Accuracy: 0.9995... Time Taken: 2.6391 seconds
    Epoch: 152/200... Loss: 0.0178... Accuracy: 0.9996... Time Taken: 3.2244 seconds
    Epoch: 153/200... Loss: 0.0180... Accuracy: 0.9996... Time Taken: 3.3582 seconds
    Epoch: 154/200... Loss: 0.0179... Accuracy: 0.9996... Time Taken: 3.4923 seconds
    Epoch: 155/200... Loss: 0.0183... Accuracy: 0.9995... Time Taken: 2.5894 seconds
    Epoch: 156/200... Loss: 0.0177... Accuracy: 0.9996... Time Taken: 2.6014 seconds
    Epoch: 157/200... Loss: 0.0181... Accuracy: 0.9995... Time Taken: 2.7769 seconds
    Epoch: 158/200... Loss: 0.0180... Accuracy: 0.9996... Time Taken: 2.6053 seconds
    Epoch: 159/200... Loss: 0.0177... Accuracy: 0.9996... Time Taken: 2.5928 seconds
    Epoch: 160/200... Loss: 0.0178... Accuracy: 0.9997... Time Taken: 2.6379 seconds
    Epoch: 161/200... Loss: 0.0179... Accuracy: 0.9996... Time Taken: 3.1609 seconds
    Epoch: 162/200... Loss: 0.0177... Accuracy: 0.9997... Time Taken: 2.6448 seconds
    Epoch: 163/200... Loss: 0.0180... Accuracy: 0.9994... Time Taken: 2.5746 seconds
    Epoch: 164/200... Loss: 0.0180... Accuracy: 0.9994... Time Taken: 2.6516 seconds
    Epoch: 165/200... Loss: 0.0182... Accuracy: 0.9994... Time Taken: 3.3428 seconds
    Epoch: 166/200... Loss: 0.0178... Accuracy: 0.9996... Time Taken: 2.6213 seconds
    Epoch: 167/200... Loss: 0.0177... Accuracy: 0.9996... Time Taken: 3.0967 seconds
    Epoch: 168/200... Loss: 0.0178... Accuracy: 0.9996... Time Taken: 2.5412 seconds
    Epoch: 169/200... Loss: 0.0182... Accuracy: 0.9994... Time Taken: 2.5439 seconds
    Epoch: 170/200... Loss: 0.0176... Accuracy: 0.9997... Time Taken: 2.5998 seconds
    Epoch: 171/200... Loss: 0.0176... Accuracy: 0.9996... Time Taken: 2.7536 seconds
    Epoch: 172/200... Loss: 0.0176... Accuracy: 0.9997... Time Taken: 2.6462 seconds
    Epoch: 173/200... Loss: 0.0175... Accuracy: 0.9996... Time Taken: 2.6801 seconds
    Epoch: 174/200... Loss: 0.0176... Accuracy: 0.9996... Time Taken: 2.6599 seconds
    Epoch: 175/200... Loss: 0.0177... Accuracy: 0.9996... Time Taken: 2.5852 seconds
    Epoch: 176/200... Loss: 0.0177... Accuracy: 0.9996... Time Taken: 2.6656 seconds
    Epoch: 177/200... Loss: 0.0176... Accuracy: 0.9996... Time Taken: 2.6122 seconds
    Epoch: 178/200... Loss: 0.0174... Accuracy: 0.9997... Time Taken: 2.6994 seconds
    Epoch: 179/200... Loss: 0.0174... Accuracy: 0.9997... Time Taken: 3.3239 seconds
    Epoch: 180/200... Loss: 0.0176... Accuracy: 0.9995... Time Taken: 2.7048 seconds
    Epoch: 181/200... Loss: 0.0177... Accuracy: 0.9996... Time Taken: 2.7155 seconds
    Epoch: 182/200... Loss: 0.0173... Accuracy: 0.9997... Time Taken: 2.5776 seconds
    Epoch: 183/200... Loss: 0.0173... Accuracy: 0.9995... Time Taken: 2.7194 seconds
    Epoch: 184/200... Loss: 0.0176... Accuracy: 0.9997... Time Taken: 2.7349 seconds
    Epoch: 185/200... Loss: 0.0177... Accuracy: 0.9996... Time Taken: 2.5996 seconds
    Epoch: 186/200... Loss: 0.0175... Accuracy: 0.9997... Time Taken: 2.5958 seconds
    Epoch: 187/200... Loss: 0.0174... Accuracy: 0.9996... Time Taken: 2.6491 seconds
    Epoch: 188/200... Loss: 0.0176... Accuracy: 0.9994... Time Taken: 2.5828 seconds
    Epoch: 189/200... Loss: 0.0172... Accuracy: 0.9998... Time Taken: 3.3346 seconds
    Epoch: 190/200... Loss: 0.0172... Accuracy: 0.9997... Time Taken: 2.6041 seconds
    Epoch: 191/200... Loss: 0.0176... Accuracy: 0.9996... Time Taken: 2.6345 seconds
    Epoch: 192/200... Loss: 0.0174... Accuracy: 0.9996... Time Taken: 2.9950 seconds
    Epoch: 193/200... Loss: 0.0174... Accuracy: 0.9997... Time Taken: 2.5821 seconds
    Epoch: 194/200... Loss: 0.0174... Accuracy: 0.9996... Time Taken: 2.7444 seconds
    Epoch: 195/200... Loss: 0.0177... Accuracy: 0.9996... Time Taken: 2.6246 seconds
    Epoch: 196/200... Loss: 0.0173... Accuracy: 0.9997... Time Taken: 2.5798 seconds
    Epoch: 197/200... Loss: 0.0174... Accuracy: 0.9997... Time Taken: 2.6676 seconds
    Epoch: 198/200... Loss: 0.0172... Accuracy: 0.9997... Time Taken: 3.0055 seconds
    Epoch: 199/200... Loss: 0.0174... Accuracy: 0.9996... Time Taken: 2.5939 seconds
    Epoch: 200/200... Loss: 0.0175... Accuracy: 0.9997... Time Taken: 2.6308 seconds
    Total Time Taken: 559.3997 seconds

## Testing

```python
# Create DatatLoader
test_dataset = data_utils.TensorDataset(x_test, y_test)
test_dataloader = torch.utils.data.DataLoader(test_dataset,
                                              batch_size=batch_size,
                                              shuffle=True)

measures = defaultdict(int)
start_time = time.time()

# put Deep VIB into train mode
vib.eval()

with torch.no_grad():
    for _, (X,y) in enumerate(test_dataloader):
        X = X.to(device)
        y = y.long().to(device)

        # forward pass through Deep VIB
        y_pred, mu, std = vib(X)

        y_pred = torch.argmax(y_pred,dim=1)
        measures['accuracy'] += int(torch.sum(y == y_pred))

print("Accuracy: {:.4f}...".format(measures['accuracy']/len(test_dataloader.dataset)),
      "Time Taken: {:,.4f} seconds".format(time.time()-start_time))
```

    Accuracy: 0.9824... Time Taken: 0.1271 seconds


# Integral Control Analysis

# Preliminaries

## Imports

```python
import collections
import tellurium as te
import sympy
from IPython.display import Image
from IPython.core.display import HTML
import matplotlib.pyplot as plt
import numpy as np
from common_python.sympy import sympyUtil as su
from common_python.ODEModel.ODEModel import ODEModel
```

## Constants

```python
def initSymbols():
    symbols = "dA A A_ref K_AN K_AM N K_PA K_MP rd_A rd_A1 K1 K2 K3 K4 K_A2 Kd_A Kd_M Kd_P K_Mp A M Mp dP P K_P k_0 M dM K_M rd_M k_0"
    su.addSymbols(symbols, dct=globals())

initSymbols()
EQUATION_VARS = [P, M, A]
```

```python
STATE_DCT = {
     A: K_AN * N - Kd_A * A * M,
     P: K_PA * A - k_0,
     M: K_MP * P - Kd_M * M,
}
SUBS = {Kd_A: 1, K_MP: 1, K_PA: 1, k_0: 2.0, Kd_M: 1, N: 1, K_AN: 1, Kd_M: 1, Kd_A: 1, Kd_P: 1, K_AM: 1}
```

# Helper Functions

```python

```

```python
def mkSymbolStmt(symbolStr):
  """
  Creaes an executable statement that defines the blank separated symbols in the string.
  """
  symbols = symbolStr.split(" ")
  quotedStrs = ["%s" %s for s in symbols]
  argStrs = ", ".join(symbols)
  return "%s = sympy.symbols(%s)" % (argStrs, quotedStrs)

# Tests
variables = ["aa", "bbb", "fruit"]
symbolStr = " ".join(variables)
stmt = mkSymbolStmt(symbolStr)
exec(stmt)
for variable in variables:
  assert(variable in locals())
  delStmt = "del %s" % variable
  exec(delStmt)
```

```python
def evaluate(expression, subs):
    result = expression.copy()
    for symbol, value in subs.items():
        result = result.subs(symbol, value)
    return sympy.simplify(result)

# Tests
initSymbols()
dA = A + A * M
result = evaluate(dA, {A: 1})
assert(result == 1 + M)
```

```python
def initEquations():
  """
  Constructs equations for network A where M inhibits A
  """
  statements = [
      "dA = K_AN * N - Kd_A * A * M",
      "dP = K_PA * A - k_0 ",
      "dM = K_MP * P - Kd_M * M",
      "equationDct = {A: dA, P: dP, M: dM}",
  ]
  return "; ".join(statements)

exec(initEquations())
```

```python
def mkEquationVec(equationDct, equationVars=[P, M, A]):
    return sympy.Matrix([equationDct[s] for s in equationVars])

# Tests
exec(initEquations())
vec = mkEquationVec(equationDct)
assert(len(vec) == 3)
assert(isinstance(vec, sympy.Matrix))
```

```python
def calcJacobian(equationDct, fixedDct, equationVars=[P, M, A], **kwargs):
    """
    Calculates the Jacobian at the fixed point and evaluates it.

    Parameters
    ----------
    equationDct: key: Symbol, value: expression
    fixedDct: key: Symbol, value: expression/number
    varVec: vector of variable (symbols) that correspond to the equations
    kwargs: dict
        optional arguments for evaluate

    Returns
    -------
    sympy.Matrix
    """
    equationVec = mkEquationVec(equationDct, equationVars)
    # Calculate fixed points and eigenvectors
    jacobianMat = equationVec.jacobian(equationVars)
    jacobianMat = su.evaluate(jacobianMat, subs=fixedDct, isNumpy=False)
    if len(kwargs) > 0:
        jacobianMat = su.evaluate(jacobianMat, **kwargs)
    return jacobianMat

# Tests
exec(initEquations())
fixedDct = {A:101, M: 1001}
resultMat = calcJacobian(equationDct , fixedDct, isNumpy=False)
assert(resultMat[2, 2] == -1001.0 * Kd_A)
assert((resultMat.rows == 3) and (resultMat.cols ==3))
```

```python
# eqn: equations for model
# var: variable names
# fps: list-FixedPointInfo
# jac: jacobian
# eig: eigenvalues
# fp: list-dct of fixed points
ModelInfo = collections.namedtuple("ModelInfo", "eqn var fps")
FixedPointInfo = collections.namedtuple("FixedPointInfo", "jac eig fp")
def calcModelInfo(equationDct, equationVars=EQUATION_VARS, subs={}):
    """
    Construct various information for a model.

    Parameters
    ----------
    equationDct: dict
        key: state
        value: expression
    equationVars: list-symbol
        state variables
    subs: dict

    Returns
    -------
    ModelInfo
    """
    def mkFixedDcts(fixedPoints):
        def mkDct(points):
            if isinstance(points, dict):
                return points
            return {v: p for p, v in zip(points, equationVars)}
        #
        if isinstance(fixedPoints, dict):
            fixedDcts = [mkDct(fixedPoints)]
        else:
            fixedDcts = []
            for fixedPoint in fixedPoints:
                fixedDcts.append(mkDct(fixedPoint))
        return fixedDcts
    #
    modelInfos = []
    equationVec = mkEquationVec(equationDct, equationVars)
    # Calculate the fixed points
    fixedPoints = sympy.solve(equationVec, equationVars)
    fixedDcts = mkFixedDcts(fixedPoints)
    # Calculate the Jacobian
    fps = []
    for fixedDct in fixedDcts:
        jacobianMat = calcJacobian(equationDct, fixedDct, isNumpy=False)
        eigenValues = list(jacobianMat.eigenvals().keys())
        fps.append(FixedPointInfo(fp=fixedDct, jac=jacobianMat, eig=eigenValues))
    # Construct the ModelInfo
    return ModelInfo(eqn=equationDct, var=equationVars, fps=fps)

# Tests
exec(initEquations())
result = calcModelInfo(equationDct)
eigenvalues = [su.evaluate(e, subs=SUBS) for e in result.fps[0].eig]
assert(eigenvalues[0] < 0)
assert(len(eigenvalues) == 3)
```

# Implementation of integral feedback control in biological systems
## Reference
Authors:
Somvanshi, Pramod R.
Patel, Anilkumar K.
Bhartiya, Sharad
Venkatesh, K. V.

Date: 2015

## Analysis

[Feedback Designs](https://drive.google.com/file/d/1GT2hfqxUAo8iiLutB4ccp_mMl59KB8OH/view?usp=sharing)

* $P$ - protein
* $A$ - signaling molecule
* $M$ - metabolite
* $N$ - disturbance
* $E = A_{ref} - A$
* $A_{ref} = \frac{k_0}{K_P}$

* $P \xrightarrow{k_0}\phi$
* $\phi \xrightarrow{K_M P} M$
* $A \xrightarrow{Kd_A A M} \phi$
* $N \xrightarrow{K_A} A$
* $\phi \xrightarrow{K_P A} P$

```python
NET_A = """

J1: P -> ; k_0
J3: -> P; K_P * A
J2: -> M; K_M * P
J2a: M ->; Kd_M * M
J4: A -> ; Kd_A * A * M
J5: $N -> A; K_A * N

$N = 1
P = 100
M = 1
A = 1
K_N = 1
k_0 = 1
K_M = 1
Kd_M = 1
K_P = 1
Kd_A = 1
K_A = 1
"""

rr = te.loada(NET_A)
rr.plot(rr.simulate(0, 1000, 100), title="N=%d" % rr.N)
print("Final A = %2.2f" % rr.A)
```

### Revised Network A

```python
# Have a source of A other than N
NET_A1 = """

J1: P -> ; k_0
J3: -> P; K_P * A
J2: -> M; K_M * P
J2a: M ->; Kd_M * M
J4: A -> ; Kd_A * A * M
J4a: -> A; k_1
J5: $N -> A; K_A * N

$N = 1
P = 1
M = 1
A = 1
K_N = 1
k_0 = 1
K_M = 1
Kd_M = 1
K_P = 1
Kd_A = 1
K_A = 1
k_1 = 1
"""

rr = te.loada(NET_A1)
rr.plot(rr.simulate(0, 30, 100), title="N=%d" % rr.N)
print("Final A = %2.2f" % rr.A)
```

### Changing $k_0$

Vary k_0 from 0.5 to 3.
Plot fixed points and how they change type.

```python
# Have a source of A other than N
NET_A1 = """

J1: P -> ; k_0
J3: -> P; K_P * A
J2: -> M; K_M * P
J2a: M ->; Kd_M * M
J4: A -> ; Kd_A * A * M
J4a: -> A; k_1
J5: $N -> A; K_A * N

$N = 1
P = 1
M = 1
A = 1
K_N = 1
k_0 = 1
K_M = 1
Kd_M = 1
K_P = 1
Kd_A = 1
K_A = 1
k_1 = 1
"""

rr = te.loada(NET_A1)
rr.plot(rr.simulate(0, 30, 100), title="N=%d" % rr.N)
print("Final A = %2.2f" % rr.A)
```

```python
K0_VALS = [0.5, 0.75, 1.0, 1.5, 2.0, 2.5]
```

```python
def plotk0(model, k0Vals=K0_VALS):
    numCol = 3
    fig, axes = plt.subplots(2, numCol, figsize=(12, 10))
    for idx, k_0 in enumerate(k0Vals):
        if idx < numCol:
            ax = axes[0, idx]
        else:
            ax = axes[1, idx - numCol]
        rr =  te.loada(model)
        rr.k_0 = k_0
        data = rr.simulate(0, 30, 100)
        xv = data[:, 0]
        columns = [s[1:-1] for s in data.colnames[1:]]
        for idx1, column in enumerate(columns):
            ax.plot(xv, data[:, idx1+1])
        ax.plot(xv, np.repeat(k_0, len(xv)), linestyle="--", color="black")
        if idx == 0:
            ax.legend(columns)
        if idx == numCol:
            ax.set_xlabel("time")
            ax.set_ylabel("value")
        ax.set_title("k_0=%2.2f" % k_0)
        ax.set_ylim([0, 8])

# Tests
plotk0(NET_A1)
```

```python
def calcEigenVals(xVec, dxVec, subs):
    """
    Calculates the eigenvectors of the linearized derivative matrix
    at the substituted values.

    Parameters
    ----------
    xVec: sympy.Mat N X 1
        symbols for states
    dxVec: sympy.Mat N X 1
        expressions for derivatives of state
    subs: dict
        substitutions to make for eigenvalue calculation
        key: symbol
        value: value to use for symbol

    Returns
    -------
    dict
        key: sympy.Matrix N X 1
            fixed point
        value: sympy.Matrix N X 1
            eigenvalues
    """
    jacobianMat = dxVec.jacobian(xVec)
    fixedPoints = sympy.solve(dxVec, xVec)
    dct = {}
    jacobianMat = evaluate(jacobianMat, subs)
    for fixedPoint in fixedPoints:
        numericFixedPoint = tuple([sympy.simplify(evaluate(f, subs)) for f in fixedPoint])
        fixedSubs = {s: v for s, v in zip(xVec, numericFixedPoint)}
        mat = evaluate(jacobianMat, fixedSubs)
        mat = evaluate(mat, subs)
        dct[numericFixedPoint] = sympy.Matrix(list(mat.eigenvals().keys()))
        dct[numericFixedPoint] = dct[numericFixedPoint].evalf()
    return dct

# Tests
initSymbols()
exec(initEquations())
xVec = sympy.Matrix([A, P, M])
dxVec = mkEquationVec(equationDct, equationVars=[A, P, M])
#dxVec = sympy.Matrix(equations)
dct = calcEigenVals(xVec, dxVec, SUBS)
assert(list(dct.keys()) == [(2.0, 0.5, 0.5)])
```

```python
# Analysis of fixed points

subs = dict(SUBS)
for val in K0_VALS:
    initSymbols()
    subs[k_0] = val
    xVec = sympy.Matrix([A, P, M])
    equationVec = mkEquationVec(equationDct, equationVars=xVec)
    dxVec = sympy.Matrix(equationVec)
    dct = calcEigenVals(xVec, dxVec, subs)
    print(dct)
```

    {(0.500000000000000, 2.00000000000000, 2.00000000000000): Matrix([
    [-2.19148788395312 - 2.53459329291848e-64*I],
    [  -0.404256058023441 - 0.254425889416369*I],
    [  -0.404256058023441 + 0.254425889416369*I]])}
    {(0.750000000000000, 1.33333333333333, 1.33333333333333): Matrix([
    [-1.8284526398601 - 2.49609327051412e-64*I],
    [ -0.252440346736618 - 0.588605783081763*I],
    [ -0.252440346736618 + 0.588605783081763*I]])}
    {(1.00000000000000, 1.00000000000000, 1.00000000000000): Matrix([
    [-0.122561166876654 - 0.744861766619744*I],
    [-0.122561166876654 + 0.744861766619744*I],
    [                       -1.75487766624669]])}
    {(1.50000000000000, 0.666666666666667, 0.666666666666667): Matrix([
    [0.0512182151373453 - 0.919382383528956*I],
    [0.0512182151373453 + 0.919382383528956*I],
    [                       -1.76910309694136]])}
    {(2.00000000000000, 0.500000000000000, 0.500000000000000): Matrix([
    [0.163002589530756 - 1.03378784147109*I],
    [0.163002589530756 + 1.03378784147109*I],
    [                     -1.82600517906151]])}
    {(2.50000000000000, 0.400000000000000, 0.400000000000000): Matrix([
    [0.244456239475004 - 1.12416815004909*I],
    [0.244456239475004 + 1.12416815004909*I],
    [                     -1.88891247895001]])}

## Network A

```python
modelA = ODEModel(STATE_DCT, isEigenvecs=False)
```

```python
for fp in modelA.fixedPoints:
    print(fp.valueDct)
    print(fp.getEigenvalues(subs=SUBS))
    print("\n")
```

    {A: k_0/K_PA, P: K_AN*K_PA*Kd_M*N/(K_MP*Kd_A*k_0), M: K_AN*K_PA*N/(Kd_A*k_0)}
    [-1.82600517906151, 0.163002589530756 + 1.03378784147109*I, 0.163002589530756 - 1.03378784147109*I]

## Network B

Network B1 has degrdation of $P$ that depends on $P$. It has two problems:
* Eigenvectors are a funxtion of $M$
* Fixed points are a function of $N$

```python
stateDct = dict(STATE_DCT)
stateDct[A] = K_AN * N - Kd_A*A + K_AM * M
stateDct[P] = -K_PA * A * M  + k_0
```

```python
modelB1 = ODEModel(stateDct, isEigenvecs=False)
```

```python
modelB1.jacobianMat
```

$\displaystyle \left[\begin{matrix}- Kd_{A} & 0 & K_{AM}\\- K_{PA} M & 0 & - A K_{PA}\\0 & K_{MP} & - Kd_{M}\end{matrix}\right]$

```python
modelB1.fixedPoints[0].valueDct[P]
```

$\displaystyle - \frac{Kd_{M} \left(K_{AN} \sqrt{K_{PA}} N + \sqrt{4 K_{AM} Kd_{A} k_{0} + K_{AN}^{2} K_{PA} N^{2}}\right)}{2 K_{AM} K_{MP} \sqrt{K_{PA}}}$

```python
 modelB1.fixedPoints[0].valueDct[M]
```

$\displaystyle - \frac{K_{AN} N}{2 K_{AM}} - \frac{\sqrt{4 K_{AM} Kd_{A} k_{0} + K_{AN}^{2} K_{PA} N^{2}}}{2 K_{AM} \sqrt{K_{PA}}}$

```python
 modelB1.fixedPoints[0].valueDct[A]
```

$\displaystyle \frac{K_{AN} N}{2 Kd_{A}} - \frac{\sqrt{4 K_{AM} Kd_{A} k_{0} + K_{AN}^{2} K_{PA} N^{2}}}{2 \sqrt{K_{PA}} Kd_{A}}$

Network B2 has degrdation of $P$ that does *not* depend on $P$.
* It has a fixed point for $A$ ad $k_0 / K_{PA}$.
* It has no dependency on the state variables, and so the dynamics are independent of the fixed point. (It is a linear system.)

```python
stateDct = dict(STATE_DCT)
stateDct[A] = K_AN * N - Kd_A*A + K_AM * M
stateDct[P] = -K_PA * A  + k_0
```

```python
modelB2 = ODEModel(stateDct, isEigenvecs=False)
```

```python
modelB2.jacobianMat
```

$\displaystyle \left[\begin{matrix}- Kd_{A} & 0 & K_{AM}\\- K_{PA} & 0 & 0\\0 & K_{MP} & - Kd_{M}\end{matrix}\right]$

```python
modelB2.fixedPoints[0].valueDct[P]
```

$\displaystyle \frac{- K_{AN} K_{PA} Kd_{M} N + Kd_{A} Kd_{M} k_{0}}{K_{AM} K_{MP} K_{PA}}$

```python
modelB2.fixedPoints[0].valueDct[M]
```

$\displaystyle \frac{- K_{AN} K_{PA} N + Kd_{A} k_{0}}{K_{AM} K_{PA}}$

```python
modelB2.fixedPoints[0].valueDct[A]
```

$\displaystyle \frac{k_{0}}{K_{PA}}$

```python
for fp in modelB2.fixedPoints:
    print(fp.getEigenvalues(subs=SUBS))
```

    [-1.75487766624669, -0.122561166876654 + 0.744861766619744*I, -0.122561166876654 - 0.744861766619744*I]

```python
# Have a source of A other than N
NET_B1 = """

J1: -> P ; k_0
J3: P -> ; K_PA * A * P
J2: -> M; K_MP * P
J2a: M ->; Kd_M * M
J4: A -> ; Kd_A * A
J4a: -> A; K_AM*M
J5: $N -> A; K_AN * N

$N = 0
P = 0
M = 0
A = 0
K_AN = 1
k_0 = 5
K_MP = 1
Kd_M = 1
K_PA = 1
Kd_A = 1
K_AM = 1
"""

rr = te.loada(NET_B1)
rr.plot(rr.simulate(0, 30, 100), title="N=%d" % rr.N)
print("Final A = %2.2f" % rr.A)
```

```python
plotk0(NET_B1)
```

```python
# Have a source of A other than N
NET_B2 = """

J1: -> P ; k_0
J3: P -> ; K_PA * A
J2: -> M; K_MP * P
J2a: M ->; Kd_M * M
J4: A -> ; Kd_A * A
J4a: -> A; K_AM*M
J5: $N -> A; K_AN * N

$N = 0
P = 0
M = 0
A = 0
K_AN = 1
k_0 = 5
K_MP = 1
Kd_M = 1
K_PA = 1
Kd_A = 1
K_AM = 1
"""

rr = te.loada(NET_B2)
rr.plot(rr.simulate(0, 30, 100), title="N=%d" % rr.N)
print("Final A = %2.2f" % rr.A)
```

```python
plotk0(NET_B2)
```

**Observations**
1. Can change $k_0$ without affect Jacobian. However, fixed point depends on $k_0$, and so dynamics are still impacted if there is a change in the setpoint.

## Network C

```python
# Design 1(c): Both M and E inhibit A. Again, need a constant influx so that steady states can be achieved.
exec(initEquations())
dA = - K_AN * N - Kd_A * M + K1
#
result = sympy.solve([dP, dM, dA], [P, M, A])
result
```

    {A: k_0/K_PA, P: (K1*Kd_M - K_AN*Kd_M*N)/(K_MP*Kd_A), M: (K1 - K_AN*N)/Kd_A}

## Network D

```python
# Design 1(d): E inhibits A; A inhibits P; M activates A
exec(initEquations())
dA = K_AM * M - K_AN * N - Kd_A * A
dP = -K_PA * A + k_0  # Since A inhibits, need 0th order kinetics for creation of P
#
result = sympy.solve([dP, dM, dA], [P, M, A])
result
```

    {P: (K_AN*K_PA*Kd_M*N + Kd_A*Kd_M*k_0)/(K_AM*K_MP*K_PA),
     M: (K_AN*K_PA*N + Kd_A*k_0)/(K_AM*K_PA),
     A: k_0/K_PA}

Can construct a table with cases defined by:
* E on A
* M on A
* A on P

Theme: Systematic design of integral control based on:
1. the placement of inhibit vs. activate and use of 0th order kinetics
1. which term(s) have 0th order kinetics

## Tracking for Architecture (a)

```python
exec(initEquations())
dP = K_P * A - k_0 * A_ref
result = sympy.solve([dP, dM, dA], [P, M, A])
result
```

    [(K_AN*K_P*Kd_M*N/(A_ref*K_MP*Kd_A*k_0),
      K_AN*K_P*N/(A_ref*Kd_A*k_0),
      A_ref*k_0/K_P)]

```python
TRACKING_MODEL_A = """
->P; K_P*A
P -> A; K_P * P
A -> P; -KA_1 * A
A ->; KA * $M
// E -> A; KE * A

$M = 1
KP_1 = 1
KP_2 = 1
KA_1 = 1
KA = 1
KE = 1
K_P = 1
"""

rr = te.loada(TRACKING_MODEL_A)
rr.plot(rr.simulate())
```

# Signaling Cascades

```python
su.addSymbols("A_1 A_2 A_3 A_4 T_1 T_2 T_3 T_4 K_1 K_2 K_3 K_4 k_1 k_2 k_3 k_4 S_0 S_1")
```

```python
# State equations
stateDct = {
    A_1: K_1 * S_0 * (T_1 - A_1) - k_1 * A_1,
    A_2: K_2 * A_1 * (T_2 - A_2) - k_2 * A_2,
    A_3: K_3 * A_2 * (T_3 - A_3) - k_3 * A_3,
    A_4: K_4 * A_3 * (T_4 - A_4) - k_4 * A_4,
}
modelSig = ODEModel(stateDct, isEigenvecs=False)
```

```python
fps = modelSig.fixedPoints
len(fps)
```

    1

```python
fps[0].valueDct[A_1]
```

$\displaystyle \frac{K_{1} S_{0} T_{1}}{K_{1} S_{0} + k_{1}}$

```python
fps[0].valueDct[A_2]
```

$\displaystyle \frac{K_{1} K_{2} S_{0} T_{1} T_{2}}{K_{1} K_{2} S_{0} T_{1} + K_{1} S_{0} k_{2} + k_{1} k_{2}}$

```python
fps[0].valueDct[A_3]
```

$\displaystyle \frac{K_{1} K_{2} K_{3} S_{0} T_{1} T_{2} T_{3}}{K_{1} K_{2} K_{3} S_{0} T_{1} T_{2} + K_{1} K_{2} S_{0} T_{1} k_{3} + K_{1} S_{0} k_{2} k_{3} + k_{1} k_{2} k_{3}}$

```python
fps[0].valueDct[A_4]
```

$\displaystyle \frac{K_{1} K_{2} K_{3} K_{4} S_{0} T_{1} T_{2} T_{3} T_{4}}{K_{1} K_{2} K_{3} K_{4} S_{0} T_{1} T_{2} T_{3} + K_{1} K_{2} K_{3} S_{0} T_{1} T_{2} k_{4} + K_{1} K_{2} S_{0} T_{1} k_{3} k_{4} + K_{1} S_{0} k_{2} k_{3} k_{4} + k_{1} k_{2} k_{3} k_{4}}$

```python
jacobianMat = modelSig.jacobianMat.subs(fps[0].valueDct)
jacobianMat
```

$\displaystyle \left[\begin{matrix}- K_{1} S_{0} - k_{1} & 0 & 0 & 0\\- K_{2} \left(\frac{K_{1} K_{2} S_{0} T_{1} T_{2}}{K_{1} K_{2} S_{0} T_{1} + K_{1} S_{0} k_{2} + k_{1} k_{2}} - T_{2}\right) & - \frac{K_{1} K_{2} S_{0} T_{1}}{K_{1} S_{0} + k_{1}} - k_{2} & 0 & 0\\0 & - K_{3} \left(\frac{K_{1} K_{2} K_{3} S_{0} T_{1} T_{2} T_{3}}{K_{1} K_{2} K_{3} S_{0} T_{1} T_{2} + K_{1} K_{2} S_{0} T_{1} k_{3} + K_{1} S_{0} k_{2} k_{3} + k_{1} k_{2} k_{3}} - T_{3}\right) & - \frac{K_{1} K_{2} K_{3} S_{0} T_{1} T_{2}}{K_{1} K_{2} S_{0} T_{1} + K_{1} S_{0} k_{2} + k_{1} k_{2}} - k_{3} & 0\\0 & 0 & - K_{4} \left(\frac{K_{1} K_{2} K_{3} K_{4} S_{0} T_{1} T_{2} T_{3} T_{4}}{K_{1} K_{2} K_{3} K_{4} S_{0} T_{1} T_{2} T_{3} + K_{1} K_{2} K_{3} S_{0} T_{1} T_{2} k_{4} + K_{1} K_{2} S_{0} T_{1} k_{3} k_{4} + K_{1} S_{0} k_{2} k_{3} k_{4} + k_{1} k_{2} k_{3} k_{4}} - T_{4}\right) & - \frac{K_{1} K_{2} K_{3} K_{4} S_{0} T_{1} T_{2} T_{3}}{K_{1} K_{2} K_{3} S_{0} T_{1} T_{2} + K_{1} K_{2} S_{0} T_{1} k_{3} + K_{1} S_{0} k_{2} k_{3} + k_{1} k_{2} k_{3}} - k_{4}\end{matrix}\right]$

```python
sympy.Matrix([jacobianMat[i,i] for i in range(jacobianMat.rows)])
```

$\displaystyle \left[\begin{matrix}- K_{1} S_{0} - k_{1}\\- \frac{K_{1} K_{2} S_{0} T_{1}}{K_{1} S_{0} + k_{1}} - k_{2}\\- \frac{K_{1} K_{2} K_{3} S_{0} T_{1} T_{2}}{K_{1} K_{2} S_{0} T_{1} + K_{1} S_{0} k_{2} + k_{1} k_{2}} - k_{3}\\- \frac{K_{1} K_{2} K_{3} K_{4} S_{0} T_{1} T_{2} T_{3}}{K_{1} K_{2} K_{3} S_{0} T_{1} T_{2} + K_{1} K_{2} S_{0} T_{1} k_{3} + K_{1} S_{0} k_{2} k_{3} + k_{1} k_{2} k_{3}} - k_{4}\end{matrix}\right]$

**Observations**
1. Stable system with real eigenvectors.
1. If $K_i >> k_i$, then $A_{i+1} / A_{i} \approx T_{i+1}$ and so $A_n / S_0 \approx \prod_i T_i$

# Push Pull (Cell Systems)

```python
# Push Pull in Cell Systems. Assume n* = 1
su.addSymbols("T_A A B T_B I k_A k_IA k_a k_Ba k_B k_AB k_b")
```

```python
# State equations
stateDct = {
    A:  (T_A - A) * (k_A + k_IA * I) - A * (k_a + k_Ba * B),
    B: (T_B - B) * (k_B + k_AB * A) - k_b * B
}
modelPushPull = ODEModel(stateDct, isEigenvecs=False, isFixedPoints=False)
```

```python
stateDct[A].expand()
```

$\displaystyle - A B k_{Ba} - A I k_{IA} - A k_{A} - A k_{a} + I T_{A} k_{IA} + T_{A} k_{A}$

```python
stateDct[B]
```

$\displaystyle - B k_{b} + \left(- B + T_{B}\right) \left(A k_{AB} + k_{B}\right)$

```python
solnA = sympy.solve(stateDct[A], [A])[0]
solnA
```

$\displaystyle \frac{T_{A} \left(I k_{IA} + k_{A}\right)}{B k_{Ba} + I k_{IA} + k_{A} + k_{a}}$

```python
subsB = stateDct[B].subs({A: solnA})
subsB
```

$\displaystyle - B k_{b} + \left(- B + T_{B}\right) \left(\frac{T_{A} k_{AB} \left(I k_{IA} + k_{A}\right)}{B k_{Ba} + I k_{IA} + k_{A} + k_{a}} + k_{B}\right)$

```python
solnB = sympy.solve(subsB, [B])[0]
solnB
```

$\displaystyle \frac{- I T_{A} k_{AB} k_{IA} - I k_{B} k_{IA} - I k_{IA} k_{b} - T_{A} k_{A} k_{AB} + T_{B} k_{B} k_{Ba} - k_{A} k_{B} - k_{A} k_{b} - k_{B} k_{a} - k_{a} k_{b} - \sqrt{I^{2} T_{A}^{2} k_{AB}^{2} k_{IA}^{2} + 2 I^{2} T_{A} k_{AB} k_{B} k_{IA}^{2} + 2 I^{2} T_{A} k_{AB} k_{IA}^{2} k_{b} + I^{2} k_{B}^{2} k_{IA}^{2} + 2 I^{2} k_{B} k_{IA}^{2} k_{b} + I^{2} k_{IA}^{2} k_{b}^{2} + 2 I T_{A}^{2} k_{A} k_{AB}^{2} k_{IA} + 2 I T_{A} T_{B} k_{AB} k_{B} k_{Ba} k_{IA} + 4 I T_{A} T_{B} k_{AB} k_{Ba} k_{IA} k_{b} + 4 I T_{A} k_{A} k_{AB} k_{B} k_{IA} + 4 I T_{A} k_{A} k_{AB} k_{IA} k_{b} + 2 I T_{A} k_{AB} k_{B} k_{IA} k_{a} + 2 I T_{A} k_{AB} k_{IA} k_{a} k_{b} + 2 I T_{B} k_{B}^{2} k_{Ba} k_{IA} + 2 I T_{B} k_{B} k_{Ba} k_{IA} k_{b} + 2 I k_{A} k_{B}^{2} k_{IA} + 4 I k_{A} k_{B} k_{IA} k_{b} + 2 I k_{A} k_{IA} k_{b}^{2} + 2 I k_{B}^{2} k_{IA} k_{a} + 4 I k_{B} k_{IA} k_{a} k_{b} + 2 I k_{IA} k_{a} k_{b}^{2} + T_{A}^{2} k_{A}^{2} k_{AB}^{2} + 2 T_{A} T_{B} k_{A} k_{AB} k_{B} k_{Ba} + 4 T_{A} T_{B} k_{A} k_{AB} k_{Ba} k_{b} + 2 T_{A} k_{A}^{2} k_{AB} k_{B} + 2 T_{A} k_{A}^{2} k_{AB} k_{b} + 2 T_{A} k_{A} k_{AB} k_{B} k_{a} + 2 T_{A} k_{A} k_{AB} k_{a} k_{b} + T_{B}^{2} k_{B}^{2} k_{Ba}^{2} + 2 T_{B} k_{A} k_{B}^{2} k_{Ba} + 2 T_{B} k_{A} k_{B} k_{Ba} k_{b} + 2 T_{B} k_{B}^{2} k_{Ba} k_{a} + 2 T_{B} k_{B} k_{Ba} k_{a} k_{b} + k_{A}^{2} k_{B}^{2} + 2 k_{A}^{2} k_{B} k_{b} + k_{A}^{2} k_{b}^{2} + 2 k_{A} k_{B}^{2} k_{a} + 4 k_{A} k_{B} k_{a} k_{b} + 2 k_{A} k_{a} k_{b}^{2} + k_{B}^{2} k_{a}^{2} + 2 k_{B} k_{a}^{2} k_{b} + k_{a}^{2} k_{b}^{2}}}{2 k_{Ba} \left(k_{B} + k_{b}\right)}$

```python
# Eigenvalues are negative if trace is negative and determinant is positive
modelPushPull.jacobianMat
```

$\displaystyle \left[\begin{matrix}- B k_{Ba} - I k_{IA} - k_{A} - k_{a} & - A k_{Ba}\\k_{AB} \left(- B + T_{B}\right) & - A k_{AB} - k_{B} - k_{b}\end{matrix}\right]$

**Observations**
1.

# Push Pull from Group Presentation

**Have wrong model**

```python
su.addSymbols("A_1 A_2 A_3 A_4 T_1 T_2 T_3 T_4 K_1 K_2 K_3 K_4 k_1 k_2 k_12 k_4 S_0 S_1")
```

```python
# State equations
stateDct = {
    A_1: K_1 * S_0 * (T_1 - A_1) - k_1 * A_1,
    A_2: K_2 * A_1 * (T_2 - A_2) - k_12 * A_2 * (T_1 - A_1),
}
modelSig = ODEModel(stateDct, isEigenvecs=False)
```

```python
stateDct[A_1]
```

```python
stateDct[A_2]
```

```python
len(modelSig.fixedPoints)
```

```python
fpDct = modelSig.fixedPoints[0].valueDct
fpDct[A_1]
```

```python
fpDct[A_2]
```

```python
entries = modelSig.fixedPoints[0].eigenEntries
eigenvalues = [e.value for e in entries]
eigenvalues[0]
```

```python
eigenvalues[1]
```

# Glycolytic Oscillations

```python
model = """
// Created by libAntimony v2.12.0.3
model *Jana_WolfGlycolysis()

  // Compartments and Species:
  compartment compartment_;
  species Glucose in compartment_, fructose_1_6_bisphosphate in compartment_;
  species glyceraldehyde_3_phosphate in compartment_, glycerate_3_phosphate in compartment_;
  species pyruvate in compartment_, Acetyladehyde in compartment_, External_acetaldehyde in compartment_;
  species ATP in compartment_, ADP in compartment_, NAD in compartment_, NADH in compartment_;
  species $External_glucose in compartment_, $ethanol in compartment_, $Glycerol in compartment_;
  species $Sink in compartment_;

  // Reactions:
  J0: $External_glucose => Glucose; J0_inputFlux;
  J1: Glucose + 2 ATP => fructose_1_6_bisphosphate + 2 ADP; J1_k1*Glucose*ATP*(1/(1 + (ATP/J1_Ki)^J1_n));
  J2: fructose_1_6_bisphosphate => 2 glyceraldehyde_3_phosphate; J2_k*fructose_1_6_bisphosphate;
  J3: glyceraldehyde_3_phosphate + NADH => NAD + $Glycerol; J3_k*glyceraldehyde_3_phosphate*NADH;
  J4: glyceraldehyde_3_phosphate + ADP + NAD => ATP + glycerate_3_phosphate + NADH; (J4_kg*J4_kp*glyceraldehyde_3_phosphate*NAD*ADP - J4_ka*J4_kk*glycerate_3_phosphate*ATP*NADH)/(J4_ka*NADH + J4_kp*ADP);
  J5: glycerate_3_phosphate + ADP => ATP + pyruvate; J5_k*glycerate_3_phosphate*ADP;
  J6: pyruvate => Acetyladehyde; J6_k*pyruvate;
  J7: Acetyladehyde + NADH => NAD + $ethanol; J7_k*Acetyladehyde*NADH;
  J8: Acetyladehyde => External_acetaldehyde; J8_k1*Acetyladehyde - J8_k2*External_acetaldehyde;
  J9: ATP => ADP; J9_k*ATP;
  J10: External_acetaldehyde => $Sink; J10_k*External_acetaldehyde;

  // Species initializations:
  Glucose = 0;
  fructose_1_6_bisphosphate = 0;
  glyceraldehyde_3_phosphate = 0;
  glycerate_3_phosphate = 0;
  pyruvate = 0;
  Acetyladehyde = 0;
  External_acetaldehyde = 0;
  ATP = 3;
  ADP = 1;
  NAD = 0.5;
  NADH = 0.5;
  External_glucose = 0;
  ethanol = 0;
  Glycerol = 0;
  Sink = 0;

  // Compartment initializations:
  compartment_ = 1;

  // Variable initializations:
  J0_inputFlux = 50;
  J1_k1 = 550;
  J1_Ki = 1;
  J1_n = 4;
  J2_k = 9.8;
  J3_k = 85.7;
  J4_kg = 323.8;
  J4_kp = 76411.1;
  J4_ka = 57823.1;
  J4_kk = 23.7;
  J5_k = 80;
  J6_k = 9.7;
  J7_k = 2000;
  J8_k1 = 375;
  J8_k2 = 375;
  J9_k = 28;
  J10_k = 80;

  // Other declarations:
  const compartment_, J0_inputFlux, J1_k1, J1_Ki, J1_n, J2_k, J3_k;
  const J4_kg, J4_kp, J4_ka, J4_kk, J5_k, J6_k, J7_k, J8_k1, J8_k2;
  const J9_k, J10_k;
end
"""

rr = te.loada(model)
data = rr.simulate()
timeArr = data["time"]
_, ax = plt.subplots(1)
for column in data.colnames[1:]:
    ax.plot(timeArr, data[column])
plt.legend(data.colnames[1:], bbox_to_anchor=(1.1, 1.05))
```

```python
fixedPoints[0][Adp]
```

```python
rr.NADH, rr.NAD, rr.Glucose
```

```python
sympy.simplify(rr.NADH * rr.J3_k - rr.NAD * rr.J4_kg)
```

```python
stateSymbols = "Ach Adp Atp Fru Ghp Glu Gt3p Nad Nadh Pyr"
fluxSymbols = "J1 J2 J3 J4 J5 J6 J7 J8 J9 J10"
parameterSymbols = "J0 J1_k1 J1_Ki J1_n J2_k J3_k J4_kg J4_kp J4_k1 J4_kk J4_ka J5_k J6_k J7_k J8_k1 J8_k2 J9_k Eah"
su.addSymbols(stateSymbols + " " + parameterSymbols + " " + fluxSymbols)
```

```python
SUBS = {
    J0: 50 ,
    J1_k1: 550,
    J1_Ki: 1,
    J1_n: 4,
    J2_k: 9.8,
    J3_k: 85.7,
    J4_kg: 323.8,
    J4_kp: 76411.1,
    J4_ka: 57823.1,
    J4_kk: 23.7,
    J5_k: 80,
    J6_k: 9.7,
    J7_k: 2000,
    J8_k1: 375,
    J8_k2: 375,
    J9_k: 28,
    Eah: 0
}
```

```python
fluxes = [J1, J2, J3, J4, J5, J6, J7, J8, J9]
```

```python
fluxEprDct = {
    J1: J1_k1*Glu*(Atp/ (1 + (Atp/J1_Ki)**J1_n)),
    J2: J2_k*Fru,
    J3: J3_k*Ghp*Nadh,
    J4: (J4_kg*J4_kp*Ghp*Nad*Adp - J4_ka*J4_kk*Gt3p*Atp*Nadh)/(J4_ka*Nadh + J4_kp*Adp),
    J5: J5_k*Gt3p*Adp,
    J6: J6_k*Pyr,
    J7: J7_k*Ach*Nadh,
    J8: J8_k1*Ach - J8_k2*Eah,
    J9: J9_k*Atp,
          }

# Derivatives of the state variables in terms of fluxes
dstateFluxDct = {
    Ach: J7 + J8 - J6,
    Adp: 2*J1 - J4 - J5 + J9,
    Atp: -2 * J1 + J4 + J5 - J9,
    Fru: J1 - J2,
    Ghp: -2*J2 + J3 - J4,
    Glu: J0 - J1,
    Gt3p: J4 - J5,
    Nad: J3 - J4 + J7,
    Nadh: -J3 - J7 + J4,
    Pyr: J5 - J6,
}
```

```python
# Exploration of using fluxes to find fixed points
vec = sympy.Matrix(list(dstateFluxDct.values()))
mat = vec.jacobian(fluxes)
nullspaceVec = mat.nullspace()[0]
{s: v for s, v in zip(fluxes, nullspaceVec)}
```

```python
fluxFixedPoint = {f: v for f, v in zip(fluxes, mat.nullspace()[0])}
fluxFixedPoint
```

```python
# Solve for the chemical species
eprVec = sympy.Matrix(list(fluxEprDct.values())) - nullspaceVec
eprVec
```

```python
# Provide helper substitutions for solver
subs = {Glu: 0, # from J1, J9 (since Atp > 0)
        Fru: 0, # from J2
        Ach: 0, # from J8
        Atp: 1/J9_k, # from J9
       }
fixedPoints = sympy.solve(eprVec.subs(subs))
```

```python
fixedPoints[0]
```

```python
fixedPoints[1]
```

```python
dct = {s: fixedPoints[0][s] - fixedPoints[1][s] for s in fixedPoints[0].keys() if fixedPoints[0][s] - fixedPoints[1][s] != 0}
dct.keys()
```

The denominator is negative with the ending values of the simulations. And so, only one of these is positive.

```python
fixedPoints[0][Adp]
```

```python
#
fixedPoints[1][Adp]
```

```python
fluxEprDct[J2]
```

```python
fluxEprDct[J3]
```

```python
fluxEprDct[J4]  # How interpret this expression? Note the cubic interaction terms.
# One possibility is to treat this as a separate variable.
```

```python
fluxEprDct[J5]
```

```python
fluxEprDct[J6]
```

```python
fluxEprDct[J7]
```

```python
fluxEprDct[J8]
```

```python
fluxEprDct[J9]
```

## Procedure for finding fixed points

1. Let ${\bf v}^J$ be such that $v^J_i$ is the kinetics for reaction $i$.
1. Calculate the stoichiometry matrix $N$. Note that $\frac{\bf \partial x}{\bf \partial J} = {\bf N}$,
where ${\bf x}$ is the vector of chemical species.
1. Calculate ${\bf b}_1, \cdots, {\bf b}_n$, a basis for the nullspace of ${\bf N}$. A fixed point must lie in this null space.
1. Solve the system of equations ${\bf v}^J = {\bf b}_i$. This may require adding constraints
to the state variables based on ${\bf b}_i$.

## Manual solution to equations

```python
# The strategy is to solve the equations in terms of Atp, Adp
# Key: state variable
# Value: dStateVariable in terms of Atp, Adp
fullSolnDct = {}
fullSolnDct[J9] = fluxEprDct[J9]
fullSolnDct[Ach] = sympy.solve(fluxEqnDct[J8], [Ach])[0]
fullSolnDct[Nadh] = sympy.solve(fluxEqnDct[J7].subs(fullSolnDct), [Nadh])[0]
fullSolnDct[Pyr] = sympy.solve(fluxEqnDct[J6].subs(fullSolnDct), [Pyr])[0]
fullSolnDct[Fru] = sympy.solve(fluxEqnDct[J2].subs(fullSolnDct), [Fru])[0]
fullSolnDct[Glu] = sympy.solve(fluxEqnDct[J1].subs(fullSolnDct), [Glu])[0]
fullSolnDct[Gt3p] = sympy.solve(fluxEqnDct[J5].subs(fullSolnDct), [Gt3p])[0]
fullSolnDct[Ghp] = sympy.solve(fluxEqnDct[J3].subs(fullSolnDct), [Ghp])[0]
fullSolnDct[Nad] = sympy.solve(fluxEqnDct[J4].subs(fullSolnDct), [Nad])[0]
fullSolnDct
```

```python
# Verify that only have Atp, Adp as state variables
freeSymbols = set([])
for soln in fullSolnDct.values():
    freeSymbols = freeSymbols.union(soln.free_symbols)
freeSymbols
```

```python
# Search for values Atp, Adp that result in 0 flux
def calcLoss(atp, adp, fluxDct, subs=SUBS):
    """
    Calculates the squared value of the sum of the fluxes for the value of atp, adp
    """
    solnDct = {}
    fullSubs = dict(subs)
    fullSubs[Atp] = atp
    fullSubs[Adp] = adp
    for key, value in fluxDct.items():
        solnDct[key] = sympy.simplify(fluxDct[key].subs(fullSubs))
    #
    return sympy.simplify(sum([solnDct[k]**2 for k in solnDct.keys()]))

# Tests
calcLoss(100, 100, fullSolnDct)
```

# Architecture II: No M

```python
def init2Equations():
  statements = [
      "dA = K_A * N - Kd_A * A * P",
      "dP = K_P * A - k_0",
      "equations = [dA, dP]",
  ]
  return "; ".join(statements)
```

```python
# Case 2(a): P inhibits A
if False:
    exec(init2Equations())
    sympy.solve([dP], A)
    # How much protein is required
    newdA = dA.subs(A, k_0/K_P)
    sympy.solve(newdA, P)
```

```python

```

# Tracking Control

```python
# Can still get integral control with unimolecular degradation for A, M in A. Why biomolecular in model?
exec(initEquations())
dA = K_A * N - Kd_A * (A + M)
#
sympy.solve([dP], A)
```

Architecture: P degradation depends on tracking molecule, but it cannot change the level of the molecule.

```python
MODEL = """
P -> A; KP_1 * P * M
A -> P; -KA_1 * A
A ->; KA * $M
// E -> A; KE * A

$M = 1
KP_1 = 1
KP_2 = 1
KA_1 = 1
KA = 1
KE = 1
"""

rr = te.loada(MODEL)
rr.plot(rr.simulate())
```

```python
def init3Equations():
  statements = [
      "dA = K_A * N - Kd_A * A * P",
      "dP = K_P * A - k_0 * M",
      "dM = -k_0 + K_Mp * Mp",
      "dMp = K_M * M - K_Mp * Mp",
      "equations = [dA, dP]",
  ]
  return "; ".join(statements)
```

```python
exec(init3Equations())
sympy.solve([dP], A)
```

```python
exec(init3Equations())
sympy.solve(equations, A)
```

# Integral Control Paper 1

[Feedback Designs](https://drive.google.com/file/d/1mP0A5jvdlVCtdFCy6QbapDgcsWC_LzmU/view?usp=sharing)

The system including $P$ phosphorylation and dephosphorylation.

* $\dot{S} = k_0 x_0 - k_2 P  + k_6 E$
* $\dot{P} = k_4 x_0 - k_7 P + k_8 Pp$.
* $\dot{Pp} = k_7 P - k_8 Pp$
* $\dot{x}_1 = k_5 S$

**LaPlace Transforms.**

$s S(s) = k_0 X_0 (s) - k_2 P(s) +k_6 E(s)$

$s P(s) = k_4 X_0 (s) -k_7 P(s) + k_8 Pp(s)$

$s Pp(s) = k_7 P(s) - k_8 Pp(s)$
Or, $Pp(s) = P(s) \frac{k_7}{s + k_8}.$

So,
\begin{align*}
sP(s) & = & k_4 X_0 (s) -k_7 P(s) + k_8 Pp(s) \\
& = & k_4 X_0 (s) -k_7 P(s) + k_8 P(s) \frac{k_7}{s + k_8} \\
P(s)& = & X_0 (s) \frac{k_4 }{s + k_7 - \frac{k_7 k_8 }{s + k_8}}
\end{align*}

With this,
\begin{align*}
S(s) = X_0(s) \frac{ k_0 - k_2 \frac{k_4 }{s + k_7 - \frac{k_7 k_8 }{s + k_8}}}{s + k_7} +  E(s) \frac{k_6}{s + k_3}
\end{align*}

\begin{align*}
X_1(s) & = & \frac{k_5}{s}S(s) \\
& = & X_0(s) \frac{k_5}{s}  \frac{ k_0  - k_2 \frac{k_4 }{s + k_7 - \frac{k_7 k_8 }{s + k_8}}}{s + k_7} +  E(s) \frac{k_5}{s} \frac{k_6}{s + k_3}
\end{align*}

This suggests that the impulse response is finite but the step response is unbounded. Also, $x_t(t) \neq 0$ for a non-zeror $e(t)$.

Note that $k_8$ must be small for the integrator to work.
So,
\begin{align*}
X_1(s) & \approx & X_0(s) \frac{k_5}{s}  \frac{ k_0 - k_2 \frac{k_4 }{s + k_7 }}{s + k_7} +  E(s) \frac{k_5}{s} \frac{k_6}{s + k_3}
\end{align*}

Observe that for an impulse $x_0$,
$x_1 (\infty) =  k_5 \frac{ k_0 k_7 - k_2 k_4}{ k_7^2} .$

Solve for $H_E (s) = \frac{X_1(s)}{E(s)}$.

$X_0(s) = 0$

$s S(s) =  - k_3 S(s) + k_6 E(s)$ And so,
$S(s) = \frac{k_6 E(s)}{s + k_3}$

Further,
$X_1(s) = \frac{k_5}{s}\frac{k_6 E(s)}{s + k_3} .$

Suggest that there is an error with the impulse response, and the system does not converge with the step response.

Solve for transfer function: $H(s) = \frac{X_1(s)}{X_0(s)}$.

$s S(s) = X_0 (s) \frac{k_0 - k_2 k_4 }{s + k_3}$

$
\begin{align*}
X_1 (s) & = &  \frac{k_5}{s} S(s) \\
& = & X_0 (s) \frac{k_5}{s^2}  \frac{k_0 - k_2 k_4 }{s + k_3} \\
H(s) & = & \frac{1}{s^2} \frac{k_0 - k_2 k_4 }{s + k_3}
\end{align*}
$

$H (0)$ is constant and so the output tracks the input. However, the system has complex dynamics.

```python
# Simulation
MODEL = """
$X0 -> S; k0*X0 - k2*P
S -> X1; k4*X0
S -> P; k4*X0
P -> Pp; k7*P
Pp -> P; k8*Pp

$X0 = 1
k0 = 1
k2 = 1
k3
"""
```

# Integral Paper 2
Analysis of Herbert's early draft.

```python
su.addSymbols("P S S_1 S_2 _3")
```

## Model

* $P \xrightarrow{v_r} S$ has kinetics $v_r (P, E_r) = \frac{k_r E_r P}{K_{M_{r}} + P}$
* $S \xrightarrow{v_f} P$ has kinetics $v_f (S, E_f)
= k_f E_f$ because $S$ saturates the reaciton.

Consider region where $K_{M_r} >> P$ and $E_r$ is stable.
Then, $v_r(P, E_r) \approx k_r E_r P$.

The LaPlace transforms are:
* $\frac{S(s)}{P(s)} = \frac{1}{s} k_r E_r$
* $\frac{S(s)}{E(s)} = \frac{T}{s^2} -  \frac{1}{s} k_f E_f(s)$


# 14 Partial Differential Equations — 1

## Solving Laplace's or Poisson's equation

**Poisson's equation** for the electric potential $\Phi(\mathbf{r})$ and the charge density $\rho(\mathbf{r})$:

$$
\nabla^2 \Phi(x, y, z) = -4\pi\rho(x, y, z)\\
$$

For a region of space without charges ($\rho = 0$) this reduces to **Laplace's equation**

$$
\nabla^2 \Phi(x, y, z) = 0
$$

Solutions depend on the **boundary conditions**:

* the *value of the potential* on the *boundary* or
* the *electric field* (i.e. the derivative of the potential, $\mathbf{E} = -\nabla\Phi$ *normal to the surface* ($\mathbf{n}\cdot\mathbf{E}$), which directly follows from the charge distribution).

### Example: 2D Laplace equation
$$
\frac{\partial^2 \Phi(x,y)}{\partial x^2} + \frac{\partial^2 \Phi(x,y)}{\partial y^2} = 0
$$
("elliptic PDE")

Boundary conditions:
* square area surrounded by wires
* three wires at ground (0 V), one wire at 100 V

## Finite difference algorithm for Poisson's equation
Discretize space on a lattice (2D) and solve for $\Phi$ on each lattice site.

Taylor-expansion of the four neighbors of $\Phi(x, y)$:

\begin{align}
\Phi(x \pm \Delta x, y) &= \Phi(x, y) \pm \Phi_x \Delta x + \frac{1}{2} \Phi_{xx} \Delta x^2 + \dots\\
\Phi(x, y \pm \Delta y) &= \Phi(x, y) \pm \Phi_y \Delta x + \frac{1}{2} \Phi_{yy} \Delta x^2 + \dots\\
\end{align}

Add equations in pairs: odd terms cancel, and **central difference approximation** for 2nd order partial derivatives (to $\mathcal{O}(\Delta^4)$):

\begin{align}
\Phi_{xx}(x,y) = \frac{\partial^2 \Phi}{\partial x^2} & \approx
  \frac{\Phi(x+\Delta x,y) + \Phi(x-\Delta x,y) - 2\Phi(x,y)}{\Delta x^2} \\
\Phi_{yy}(x,y) = \frac{\partial^2 \Phi}{\partial y^2} &\approx
  \frac{\Phi(x,y+\Delta y) + \Phi(x,y-\Delta y) - 2\Phi(x,y)}{\Delta y^2}
\end{align}

Take $x$ and $y$ grids of equal spacing $\Delta$: Discretized Poisson equation

$$
\begin{split}
\Phi(x+\Delta x,y) + \Phi(x-\Delta x,y) +\Phi(x,y+\Delta y) &+ \\
   +\, \Phi(x,y-\Delta y) - 4\Phi(x,y) &= -4\pi\rho(x,y)\,\Delta^2
   \end{split}
$$

Defines a system of $N_x \times N_y$ simultaneous algebraic equations for $\Phi_{ij}$ to be solved.

Can be solved directly via matrix approaches (and then is the best solution) but can be unwieldy for large grids.

Alternatively: **iterative solution**:

$$
\begin{split}
4\Phi(x,y) &= \Phi(x+\Delta x,y) + \Phi(x-\Delta x,y) +\\
 &+ \Phi(x,y+\Delta y) + \Phi(x,y-\Delta y) + 4\pi\rho(x,y)\,\Delta^2
\end{split}
$$

Or written for lattice sites $(i, j)$ where

$$
x = x_0 + i\Delta\quad\text{and}\quad y = y_0 + j\Delta, \quad 0 \leq i,j < N_\text{max}
$$

$$
\Phi_{i,j} = \frac{1}{4}\Big(\Phi_{i+1,j} + \Phi_{i-1,j} + \Phi_{i,j+1} + \Phi_{i,j-1}\Big)
     + \pi\rho_{i,j} \Delta^2
$$

* Converged solution at $(i, j)$ will be the average potential from the four neighbor sites + charge density contribution.
* *Not a direct solution*: iterate and hope for convergence.

#### Jacobi method
Do not change $\Phi_{i,j}$ until a complete sweep has been completed.

#### Gauss-Seidel method
Immediately use updated new values for $\Phi_{i-1, j}$ and $\Phi_{i, j-1}$ (if starting from $\Phi_{1, 1}$).

Leads to *accelerated convergence* and therefore *less round-off error* (but distorts symmetry of boundary conditions... hopefully irrelevant when converged but check!)

### Solution via relaxation (Gauss-Seidel)

Solve the box-wire problem on a lattice: The wire at $x=0$ (the $y$-axis) is at 100 V, the other three sides of the box are grounded (0 V).

Note: $\rho=0$ inside the box.

Note for Jupyter notebook use:
* For interactive 3D plots, select
  ```
  %matplotlib notebook
  ```
* For standard inline figures (e.g. for exporting the notebook to LaTeX/PDF or html) use
  ```
  %matplotlib inline
  ```

```python
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
# %matplotlib inline
%matplotlib notebook
```

```python
%matplotlib inline
```

#### Wire on a box: Solution of Laplace's equation with the Gauss-Seidel algorithm

```python
Nmax = 100
Max_iter = 70
Phi = np.zeros((Nmax, Nmax), dtype=np.float64)

# initialize boundaries
# everything starts out zero so nothing special for the grounded wires
Phi[0, :] = 100     # wire at x=0 at 100 V

Nx, Ny = Phi.shape

for n_iter in range(Max_iter):
    for xi in range(1, Nx-1):
        for yj in range(1, Ny-1):
            Phi[xi, yj] = 0.25*(Phi[xi+1, yj] + Phi[xi-1, yj]
                                + Phi[xi, yj+1] + Phi[xi, yj-1])
```

#### Visualization of the potential

```python
# plot Phi(x,y)
x = np.arange(Phi.shape[0])
y = np.arange(Phi.shape[1])
X, Y = np.meshgrid(x, y)

Z = Phi[X, Y]
```

```python
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
ax.plot_wireframe(X, Y, Z, rstride=2, cstride=2)

ax.set_xlabel('X')
ax.set_ylabel('Y')
ax.set_zlabel(r'potential $\Phi$ (V)')

ax.view_init(elev=40, azim=-65)
```

Nicer plot (use this code for other projects):

```python
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')

ax.plot_wireframe(X, Y, Z, rstride=2, cstride=2, linewidth=0.5, color="gray")
surf = ax.plot_surface(X, Y, Z, cmap=plt.cm.coolwarm, alpha=0.6)
cset = ax.contourf(X, Y, Z, zdir='z', offset=-50, cmap=plt.cm.coolwarm)

ax.set_xlabel('X')
ax.set_ylabel('Y')
ax.set_zlabel(r'potential $\Phi$ (V)')
ax.set_zlim(-50, 100)
ax.view_init(elev=40, azim=-65)

cb = fig.colorbar(surf, shrink=0.5, aspect=5)
cb.set_label(r"potential $\Phi$ (V)")
```

(Note that the calculation above is is *not converged* ... see next lecture.)


# Sympy: Symbolic Mathematics in Python

SymPy is a Python library for symbolic mathematics. It aims to be an alternative to systems such as Mathematica and Wolfram Walpha while keeping the code as simple as possible and easily extensible. SymPy is written entire in Python and does not require any external libraries.

SymPy documentation and packages for installation can be found at https://www.sympy.org/. Much of this material is drawn from the SciPy lecture notes, found here: https://www.scipy-lectures.org/packages/sympy.html.

```python
import sympy as sym
```

SymPy allows for control of the display of the output. From here we can use the following setting for fancy printing:

```python
sym.init_printing()
```

## First Steps

SymPy defines three numerical types: `Real`, `Rational` and `Integer`.

The Rational class represents a rational number as a pair of two Integers: the numerator and the denominator, so `Rational(1, 2)` represents $\frac{1}{2}$, `Rational(5, 2)` represents $\frac{5}{2}$ and so on.

```python
a = sym.Rational(1,2)
a
```

```python
a*2
```

SymPy uses `mpmath` in the background, which makes it possible to perform computations using arbitrary-precision arithmetic. That way, some constants such as $e$, $\pi$, $\inf$, are treated as symbols and can be evaluated with arbitrary precision:

```python
sym.pi**2
```

```python
sym.pi.evalf()
```

```python
(sym.pi + sym.exp(1)).evalf()
```

`evalf` evaluates the expression to a floating-point number. This can be potentially up to $n$ precision, as needed:

```python
sym.pi.evalf(50)
```

## Symbols

In constrast to many other Computer Algebra systems, in SymPy you have to declare symbolic variables explicitly:

```python
x = sym.Symbol("x")
y = sym.Symbol("y")
```

Then you can manipulate them:

```python
x + y + x - y
```

```python
(x + y)**2
```

Multiple symbols can be defined at once, as:

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

## Basic Operations

Here we show some of the most basic operations needed for expression manipulation in SymPy.

### Substitution

One of the most common things you might want to do with a mathematical expression is *substitution*. Substitution replaces all instances of something in an expression with something else. It is achieved using the `subs` method.

```python
expr = sym.cos(x) + 1
expr.subs(x, y)
```

Substitution is usually done for one of two reasons:

1. Evaluating an expression at a point. For instance, if our expression is $\cos(x)+1$ and we want to evaluate it at the point $x=0$, such that we get $\cos(0)+1=2$.

```python
expr.subs(x, 0)
```

2. Replacing a subexpression with another subexpression. There are a number of reasons why we want to do this. The first is that if we are trying to build an expression with symmetry, such as x^x^x.

```python
expr = x ** y
expr
```

```python
expr = expr.subs(y, x**y)
expr
```

```python
expr = expr.subs(y, x**x)
expr
```

Another case is if we want to perform controlled simplification, or a simplification that SymPy is otherwise unable to do. For example, take $\sin(2x)+\cos(2x)$, and we want to replace $\sin(2x)$ with $2\sin(x)\cos(x)$. The function `expand_trig()` achieves this, but will also expand $\cos(2x)$, which we may not want. One of the easiest ways to prevent this is a manual substitution:

```python
expr = sym.sin(2*x) + sym.cos(2*x)
sym.expand_trig(expr)
```

```python
expr.subs(sym.sin(2*x), 2*sym.sin(x)*sym.cos(x))
```

### Converting Strings to SymPy Expressions

The function `sympify` can be used to convert Python strings into SymPy expressions:

```python
str_expr = "x**2 + 3*x - 1/2"
sym.sympify(str_expr)
```

**WARNING**: `sympify` uses `eval`. Does use it on unsanitized input.

### lambdify

`subs` and `evalf` are good if you wish to do simple evaluation, but if you intend to evaluate an expression at many points, there are more efficient ways. For example, if you wanted to evaluate an expression with 1000 points, using SymPy would be far slower than it needs to be, especially if you care about machine precision. Instead, use libraries like `NumPy` and `SciPy`.

Alternatively, a SymPy expression can be converted to be numerically evaluated using `lambdify`. This acts as a lambda function, except it converts the SymPy names to the names of the given numerical library, usually NumPy.

```python
import numpy as np
a = np.arange(10)
expr = sym.sin(x)
f = sym.lambdify(x, expr, "numpy")
f(a)
```

    array([ 0.        ,  0.84147098,  0.90929743,  0.14112001, -0.7568025 ,
           -0.95892427, -0.2794155 ,  0.6569866 ,  0.98935825,  0.41211849])

## Algebraic Manipulations

SymPy is capable of performing powerful algebraic manipulations. We'll take a look into some of the most frequently used: expand and simplify.

When we expand an algebraic expression, SymPy will try to denest powers and multiplications:

```python
sym.expand((x + y) ** 3)
```

Further options can be given in form on keywords:

```python
sym.expand(x + y, complex=True)
```

```python
sym.expand(sym.cos(x + y), trig=True)
```

We can use simplify to transform an expression into a *simpler* form:

```python
sym.simplify((x + x * y) / x)
```

```python
sym.simplify(sym.gamma(x) / sym.gamma(x - 2))
```

Where `sym.gamma(x)` is $\Gamma(x)$, the gamma function.

`collect()` collects common powers of a term in an expression, for example:

```python
expr = x*y + x - 3 + 2*x**2 - z*x**2 + x**3
expr
```

```python
sym.collect(expr, x)
```

### Power Simplifications

Before we introduce the power simplification functions, we will cover the basic mathematical identities held by powers. There are three kinds of identities satisfied by exponents:

1. $x^ax^b=x^{a+b}$
2. $x^ay^a=(xy)^a$
3. $(x^a)^b=x^{ab}$

Identity 1 is always true. Identities 2 and 3 are not always true, with specific examples not covered in this material.

```python
a, b = sym.symbols("a b", real=True)
x, y = sym.symbols("x y", positive=True)
sym.powsimp(x**a*x**b)
```

```python
sym.powsimp(x**a*y**a)
```

### Exponential and logarithms

Logarithms have similar issues as powers, there are two main identities:

1. $\log(xy)=\log(x) + \log(y)$
2. $\log(x^n)=n\log(x)$

Neither identity is true for arbitrary complex $x$ and $y$, due to the branch cut in the complex plane for the complex algorithm. The identities hold if $x$ and $y$ are positive and $n$ is real.

```python
n = sym.Symbol("n", real=True)
```

Note that identity:

$$
\log\left(\frac{x}{y}\right)=\log(x)-\log(y)
$$

is a special case of identities 1 and 2, and holds if $x$ and $y$ are positive. In addition,

$$
\log(e^x)=x \\
\log(e^x)=x\log(e)=x
$$

and holds when $x$ is real.

```python
sym.expand_log(sym.log(x*y))
```

```python
sym.expand_log(sym.log(x/y))
```

### Special Functions

```python
sym.factorial(n)
```

```python
sym.binomial(n, k)
```

```python
sym.gamma(z)
```

## Calculus

Now we get into the juicy stuff! SymPy can perform a host of impressive calculus operations:

### Limits

Limits are easy to use in SymPy, they follow the syntax `limit(function, variable, point)`, so to compute the limit of $f(x)$ as $x \to 0$, you would issue `limit(f, x, 0)`:

```python
sym.limit(sym.sin(x) / x, x, 0)
```

We can also calculate the limit at infinity:

$$
\lim_{x \to \infty} x
$$

```python
sym.limit(x, x, sym.oo)
```

$$
\lim_{x \to \infty} \frac{1}{x}
$$

```python
sym.limit(1 / x, x, sym.oo)
```

$$
\lim_{x \to 0} x^x
$$

```python
sym.limit(x**x, x, 0)
```

Limit as an unevaluated counterpart, `Limit`:

```python
expr = sym.Limit((sym.cos(x) - 1)/x, x, 0)
expr
```

```python
expr.doit()
```

### Differentiation

You can differentiate any SymPy expression using `diff(func, var)`, for example:

$$
\frac{\text{d}\cos(x)}{\text{d}x}=-\sin(x)
$$

```python
sym.diff(sym.cos(x), x)
```

$$
\frac{\text{d}\sin(2x)}{\text{d}x}=2\cos(2x)
$$

```python
sym.diff(sym.sin(2*x), x)
```

```python
sym.diff(sym.tan(x), x)
```

Higher derivatives can be calculated by adding the parameter $n$ to the method, as follows:

```python
sym.diff(sym.sin(2*x), x, 2)
```

```python
sym.diff(sym.sin(2*x), x, 3)
```

`diff` can also take multiple derivatives at once. To take multiple derivatives, pass the variable as many times as you wish to differentiate, or pass a number:

```python
sym.diff(x**4, x, x, x)
```

You can also take derivatives with respect to many variables at once. Just pass each derivative in order, using the same single as for single variable derivatives:

```python
expr = sym.exp(x*y*z)
expr
```

```python
sym.diff(expr, x, y, y, z, z, x)
```

To create an unevaluated derivative, use the `Derivative` class. It has the same syntax as `diff()`:

```python
deriv = sym.Derivative(expr, x, y, y, z, z, x)
deriv
```

```python
deriv.doit()
```

### Series Expansion

SymPy also knows how to compute the Taylor series of an expression at a point. Use `series(expr, var)`, like so:

```python
sym.series(sym.cos(x), x)
```

```python
sym.series(1 / sym.cos(x), x)
```

```python
expr = sym.exp(sym.sin(x))
expr.series(x, 0, 4)
```

### Integration

SymPy has support for indefinite and definite integration of transcendental elementary and special functions via `integrate()`, which uses the powerful extended Risch-Norman algorithm, some heuristics and pattern matching. You can integrate elementary functions:

$$
\int_{-\infty}^{\infty} 6x^5 dx
$$

```python
sym.integrate(6 * x**5, x)
```

$$
\int_{-\infty}^{\infty} \sin(x) dx
$$

```python
sym.integrate(sym.sin(x), x)
```

$$
\int_{-\infty}^{\infty} \log(x) dx
$$

```python
sym.integrate(sym.log(x), x)
```

$$
\int_{-\infty}^{\infty} 2x + \sinh(x)
$$

```python
sym.integrate(2*x + sym.sinh(x), x)
```

Also special functions are handled rather nicely:

$$
\int_{-\infty}^{\infty} e^{x^2} \text{erf}(x)
$$

```python
sym.integrate(sym.exp(-x**2) * sym.erf(x), x)
```

It is possible to compute definite integrals assuming that the lower and upper bounds of an interval are provided:

$$
\int_{-1}^1 x^3 dx
$$

```python
sym.integrate(x**3, (x, -1, 1))
```

$$
\int_0^{\pi/2} \sin(x) dx
$$

```python
sym.integrate(sym.sin(x), (x, 0, sym.pi / 2))
```

$$
\int_{-\pi/2}^{\pi/2} \cos(x) dx
$$

```python
sym.integrate(sym.cos(x), (x, -sym.pi / 2, sym.pi / 2))
```

Definite integrals also support improper integrals:

$$
\int_0^{\infty} e^{-x} dx
$$

```python
sym.integrate(sym.exp(-x), (x, 0, sym.oo))b
```

As with `Derivative`, you can create an unevaluated integral using `Integral`. To later evaluate this integral, call `doit()`:

```python
expr = sym.Integral(sym.log(x)**2, x)
expr
```

```python
expr.doit()
```

```python
y = sym.symbols("y")
integ = sym.Integral(x**y*sym.exp(-x), (x, 0, sym.oo))
integ.doit()
```

$$\begin{cases} \Gamma{\left(y + 1 \right)} & \text{for}\: - \Re{\left(y\right)} < 1 \\\int_{0}^{\infty} x^{y} e^{- x}\, dx & \text{otherwise} \end{cases}$$

### Finite Differences

So far we have looked at expressions with analytic derivatives and primitive functions respectively. But what if we want to have an expression to estimate a derivative of a curve for which we lack a closed form representation, or for which we don't know the functional values for yet. One approach is using a *finite difference* approach.

```python
f, g = sym.symbols("f g", cls=sym.Function)
sym.differentiate_finite(f(x)*g(x))
```

If we want to expand the intermediate derivative we can pass the flag `evaluate=True`:

```python
sym.differentiate_finite(f(x)*g(x), evaluate=True)
```

This method can be applied to `Derivative` instances as such:

```python
f = sym.Function("f")
dfdx = f(x).diff(x)
dfdx.as_finite_difference()
```

```python
h = sym.Symbol("h")
d2fdx2 = f(x).diff(x, 2)
d2fdx2.as_finite_difference([-3*h, -h, 2*h])
```

## Equation solving

SymPy is able to solve algebraic equations, in one and several variables using the `solveset()` function:

```python
sym.solveset(x**4 - 1,b x)
```

As you can see it takes as a first argument an expression that is supposed to be equal to $0$. It also has limited support for transcendental equations:

```python
sym.solveset(sym.exp(x) + 1, x)
```

## Systems of linear equations

SymPy is able to solve a large part of polynomial equations, and is also capable of solving multiple equations with respect to multiple variables, by providing a tuple as a secondary argument. To do this we use the `solve()` command:

```python
(x + 5 * y - 2, -3 * x + 6 * y - 15)
```

```python
sol = sym.solve((x + 5 * y - 2, -3 * x + 6 * y - 15), (x, y))
sol
```

Another alternative in the case of polynomial equations is _factor_. Factor returns the polynomial factorized into irreducible terms, and is capable of computing the factorization over various domains:

```python
f = x ** 4 - 3 * x ** 2 + 1
sym.factor(f)
```

```python
sym.factor(f, modulus=5)
```

SymPy is also able to solve boolean equations, that is, to decide if a certain boolean equation is satisfiable or not. For this, we have the function `satisfiable()`:

```python
sym.satisfiable(x & ~x)
```

    False

## Linear Algebra

Now we've covered most of the basics, we probably want to scale the concepts mentioned previously up to *vectors* and *matrices*:

## Matrices

Matrices are created as instances from a `Matrix` class:

```python
sym.Matrix([[1, 0], [0, 1]])
```

$$\left[\begin{matrix}1 & 0\\0 & 1\end{matrix}\right]$$

Unlike a NumPy array, you can also put Symbols in it:

```python
x, y = sym.symbols("x, y")
A = sym.Matrix([[1, x], [y, 1]])
A
```

$$\left[\begin{matrix}1 & x\\y & 1\end{matrix}\right]$$

```python
A**2
```

$$\left[\begin{matrix}x y + 1 & 2 x\\2 y & x y + 1\end{matrix}\right]$$

SymPy also allows different manners of matrix creation, such as a list of values with the dimensional inputs separately:

```python
sym.Matrix(2, 3, [1, 2, 3, 4, 5, 6])
```

$$\left[\begin{matrix}1 & 2 & 3\\4 & 5 & 6\end{matrix}\right]$$

More interestingly, we can use a 2-variable function or `lambda` to make one. Here we create an indicator function which is 1 on the diagonal and then use it to make the *identity matrix*:

```python
def f(i, j):
    if i == j:
        return 1
    else:
        return 0

sym.Matrix(4,4,f)
```

$$\left[\begin{matrix}1 & 0 & 0 & 0\\0 & 1 & 0 & 0\\0 & 0 & 1 & 0\\0 & 0 & 0 & 1\end{matrix}\right]$$

```python
sym.Matrix(3, 4, lambda i,j: 1 - (i+j) % 2)
```

$$\left[\begin{matrix}1 & 0 & 1 & 0\\0 & 1 & 0 & 1\\1 & 0 & 1 & 0\end{matrix}\right]$$

There are a number of built-in special constructors for quick matrix construction - such as `eye`, `zeros`, `ones` and `diag`:

```python
sym.eye(3)
```

$$\left[\begin{matrix}1 & 0 & 0\\0 & 1 & 0\\0 & 0 & 1\end{matrix}\right]$$

```python
sym.zeros(2)
```

$$\left[\begin{matrix}0 & 0\\0 & 0\end{matrix}\right]$$

### Differential Equations

SymPy is capable of solving some Ordinary Differential. To solve differential equations, SymPy uses a function called `dsolve()`. First we create an undefined function by passed `cls=Function` to the symbols function:

```python
f,g = sym.symbols("f g", cls=sym.Function)
```

```python
f(x)
```

$f$ and $g$ are now undefined functions. We can call $f(x)$, and it will represent an unknown function.

```python
f(x).diff(x, x) + f(x)
```

```python
sym.dsolve(f(x).diff(x, x) + f(x), f(x))
```

Keywords arguments can be given to this function in order to help to find the best possible reso0lution system. For example, if you know that we are dealing with separable equations, you can use keyword `hint='separable'` to force `dsolve` to resolve it as such:

```python
sym.dsolve(sym.sin(x) * sym.cos(f(x)) + sym.cos(x) * sym.sin(f(x)) * f(x).diff(x),
           f(x), hint="separable")
```

```python
h = x*f(x).diff(x) + f(x)-f(x)**2
sym.dsolve(h, f(x))
```

We've barely scratched the surface of SymPy, please see the documentation and the main GitHub repository here for more information: https://github.com/sympy/sympy/wiki/Tutorial.

## Tasks

### Task 1

```python

```

```python

```

```python

```


# Averaging and sampling from an infinite stream of samples

## Wei Xu

Often we need to calculate the average or randomly select samples from an infinite
stream of samples. Suppose the $t$-th sample is $x_t$, a standard way of
calculating the average is:
$$
\begin{equation*}
\bar{x}_t = \left(1 - \frac{1}{t}\right)\bar{x}_{t-1} + \frac{1}{t} x_t
\end{equation*}
$$

If we want to randomly select $k$ samples, the standard way of doing this is the
so called [reservoir sampling](https://en.wikipedia.org/wiki/Reservoir_sampling).
What it does is to keep the $t$-th sample with probability $k/t$ at step $t$ and
randomly replacing one of the existing $k$ items.

Sometime, we want the average to give higher weight to more recent samples and
select recent samples with higher probability. A simple modification to the
moving average and reservoir sampling can achieve this.

## Streaming average with higher weight for recent samples

We only need to change the update rate of the moving average as the following:
$$
\begin{equation}
\bar{x}_t = \left(1 - \frac{s}{t}\right)\bar{x}_{t-1} + \frac{s}{t} x_t
\end{equation}
$$

Now we analyze how the above scheme assigns weight to different samples. Let
$\bar{x}_t = \sum_{i=1}^t w_{t,i} x_i$. Now the problem is to find out the values
of $w_{t,i}$.  We can see that $w_{t,t} = \frac{s}{t}$ and
$w_{t,i} = (1 - \frac{s}{t})w_{t-1,i}$ for $t>i$. So we have
$$
\begin{array}{ll}
w_{t,i} &=& \frac{s}{i} \prod_{j=i+1}^t (1 - \frac{s}{j}) \\
    &=& \frac{s}{i} \frac{i+1-s}{i+1} \cdots \frac{t-1-s}{t-1}\frac{t-s}{t} \\
    &=& s \frac{(i-s+1)(i-s+2) \cdots (i-1)}{(t-s+1)(t-s+2)\cdots t} \\
    &\approx& s \left(\frac{i}{t}\right)^{s-1}
\end{array}
$$
This means that the weight for the $i$-th sample is approximately proportional
to $i^{s-1}$.

## Streaming sampling with higher probability for recent samples

The modification to the reservoir sampling is to keep the $t$-th sample with
probability $\frac{sk}{t}$ instead of $\frac{k}{t}$. Let $p_{t,i}$ be the probability of
the $i$-th sample still being in the reservoir at step $t$. It is clear that for
$t\ge sk$, $p_{t,t} = \frac{sk}{t}$. The probability of the $i$-th item being in
the reservoir at step $t$ is the proability that it is in reservoir
at step $t-1$ multiplied with the probility of it not being replaced at step $t$:
$p_{t,i} = p_{t-1,i} \left(1 - \frac{sk}{t} \frac{1}{k}\right) = p_{t-1,i} \left(1-\frac{s}{t}\right)$.
With this relationship, we get:
$$
\begin{array}{ll}
p_{t,i} &=& p_{i,i} \prod_{j=i+1}^t \left(1-\frac{s}{j}\right) \\
    &=& \frac{sk}{i} \frac{i+1-s}{i+1} \cdots \frac{t-1-s}{t-1}\frac{t-s}{t} \\
    &=& sk \frac{(i-s+1)(i-s+2) \cdots (i-1)}{(t-s+1)(t-s+2)\cdots t} \\
    &\approx& sk \left(\frac{i}{t}\right)^{s-1}
\end{array}
$$

So we can see that $p_{t,i}$ is approximately proportional to $i^{s-1}$.


# Solving ODEs with the Euler integrator

**Ordinary differential equations** ([ODE](http://mathworld.wolfram.com/OrdinaryDifferentialEquation.html)s) describe many phenomena in physics. They describe the changes of a **dependent variable** $y(t)$ as a function of a **single independent variable** (e.g. $t$ or $x$).

- An ODE of order $n$ contains $\frac{d^n y}{dt^n}$ as the highest derivative.

  For example, **Newton's equations of motion**

  $$
  F = m \frac{d^2 x(t)}{dt^2}
  $$

  are second order ODEs.

- An ODE of order $n$ requires $n$ initial conditions to uniquely determine a solution $y(t)$.
  For Newton: we need initial position $x(t=0)$ and velocity $v(t=0)$.

- Linear ODEs contain no higher powers than 1 of any of the derivatives (including the 0-th derivative $y$ term).

- Non-linear ODEs can contain any powers in the dependent variable and its derivatives.

## Integrating ODEs with Euler's algorithm
First order ODE:

$$
\frac{dy}{dt} = f(t, y)
$$

Basic idea:
1. Start with initial conditions, $y_0 \equiv y(t=0)$
2. Use $\frac{dy}{dt} = f(t, y)$ (the RHS!) to advance solution a small step $h$ forward in time: $y(t=h) \equiv y_1$
3. Repeat with $y_1$ to obtain $y_2 \equiv y(t=2h)$... and for all future values of $t$.

### Euler's algorithm
Use the forward difference approximation for the derivative:

$$
f(t, y) = \frac{dy(t)}{dt} \approx \frac{y(t_{n+1}) - y(t_n)}{h}
$$

Solve for the position in the future $y(t_{n+1})$, based on present *and known* values $y(t_n)$ and  $f\big(t_n, y(t_n)\big)$:

$$
y_{n+1} \approx y_n + h f(t_n, y_n) \quad \text{with} \quad y_n := y(t_n)
$$

### Convert 2nd order ODE to 2 coupled 1st order ODEs
The 2nd order ODE is
$$
\frac{d^2 y}{dt^2} = f(t, y)
$$

Introduce "dummy" dependent variables $y_i$ with $y_0 \equiv y$ and

\begin{alignat}{1}
\frac{dy}{dt} &= \frac{dy_0}{dt} &=   y_1\\
\frac{d^2y}{dt^2} &= \frac{dy_1}{dt} &= {} f(t, y_0).
\end{alignat}

The first equation defines the velocity $y_1 = v$ and the second one is the original ODE.

## Bouncing ball

Problem: Integrate the equations of a bouncing ball under gravity
* Drop from height $y_0 = 2$ within initial velocity $v_0 = 0$.
* The ball bounces elastically off the ground at $y=0$.

We have to solve the *second order ODE* (Newton's equations of motion with constant acceleration)

$$
\frac{d^2 y}{dt^2} = -g.
$$

The Euler scheme for any *first order ODE*

$$
\frac{dy}{dt} = f(y, t)
$$

is
$$
y(t + h) = y(t) + h f(y(t), t).
$$

In order to solve the original 2nd order equation of motion we make use of the fact that one $n$-th order ODE can be written as $n$ coupled first order ODEs, namely

\begin{align}
\frac{dy}{dt} &= v\\
\frac{dv}{dt} &= -g.
\end{align}

Solve each of the first order ODEs with Euler:

\begin{align}
y(t + h) &= y(t) + h v(t)\\
v(t + h) &= v(t) - h g.
\end{align}

### Free fall

Start with free fall as an even simpler problem.

```python
import numpy as np
```

```python
# parameters
g = -9.81
# initial conditions
y = 2.0
v = 0.0

t = 0
dt = 0.01
data = []
while t < 10:
    y = y + v*dt
    v = v + g*dt
    data.append([t, y, v])
    t += dt

data = np.array(data)
```

```python
data.shape
```

    (1001, 3)

```python
data = data.transpose()
data.shape  # t, y, v
```

    (3, 1001)

Plot the trajectory $y(t)$ with matplotlib.

(Using `data[0]` for time and `data[1]` for position.)

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

```python
plt.plot(data[0], data[1])
plt.xlabel("time (s)")
plt.ylabel("position (m)");
```

### Bouncing
Add a floor at $y = 0$.

What happens at the floor? – The velocity changes (elastic collision).

```python
# parameters
g = -9.81
y_floor = 0

# initial conditions
y = 2.0
v = 0.0

t = 0
dt = 0.01
data = []
while t < 10:
    y += v*dt
    if y > y_floor:
        v += g*dt
    else:
        v = -v   # bounce off floor
    data.append([t, y, v])
    t += dt

data = np.array(data).transpose()
```

```python
plt.plot(data[0], data[1])
plt.xlabel("time (s)")
plt.ylabel("position (m)");
```

## Summary: Euler integrator

1. If the order of the ODE > 1 then write the ODE as a coupled system of n first order ODEs.

   For Newton's EOM ($F = m\frac{d^2}{dt^2}$):

   \begin{align}
   \frac{dx}{dt} &= v\\
   \frac{dv}{dt} &= m^{-1}F
   \end{align}

   Note that $F$ typically depends on $x$, e.g., $F(x) = -\frac{\partial U}{\partial x}$ when the force can be derived from a potential energy function $U(x)$.
2. Solve all first order ODEs with the forward Euler algorithm for time step $\Delta t$:

   \begin{align}
   x_{t+1} &= x_t + v \Delta t\\
   v_{t+1} &= v_t + m^{-1} F(x_t) \Delta t
   \end{align}

The time step of the Euler algorithm has to be chosen small because the error in $x(t)$ will go like $\Delta t^2$ – Euler is really a terrible algorithm but for this introductory class it is good enough. Better algorithms exist and are not much more difficult (see, e.g., PHY432 Computational Methods).


# Multiple equations/multiple unknowns
Solution to problem P2.31 in Modern Control Systems, Dorf and Bishop

```python
import sympy as sym
# print things all pretty
from sympy.abc import *
sym.init_printing()

# Need to define variables as symbolic for sympy to use them.
# This might have been best through multiple excetitions for smaller sets of variables.
x, y, G1, H1, G2, H2, G3, H3, G4, H4, G5, G6, R1, Y1, Y2, X1= symbols("x, y, G1, H1, G2, H2, G3, H3, G4, H4, G5, G6, R1, Y1, Y2, X1", real = True)
```

Equations are defined by putting everything on the same side so the expression is equal to zero.

The equations can be assigned to a variable.  On the next line, equation `eqn1`.

```python
eqn1 = R1-H1*Y1-X1
eqn1
```

```python
eqn2 = X1*G1+G3*H2*Y2-Y1/G2
eqn2
```

```python
eqn3 = Y2*H2*G5+G4/G2*Y1-G6*Y2
eqn3
```

Let's eliminate $X_1$ from equations 1 and 2.

```python
eqn4 = eqn2.subs(X1,sym.solve(eqn1,X1)[0])
eqn4
```

Solve the equations for $Y_1$ and $Y_2$.

```python
soln = sym.solve([eqn4, eqn3], Y1, Y2)
soln
```

```python
T11 = soln[Y1]/R1
T11
```

```python
T12 = soln[Y2]/R1
T12
```

```python

```


```python
# https://colab.research.google.com/github/kassbohm/wb-snippets/blob/master/ipynb/TEM_11/Selbst/2.7.ipynb

from sympy import *

B, q, l = var("B, q, l")
C1, C2 = var("C1, C2")
x = var("x")

pprint("\nq(x):")
qx = 4*q*(x/l-x**2/l**2)
pprint(qx)

pprint("\nR:")
R = integrate(qx, (x,0,l))
pprint(R)

xi = var("xi")

x1 = xi**1
x3 = xi**3
x4 = xi**4

pprint("\nM:")
M   = q*l**2 * ( x1/3 - 2*x3/3 + x4/3 )
pprint(M)

pprint("\nw:")
wpp = - M / B
wp = integrate(wpp*l, xi) + C1
w  = integrate(wp*l, xi) + C2
pprint(w)

eq1 = Eq( w.subs(xi,0), 0)
eq2 = Eq( w.subs(xi,1), 0)

sol = solve([eq1, eq2],[C1, C2])

C1s = sol[C1]
C2s = sol[C2]

pprint("\n\nC1:")
pprint(C1s)

pprint("\n\nC2:")
pprint(C2s)

w = w.subs({C1: C1s, C2: C2s})
w = w.simplify()

pprint("\n\n90 B w / (ql⁴):")
pprint(w * 90*B/(q*l**4))

# M:
#      ⎛ 4      3    ⎞
#  2   ⎜ξ    2⋅ξ    ξ⎟
# l ⋅q⋅⎜── - ──── + ─⎟
#      ⎝3     3     3⎠
#
# w:
#                4    6    4    5    4    3
#               l ⋅q⋅ξ    l ⋅q⋅ξ    l ⋅q⋅ξ
# C₁⋅l⋅ξ + C₂ - ─────── + ─────── - ───────
#                 90⋅B      30⋅B      18⋅B
#
#
# C1:
#  3
# l ⋅q
# ────
# 30⋅B
#
#
# C2:
# 0
#
#
# 90 B w / (ql⁴):
#   ⎛   5      4      2    ⎞
# ξ⋅⎝- ξ  + 3⋅ξ  - 5⋅ξ  + 3⎠

```