# Deep Learning Bootcamp November 2017, GPU Computing for Data Scientists

## 18  PyTorch Deep Learning NUMER.AI  Binary Classification using BCELoss

Web: https://www.meetup.com/Tel-Aviv-Deep-Learning-Bootcamp/events/241762893/

Notebooks:  On GitHub

*Shlomo Kashani*

### Data
- Download from https://numer.ai/leaderboard

### Docker image with PyTorch CPU and GPU support:

- https://github.com/QuantScientist/Deep-Learning-Boot-Camp/tree/master/docker

# PyTorch Imports

```python
# !pip install pycuda
%reset -f
# %%timeit

import torch
from torch.autograd import Variable
import numpy as np
import pandas
import numpy as np
import pandas as pd
from sklearn import cross_validation
from sklearn import metrics
from sklearn.metrics import roc_auc_score, log_loss, roc_auc_score, roc_curve, auc
import matplotlib.pyplot as plt
from sklearn import cross_validation
from sklearn import metrics
from sklearn.metrics import roc_auc_score, log_loss, roc_auc_score, roc_curve, auc
from sklearn.cross_validation import StratifiedKFold, ShuffleSplit, cross_val_score, train_test_split
import logging
import numpy
import numpy as np
from __future__ import print_function
from __future__ import division
import math
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
import pandas as pd
import os
import torch
from torch.utils.data.dataset import Dataset
from torch.utils.data import DataLoader
from torchvision import transforms
from torch import nn
import torch.nn.functional as F
import torch.optim as optim
from torch.autograd import Variable
from sklearn.preprocessing import MultiLabelBinarizer
import time
from sklearn.preprocessing import PolynomialFeatures
import pandas as pd
import numpy as np
import scipy
%matplotlib inline
from pylab import rcParams
rcParams['figure.figsize'] = (6, 6)      # setting default size of plots
import tensorflow as tf
print("tensorflow:" + tf.__version__)
!set "KERAS_BACKEND=tensorflow"
import torch
import sys
print('__Python VERSION:', sys.version)
print('__pyTorch VERSION:', torch.__version__)
print('__CUDA VERSION')
from subprocess import call
print('__CUDNN VERSION:', torch.backends.cudnn.version())
print('__Number CUDA Devices:', torch.cuda.device_count())
print('__Devices')

# !pip install http://download.pytorch.org/whl/cu75/torch-0.2.0.post1-cp27-cp27mu-manylinux1_x86_64.whl
# !pip install torchvision
# ! pip install cv2
# import cv2

print("OS: ", sys.platform)
print("Python: ", sys.version)
print("PyTorch: ", torch.__version__)
print("Numpy: ", np.__version__)

handler=logging.basicConfig(level=logging.INFO)
lgr = logging.getLogger(__name__)
%matplotlib inline

# !pip install psutil
import psutil
def cpuStats():
        print(sys.version)
        print(psutil.cpu_percent())
        print(psutil.virtual_memory())  # physical memory usage
        pid = os.getpid()
        py = psutil.Process(pid)
        memoryUse = py.memory_info()[0] / 2. ** 30  # memory use in GB...I think
        print('memory GB:', memoryUse)

cpuStats()
```

    /usr/local/lib/python2.7/dist-packages/sklearn/cross_validation.py:44: DeprecationWarning: This module was deprecated in version 0.18 in favor of the model_selection module into which all the refactored classes and functions are moved. Also note that the interface of the new CV iterators are different from that of this module. This module will be removed in 0.20.
      "This module will be removed in 0.20.", DeprecationWarning)

    tensorflow:1.2.1
    __Python VERSION: 2.7.12 (default, Nov 19 2016, 06:48:10)
    [GCC 5.4.0 20160609]
    __pyTorch VERSION: 0.1.12+4eb448a
    __CUDA VERSION
    __CUDNN VERSION: None
    __Number CUDA Devices: 1
    __Devices
    OS:  linux2
    Python:  2.7.12 (default, Nov 19 2016, 06:48:10)
    [GCC 5.4.0 20160609]
    PyTorch:  0.1.12+4eb448a
    Numpy:  1.13.1
    2.7.12 (default, Nov 19 2016, 06:48:10)
    [GCC 5.4.0 20160609]
    0.0
    svmem(total=67469099008, available=63067299840, percent=6.5, used=3831234560, free=61351706624, active=4435632128, inactive=1119084544, buffers=270614528, cached=2015543296, shared=37376000)
    memory GB: 0.213935852051

#  CUDA

```python
# %%timeit
use_cuda = torch.cuda.is_available()
# use_cuda = False

FloatTensor = torch.cuda.FloatTensor if use_cuda else torch.FloatTensor
LongTensor = torch.cuda.LongTensor if use_cuda else torch.LongTensor
Tensor = FloatTensor

lgr.info("USE CUDA=" + str (use_cuda))

# ! watch -n 0.1 'ps f -o user,pgrp,pid,pcpu,pmem,start,time,command -p `lsof -n -w -t /dev/nvidia*`'
# sudo apt-get install dstat #install dstat
# sudo pip install nvidia-ml-py #install Python NVIDIA Management Library
# wget https://raw.githubusercontent.com/datumbox/dstat/master/plugins/dstat_nvidia_gpu.py
# sudo mv dstat_nvidia_gpu.py /usr/share/dstat/ #move file to the plugins directory of dstat
```

    INFO:__main__:USE CUDA=True

#  Global params

```python
# NN params
DROPOUT_PROB = 0.75
N_EPOCHS = 50
BATCH_SIZE = 4
LR = 0.005
TEST_RATIO = .11
MOMENTUM= 0.9
PIN_MEMORY=use_cuda # True IF CUDA

# Data params
TARGET_VAR= 'target'
TOURNAMENT_DATA_CSV = 'numerai_tournament_data.csv'
TRAINING_DATA_CSV = 'numerai_training_data.csv'
BASE_FOLDER = 'numerai/'

# fix seed
seed=17*19
np.random.seed(seed)
torch.manual_seed(seed)
if use_cuda:
    torch.cuda.manual_seed(seed)
```

#  Load a CSV file for Binary classification (numpy)

```python
# %%timeit
df_train = pd.read_csv(BASE_FOLDER + TRAINING_DATA_CSV)
df_train.head(5)
```

      id
      era
      data_type
      feature1
      feature2
      feature3
      feature4
      feature5
      feature6
      feature7
      ...
      feature13
      feature14
      feature15
      feature16
      feature17
      feature18
      feature19
      feature20
      feature21
      target

      0
      135682
      era1
      train
      0.53352
      0.64336
      0.46577
      0.53001
      0.55734
      0.45773
      0.41169
      ...
      0.51224
      0.50484
      0.41929
      0.50954
      0.47383
      0.48797
      0.38373
      0.46233
      0.33341
      0

      1
      110546
      era1
      train
      0.54196
      0.81576
      0.46632
      0.62320
      0.52427
      0.64378
      0.55662
      ...
      0.52643
      0.63809
      0.67121
      0.49421
      0.45291
      0.46932
      0.54445
      0.30997
      0.19023
      0

      2
      76047
      era1
      train
      0.49158
      0.69131
      0.57816
      0.54010
      0.43064
      0.49986
      0.61902
      ...
      0.43310
      0.72286
      0.76257
      0.36600
      0.55330
      0.56566
      0.67528
      0.34960
      0.25721
      1

      3
      66098
      era1
      train
      0.54519
      0.42473
      0.63472
      0.39003
      0.37485
      0.43810
      0.59557
      ...
      0.41658
      0.63417
      0.50189
      0.40883
      0.58705
      0.63785
      0.56225
      0.55989
      0.58642
      0

      4
      88227
      era1
      train
      0.44307
      0.74076
      0.52210
      0.56543
      0.51125
      0.66457
      0.42263
      ...
      0.45851
      0.58805
      0.49860
      0.48023
      0.52606
      0.53253
      0.38361
      0.43829
      0.25014
      0

5 rows × 25 columns

#  Feature enrichement
- This would be usually not required when using NN

```python
def genBasicFeatures(inDF):
    print('Generating basic features ...')
    df_copy=inDF.copy(deep=True)
    magicNumber=21
    feature_cols = list(inDF.columns)
#     feature_cols = list(inDF.columns[:-1])
    # feature_cols=xgb_cols
#     target_col = inDF.columns[-1]

    inDF['x_mean'] = np.mean(df_copy.ix[:, 0:magicNumber], axis=1)
    inDF['x_median'] = np.median(df_copy.ix[:, 0:magicNumber], axis=1)
    inDF['x_std'] = np.std(df_copy.ix[:, 0:magicNumber], axis=1)
    inDF['x_skew'] = scipy.stats.skew(df_copy.ix[:, 0:magicNumber], axis=1)
    inDF['x_kurt'] = scipy.stats.kurtosis(df_copy.ix[:, 0:magicNumber], axis=1)
    inDF['x_var'] = np.var(df_copy.ix[:, 0:magicNumber], axis=1)
    inDF['x_max'] = np.max(df_copy.ix[:, 0:magicNumber], axis=1)
    inDF['x_min'] = np.min(df_copy.ix[:, 0:magicNumber], axis=1)
    # http://stackoverflow.com/questions/16236684/apply-pandas-function-to-column-to-create-multiple-new-columns
#     inDF=inDF.merge(df_copy.ix[:, 0:magicNumber].apply(lambda row: NumerCommonML.enrichFeatures(row), axis=1),
#                     left_index=True, right_index=True)

    print (inDF.head(1))
    return inDF

def addPolyFeatures(inDF, deg=2):
    print('Generating poly features ...')
    df_copy=inDF.copy(deep=True)
    poly=PolynomialFeatures(degree=deg)
    p_testX = poly.fit(df_copy)
    # AttributeError: 'PolynomialFeatures' object has no attribute 'get_feature_names'
    target_feature_names = ['x'.join(['{}^{}'.format(pair[0],pair[1]) for pair in tuple if pair[1]!=0]) for tuple in [zip(df_copy.columns,p) for p in poly.powers_]]
    df_copy = pd.DataFrame(p_testX.transform(df_copy),columns=target_feature_names)

    return df_copy
```

#  Train / Validation / Test Split
- Numerai provides a data set that is allready split into train, validation and test sets.

```python
# Train, Validation, Test Split
def loadDataSplit():
    df_train = pd.read_csv(BASE_FOLDER + TRAINING_DATA_CSV)
    # TOURNAMENT_DATA_CSV has both validation and test data provided by NumerAI
    df_test_valid = pd.read_csv(BASE_FOLDER + TOURNAMENT_DATA_CSV)

    answers_1_SINGLE = df_train[TARGET_VAR]
    df_train.drop(TARGET_VAR, axis=1,inplace=True)
    df_train.drop('id', axis=1,inplace=True)
    df_train.drop('era', axis=1,inplace=True)
    df_train.drop('data_type', axis=1,inplace=True)

    # Add polynomial features
    df_train=genBasicFeatures(df_train)
#     df_train = addPolyFeatures(df_train)

    df_train.to_csv(BASE_FOLDER + TRAINING_DATA_CSV + 'clean.csv', header=False,  index = False)
    df_train= pd.read_csv(BASE_FOLDER + TRAINING_DATA_CSV + 'clean.csv', header=None, dtype=np.float32)
    df_train = pd.concat([df_train, answers_1_SINGLE], axis=1)
    feature_cols = list(df_train.columns[:-1])
#     print (feature_cols)
    target_col = df_train.columns[-1]
    trainX, trainY = df_train[feature_cols], df_train[target_col]

    # TOURNAMENT_DATA_CSV has both validation and test data provided by NumerAI
    # Validation set
    df_validation_set=df_test_valid.loc[df_test_valid['data_type'] == 'validation']
    df_validation_set=df_validation_set.copy(deep=True)
    answers_1_SINGLE_validation = df_validation_set[TARGET_VAR]
    df_validation_set.drop(TARGET_VAR, axis=1,inplace=True)
    df_validation_set.drop('id', axis=1,inplace=True)
    df_validation_set.drop('era', axis=1,inplace=True)
    df_validation_set.drop('data_type', axis=1,inplace=True)

   # Add polynomial features
    df_validation_set=genBasicFeatures(df_validation_set)
#     df_validation_set = addPolyFeatures(df_validation_set)

    df_validation_set.to_csv(BASE_FOLDER + TRAINING_DATA_CSV + '-validation-clean.csv', header=False,  index = False)
    df_validation_set= pd.read_csv(BASE_FOLDER + TRAINING_DATA_CSV + '-validation-clean.csv', header=None, dtype=np.float32)
    df_validation_set = pd.concat([df_validation_set, answers_1_SINGLE_validation], axis=1)
    feature_cols = list(df_validation_set.columns[:-1])

    target_col = df_validation_set.columns[-1]
    valX, valY = df_validation_set[feature_cols], df_validation_set[target_col]

    # Test set for submission (not labeled)
    df_test_set = pd.read_csv(BASE_FOLDER + TOURNAMENT_DATA_CSV)
#     df_test_set=df_test_set.loc[df_test_valid['data_type'] == 'live']
    df_test_set=df_test_set.copy(deep=True)
    df_test_set.drop(TARGET_VAR, axis=1,inplace=True)
    tid_1_SINGLE = df_test_set['id']
    df_test_set.drop('id', axis=1,inplace=True)
    df_test_set.drop('era', axis=1,inplace=True)
    df_test_set.drop('data_type', axis=1,inplace=True)

    # Add polynomial features
    df_test_set=genBasicFeatures(df_test_set)
#     df_test_set = addPolyFeatures(df_test_set)

    feature_cols = list(df_test_set.columns) # must be run here, we dont want the ID
#     print (feature_cols)
    df_test_set = pd.concat([tid_1_SINGLE, df_test_set], axis=1)
    testX = df_test_set[feature_cols].values

    return trainX, trainY, valX, valY, testX, df_test_set
```

```python
# %%timeit
trainX, trainY, valX, valY, testX, df_test_set = loadDataSplit()
# X, y = loadDataSplit(999)

# # Number of features for the input layer
N_FEATURES=trainX.shape[1]
# print (trainX.head(3))
# print (df_test_set.head(3))
print (trainX.shape)
print (trainY.shape)
print (valX.shape)
print (valY.shape)
print (testX.shape)
print (df_test_set.shape)
```

    Generating basic features ...
       feature1  feature2  feature3  feature4  feature5  feature6  feature7  \
    0   0.53352   0.64336   0.46577   0.53001   0.55734   0.45773   0.41169

       feature8  feature9  feature10   ...     feature20  feature21    x_mean  \
    0    0.5207   0.36351    0.72262   ...       0.46233    0.33341  0.495936

       x_median     x_std   x_skew    x_kurt    x_var    x_max    x_min
    0   0.50484  0.088713  0.48164  0.453078  0.00787  0.72262  0.33341

    [1 rows x 29 columns]

    /usr/local/lib/python2.7/dist-packages/ipykernel_launcher.py:10: DeprecationWarning:
    .ix is deprecated. Please use
    .loc for label based indexing or
    .iloc for positional indexing

    See the documentation here:
    http://pandas.pydata.org/pandas-docs/stable/indexing.html#ix-indexer-is-deprecated
      # Remove the CWD from sys.path while we load stuff.

    Generating basic features ...
       feature1  feature2  feature3  feature4  feature5  feature6  feature7  \
    0   0.33463    0.5162   0.73225    0.3637   0.35314   0.63717   0.51138

       feature8  feature9  feature10   ...     feature20  feature21    x_mean  \
    0   0.62956   0.18643    0.60806   ...       0.59448    0.46003  0.492229

       x_median     x_std    x_skew    x_kurt    x_var    x_max    x_min
    0   0.51138  0.166973 -0.148699 -1.259785  0.02788  0.73225  0.18643

    [1 rows x 29 columns]
    Generating basic features ...
       feature1  feature2  feature3  feature4  feature5  feature6  feature7  \
    0   0.33463    0.5162   0.73225    0.3637   0.35314   0.63717   0.51138

       feature8  feature9  feature10   ...     feature20  feature21    x_mean  \
    0   0.62956   0.18643    0.60806   ...       0.59448    0.46003  0.492229

       x_median     x_std    x_skew    x_kurt    x_var    x_max    x_min
    0   0.51138  0.166973 -0.148699 -1.259785  0.02788  0.73225  0.18643

    [1 rows x 29 columns]
    (108405, 29)
    (108405,)
    (16686, 29)
    (16686,)
    (45647, 29)
    (45647, 30)

#  Create PyTorch GPU tensors from numpy arrays

- Note how we transfrom the np arrays

```python
# Convert the np arrays into the correct dimention and type
# Note that BCEloss requires Float in X as well as in y
def XnumpyToTensor(x_data_np):
    x_data_np = np.array(x_data_np.values, dtype=np.float32)
    print(x_data_np.shape)
    print(type(x_data_np))

    if use_cuda:
        lgr.info ("Using the GPU")
        X_tensor = Variable(torch.from_numpy(x_data_np).cuda()) # Note the conversion for pytorch
    else:
        lgr.info ("Using the CPU")
        X_tensor = Variable(torch.from_numpy(x_data_np)) # Note the conversion for pytorch

    print(type(X_tensor.data)) # should be 'torch.cuda.FloatTensor'
    print(x_data_np.shape)
    print(type(x_data_np))
    return X_tensor

# Convert the np arrays into the correct dimention and type
# Note that BCEloss requires Float in X as well as in y
def YnumpyToTensor(y_data_np):
    y_data_np=y_data_np.reshape((y_data_np.shape[0],1)) # Must be reshaped for PyTorch!
    print(y_data_np.shape)
    print(type(y_data_np))

    if use_cuda:
        lgr.info ("Using the GPU")
    #     Y = Variable(torch.from_numpy(y_data_np).type(torch.LongTensor).cuda())
        Y_tensor = Variable(torch.from_numpy(y_data_np)).type(torch.FloatTensor).cuda()  # BCEloss requires Float
    else:
        lgr.info ("Using the CPU")
    #     Y = Variable(torch.squeeze (torch.from_numpy(y_data_np).type(torch.LongTensor)))  #
        Y_tensor = Variable(torch.from_numpy(y_data_np)).type(torch.FloatTensor)  # BCEloss requires Float

    print(type(Y_tensor.data)) # should be 'torch.cuda.FloatTensor'
    print(y_data_np.shape)
    print(type(y_data_np))
    return Y_tensor
```

# The NN model

### MLP model
- A multilayer perceptron is a logistic regressor where instead of feeding the input to the logistic regression you insert a intermediate layer, called the hidden layer, that has a nonlinear activation function (usually tanh or sigmoid) . One can use many such hidden layers making the architecture deep.

- Here we define a simple MLP structure. We map the input feature vector to a higher space (256), then later gradually decrease the dimension, and in the end into a 16-dimension space. Because we are calculating the probability of each genre independently, after the final affine layer we need to implement a sigmoid layer.

###  Initial weights selection

- There are many ways to select the initial weights to a neural network architecture. A common initialization scheme is random initialization, which sets the biases and weights of all the nodes in each hidden layer randomly.

- Before starting the training process, an initial value is assigned to each variable. This is done by pure randomness, using for example a uniform or Gaussian distribution. But if we start with weights that are too small, the signal could decrease so much that it is too small to be useful. On the other side, when the parameters are initialized with high values, the signal can end up to explode while propagating through the network.

- In consequence, a good initialization can have a radical effect on how fast the network will learn useful patterns.For this purpose, some best practices have been developed. One famous example used is **Xavier initialization**. Its formulation is based on the number of input and output neurons and uses sampling from a uniform distribution with zero mean and all biases set to zero.

- In effect (according to theory) initializing the weights of the network to values that would be closer to the optimal, and therefore require less epochs to train.

### References:
* **`nninit.xavier_uniform(tensor, gain=1)`** - Fills `tensor` with values according to the method described in ["Understanding the difficulty of training deep feedforward neural networks" - Glorot, X. and Bengio, Y.](http://jmlr.org/proceedings/papers/v9/glorot10a/glorot10a.pdf), using a uniform distribution.
* **`nninit.xavier_normal(tensor, gain=1)`** - Fills `tensor` with values according to the method described in ["Understanding the difficulty of training deep feedforward neural networks" - Glorot, X. and Bengio, Y.](http://jmlr.org/proceedings/papers/v9/glorot10a/glorot10a.pdf), using a normal distribution.
* **`nninit.kaiming_uniform(tensor, gain=1)`** - Fills `tensor` with values according to the method described in ["Delving deep into rectifiers: Surpassing human-level performance on ImageNet classification" - He, K. et al.](https://arxiv.org/abs/1502.01852) using a uniform distribution.
* **`nninit.kaiming_normal(tensor, gain=1)`** - Fills `tensor` with values according to the method described in ["Delving deep into rectifiers: Surpassing human-level performance on ImageNet classification" - He, K. et al.]

```python
# p is the probability of being dropped in PyTorch
# At each layer, DECREASE dropout
dropout = torch.nn.Dropout(p=1 - (DROPOUT_PROB +0.20))

# class Net(torch.nn.Module):
#     def __init__(self, n_feature, n_hidden, n_output,initKernel='uniform'):
#         super(Net, self).__init__()
#         self.hidden = torch.nn.Linear(n_feature, n_hidden)   # hidden layer
#         self.out = torch.nn.Linear(n_hidden, n_output)   # output layer

#         # xavier initializer
#         if initKernel == 'uniform':
#             nn.init.xavier_uniform(self.hidden.weight, gain=np.sqrt(2.0))
#         else:
#             nn.init.kaiming_normal(self.hidden.weight)

#     def forward(self, x):
#         x = F.relu(self.hidden(x))      # activation function for hidden layer
#         x = self.out(x)
#         return F.sigmoid(x)

class Net2(nn.Module):
    def __init__(self, n_feature, n_hidden, n_output,initKernel='uniform'):
        super(Net2, self).__init__()
        self.dis = nn.Sequential(
            nn.Linear(n_feature, n_hidden),
            dropout,
            nn.LeakyReLU(0.1),
            nn.Linear(n_hidden, n_hidden),
            dropout,
            nn.LeakyReLU(0.1),
            nn.Linear(n_hidden, 1),
            dropout,
            nn.Sigmoid()
        )
    def forward(self, x):
        x = self.dis(x)
        return x

hiddenLayer1Size=1024
hiddenLayer2Size=int(hiddenLayer1Size/8)
hiddenLayer3Size=int(hiddenLayer1Size/16)
hiddenLayer4Size=int(hiddenLayer1Size/32)
hiddenLayer5Size=int(hiddenLayer1Size/64)

# # Hypothesis using sigmoid
linear1=torch.nn.Linear(N_FEATURES, hiddenLayer1Size, bias=True)
torch.nn.init.xavier_uniform(linear1.weight)

linear2=torch.nn.Linear(hiddenLayer1Size, hiddenLayer2Size)
torch.nn.init.xavier_uniform(linear2.weight)

linear3=torch.nn.Linear(hiddenLayer2Size, hiddenLayer3Size)
torch.nn.init.xavier_uniform(linear3.weight)

linear4=torch.nn.Linear(hiddenLayer3Size, hiddenLayer4Size)
torch.nn.init.xavier_uniform(linear4.weight)

linear5=torch.nn.Linear(hiddenLayer4Size, hiddenLayer5Size)
torch.nn.init.xavier_uniform(linear5.weight)

linear6=torch.nn.Linear(hiddenLayer5Size, 1)
torch.nn.init.xavier_uniform(linear6.weight)

sigmoid = torch.nn.Sigmoid()
tanh=torch.nn.Tanh()
relu=torch.nn.LeakyReLU()

net = torch.nn.Sequential(linear1,dropout,tanh,nn.BatchNorm1d(hiddenLayer1Size),
                          linear2,dropout,tanh,
                          linear3,dropout,relu,
                          linear4,dropout,tanh,
                          linear5,dropout,relu,
                          linear6,sigmoid
                          )

# net = Net(n_feature=N_FEATURES, n_hidden=1024, n_output=1)   # define the network
# net = Net2(n_feature=N_FEATURES, n_hidden=2048, n_output=1)   # define the network

lgr.info(net)  # net architecture
```

    INFO:__main__:Sequential (
      (0): Linear (29 -> 1024)
      (1): Dropout (p = 0.05)
      (2): Tanh ()
      (3): BatchNorm1d(1024, eps=1e-05, momentum=0.1, affine=True)
      (4): Linear (1024 -> 128)
      (5): Dropout (p = 0.05)
      (6): Tanh ()
      (7): Linear (128 -> 64)
      (8): Dropout (p = 0.05)
      (9): LeakyReLU (0.01)
      (10): Linear (64 -> 32)
      (11): Dropout (p = 0.05)
      (12): Tanh ()
      (13): Linear (32 -> 16)
      (14): Dropout (p = 0.05)
      (15): LeakyReLU (0.01)
      (16): Linear (16 -> 1)
      (17): Sigmoid ()
    )

## Print the full net architecture

```python
# See https://stackoverflow.com/questions/42480111/model-summary-in-pytorch/42616812
from torch.nn.modules.module import _addindent
import torch
import numpy as np
def torch_summarize(model, show_weights=True, show_parameters=True):
    """Summarizes torch model by showing trainable parameters and weights."""
    tmpstr = model.__class__.__name__ + ' (\n'
    for key, module in model._modules.items():
        # if it contains layers let call it recursively to get params and weights
        if type(module) in [
            torch.nn.modules.container.Container,
            torch.nn.modules.container.Sequential
        ]:
            modstr = torch_summarize(module)
        else:
            modstr = module.__repr__()
        modstr = _addindent(modstr, 2)

        params = sum([np.prod(p.size()) for p in module.parameters()])
        weights = tuple([tuple(p.size()) for p in module.parameters()])

        tmpstr += '  (' + key + '): ' + modstr
        if show_weights:
            tmpstr += ', weights={}'.format(weights)
        if show_parameters:
            tmpstr +=  ', parameters={}'.format(params)
        tmpstr += '\n'

    tmpstr = tmpstr + ')'
    return tmpstr

lgr.info(torch_summarize(net))
```

    INFO:__main__:Sequential (
      (0): Linear (29 -> 1024), weights=((1024L, 29L), (1024L,)), parameters=30720
      (1): Dropout (p = 0.05), weights=(), parameters=0
      (2): Tanh (), weights=(), parameters=0
      (3): BatchNorm1d(1024, eps=1e-05, momentum=0.1, affine=True), weights=((1024L,), (1024L,)), parameters=2048
      (4): Linear (1024 -> 128), weights=((128L, 1024L), (128L,)), parameters=131200
      (5): Dropout (p = 0.05), weights=(), parameters=0
      (6): Tanh (), weights=(), parameters=0
      (7): Linear (128 -> 64), weights=((64L, 128L), (64L,)), parameters=8256
      (8): Dropout (p = 0.05), weights=(), parameters=0
      (9): LeakyReLU (0.01), weights=(), parameters=0
      (10): Linear (64 -> 32), weights=((32L, 64L), (32L,)), parameters=2080
      (11): Dropout (p = 0.05), weights=(), parameters=0
      (12): Tanh (), weights=(), parameters=0
      (13): Linear (32 -> 16), weights=((16L, 32L), (16L,)), parameters=528
      (14): Dropout (p = 0.05), weights=(), parameters=0
      (15): LeakyReLU (0.01), weights=(), parameters=0
      (16): Linear (16 -> 1), weights=((1L, 16L), (1L,)), parameters=17
      (17): Sigmoid (), weights=(), parameters=0
    )

#  Loss and Optimizer

###  BCELoss
- In addition, we will calculate the binary cross entropy loss (BCELoss). Luckily we have one loss function already present. For details please checkout http://pytorch.org/docs/master/nn.html.

- ** NOTE this BCELoss may not be numerical stable, although it's fine during my training process.**

### Optimization

- if return F.log_softmax(x) then loss = F.nll_loss(output, target) (MNIST)
- print(nn.BCEWithLogitsLoss()(o, t)) is equivalent to print(nn.BCELoss()(sigmoid(o), t))

```python
import sympy as sp
sp.interactive.printing.init_printing(use_latex=True)
from IPython.display import display, Math, Latex
maths = lambda s: display(Math(s))
latex = lambda s: display(Latex(s))

#the loss function is as follows:
maths("\mathbf{Loss Function:} J(x, z) = -\sum_k^d[x_k \log z_k + (1-x_k)log(1-z_k)]")
```

$$\mathbf{Loss Function:} J(x, z) = -\sum_k^d[x_k \log z_k + (1-x_k)log(1-z_k)]$$

```python
# %%timeit
# optimizer = torch.optim.SGD(net.parameters(), lr=0.02)
# optimizer = optim.SGD(model.parameters(), lr=0.001, momentum=0.9)
# optimizer = optim.SGD(net.parameters(), lr=LR, momentum=MOMENTUM, weight_decay=5e-4)

#L2 regularization can easily be added to the entire model via the optimizer
# optimizer = torch.optim.Adam(net.parameters(), lr=LR,weight_decay=5e-4) #  L2 regularization
optimizer = torch.optim.Adagrad(net.parameters(), lr=1e-6, weight_decay=5e-4)
# loss_func = torch.nn.CrossEntropyLoss()  # the target label is NOT an one-hotted
# loss_func = torch.nn.NLLLoss()
loss_func=torch.nn.BCELoss() # Binary cross entropy: http://pytorch.org/docs/nn.html#bceloss
# http://andersonjo.github.io/artificial-intelligence/2017/01/07/Cost-Functions/
# use_cuda=True
if use_cuda:
    lgr.info ("Using the GPU")
    net.cuda()
    loss_func.cuda()
#     cudnn.benchmark = True
    #net = torch.nn.DataParallel(net, device_ids=range(torch.cuda.device_count()))

lgr.info (optimizer)
lgr.info (loss_func)
```

    INFO:__main__:Using the GPU
    INFO:__main__:<torch.optim.adam.Adam object at 0x7f7e6c176590>
    INFO:__main__:BCELoss (
    )

#  Training in batches  + Measuring the performance of the deep learning model

```python
import time
start_time = time.time()
epochs=1500
all_losses = []

X_tensor_train= XnumpyToTensor(trainX)
Y_tensor_train= YnumpyToTensor(trainY)

print(type(X_tensor_train.data), type(Y_tensor_train.data)) # should be 'torch.cuda.FloatTensor'

# From here onwards, we must only use PyTorch Tensors
for step in range(epochs):
#     net.train()

#     output = F.sigmoid(net(input))
#     loss = crit(output, target)
    out = net(X_tensor_train)                 # input x and predict based on x
    cost = loss_func(out, Y_tensor_train)     # must be (1. nn output, 2. target), the target label is NOT one-hotted

    optimizer.zero_grad()   # clear gradients for next train
    cost.backward()         # backpropagation, compute gradients
    optimizer.step()        # apply gradients

    if step % 150 == 0:
        loss = cost.data[0]
        all_losses.append(loss)
        print(step, cost.data.cpu().numpy())
        # RuntimeError: can't convert CUDA tensor to numpy (it doesn't support GPU arrays).
        # Use .cpu() to move the tensor to host memory first.
#         prediction = torch.max(F.softmax(out), 1)[1]
#         _, prediction = torch.max(out, 1)
        prediction = (net(X_tensor_train).data).float() # probabilities

#         prediction = (net(X_tensor).data > 0.5).float() # zero or one
#         print ("Pred:" + str (prediction)) # Pred:Variable containing: 0 or 1
#         pred_y = prediction.data.numpy().squeeze()
        # RuntimeError: can't convert CUDA tensor to numpy (it doesn't support GPU arrays).

        pred_y = prediction.cpu().numpy().squeeze()
        target_y = Y_tensor_train.cpu().data.numpy()

        tu = ((pred_y == target_y).mean(),log_loss(target_y, pred_y),roc_auc_score(target_y,pred_y ))
        print ('ACC={}, LOG_LOSS={}, ROC_AUC={} '.format(*tu))

end_time = time.time()
print ('{} {:6.3f} seconds'.format('GPU:', end_time-start_time))

%matplotlib inline
import matplotlib.pyplot as plt
plt.plot(all_losses)
plt.show()

false_positive_rate, true_positive_rate, thresholds = roc_curve(target_y,pred_y)
roc_auc = auc(false_positive_rate, true_positive_rate)

plt.title('LOG_LOSS=' + str(log_loss(target_y, pred_y)))
plt.plot(false_positive_rate, true_positive_rate, 'b', label='AUC = %0.6f' % roc_auc)
plt.legend(loc='lower right')
plt.plot([0, 1], [0, 1], 'r--')
plt.xlim([-0.1, 1.2])
plt.ylim([-0.1, 1.2])
plt.ylabel('True Positive Rate')
plt.xlabel('False Positive Rate')
plt.show()
```

#  Performance of the deep learning model on the Validation set

```python
net.eval()
# Validation data
print (valX.shape)
print (valY.shape)

X_tensor_val= XnumpyToTensor(valX)
Y_tensor_val= YnumpyToTensor(valY)

print(type(X_tensor_val.data), type(Y_tensor_val.data)) # should be 'torch.cuda.FloatTensor'

predicted_val = (net(X_tensor_val).data).float() # probabilities
# predicted_val = (net(X_tensor_val).data > 0.5).float() # zero or one
pred_y = predicted_val.cpu().numpy()
target_y = Y_tensor_val.cpu().data.numpy()

print (type(pred_y))
print (type(target_y))

tu = (str ((pred_y == target_y).mean()),log_loss(target_y, pred_y),roc_auc_score(target_y,pred_y ))
print ('\n')
print ('acc={} log_loss={} roc_auc={} '.format(*tu))

false_positive_rate, true_positive_rate, thresholds = roc_curve(target_y,pred_y)
roc_auc = auc(false_positive_rate, true_positive_rate)

plt.title('LOG_LOSS=' + str(log_loss(target_y, pred_y)))
plt.plot(false_positive_rate, true_positive_rate, 'b', label='AUC = %0.6f' % roc_auc)
plt.legend(loc='lower right')
plt.plot([0, 1], [0, 1], 'r--')
plt.xlim([-0.1, 1.2])
plt.ylim([-0.1, 1.2])
plt.ylabel('True Positive Rate')
plt.xlabel('False Positive Rate')
plt.show()

# print (pred_y)
```

#  Submission on Test set

```python
# testX, df_test_set
# df[df.columns.difference(['b'])]
# trainX, trainY, valX, valY, testX, df_test_set = loadDataSplit()

print (df_test_set.shape)
columns = ['id', 'probability']
df_pred=pd.DataFrame(data=np.zeros((0,len(columns))), columns=columns)
df_pred.id.astype(int)

for index, row in df_test_set.iterrows():
    rwo_no_id=row.drop('id')
#     print (rwo_no_id.values)
    x_data_np = np.array(rwo_no_id.values, dtype=np.float32)
    if use_cuda:
        X_tensor_test = Variable(torch.from_numpy(x_data_np).cuda()) # Note the conversion for pytorch
    else:
        X_tensor_test = Variable(torch.from_numpy(x_data_np)) # Note the conversion for pytorch

    X_tensor_test=X_tensor_test.view(1, trainX.shape[1]) # does not work with 1d tensors
    predicted_val = (net(X_tensor_test).data).float() # probabilities
    p_test =   predicted_val.cpu().numpy().item() # otherwise we get an array, we need a single float

    df_pred = df_pred.append({'id':row['id'].astype(int), 'probability':p_test},ignore_index=True)

# p_test =   pd.DataFrame (p_test, columns=['probability'])

# # df_pred = df_test_set.append(p_test, ignore_index=True)
# df_pred = pd.concat([p_test, df_test_set], axis=0, ignore_index=True)

# # # df_pred = pd.DataFrame({
# # #     'id': df_test_set['id'],
# # #     'probability': p_test[:,1]
# # # })

df_pred.head(5)

# df_test_set = pd.concat([tid_1_SINGLE, df_test_set], axis=1)
```

    (45647, 30)

      id
      probability

      0
      97040.0
      0.501994

      1
      65399.0
      0.481901

      2
      147258.0
      0.497755

      3
      129573.0
      0.504502

      4
      134978.0
      0.519406

```python
df_pred.id=df_pred.id.astype(int)

def savePred(df_pred, loss):
#     csv_path = 'pred/p_{}_{}_{}.csv'.format(loss, name, (str(time.time())))
    csv_path = 'pred/pred_{}_{}.csv'.format(loss, (str(time.time())))
    df_pred.to_csv(csv_path, columns=('id', 'probability'), index=None)
    print (csv_path)

savePred (df_pred, log_loss(target_y, pred_y))
```

    pred/pred_0.692493408893_1504543207.31.csv

```python

```

day 02 PyTORCH and PyCUDA/PyTorch/18-PyTorch-NUMER.AI-Binary-Classification-BCELoss.ipynb


# Tutorial-IllinoisGRMHD: A_i_rhs_no_gauge_terms.C

## Authors: Leo Werneck & Zach Etienne

**This module is currently under development**

## In this tutorial module we explain the construction of the right-hand side of the evolution equations of $A_{i}$

### Required and recommended citations:

* **(Required)** Etienne, Z. B., Paschalidis, V., Haas R., Mösta P., and Shapiro, S. L. IllinoisGRMHD: an open-source, user-friendly GRMHD code for dynamical spacetimes. Class. Quantum Grav. 32 (2015) 175009. ([arxiv:1501.07276](http://arxiv.org/abs/1501.07276)).
* **(Required)** Noble, S. C., Gammie, C. F., McKinney, J. C., Del Zanna, L. Primitive Variable Solvers for Conservative General Relativistic Magnetohydrodynamics. Astrophysical Journal, 641, 626 (2006) ([astro-ph/0512420](https://arxiv.org/abs/astro-ph/0512420)).
* **(Recommended)** Del Zanna, L., Bucciantini N., Londrillo, P. An efficient shock-capturing central-type scheme for multidimensional relativistic flows - II. Magnetohydrodynamics. A&A 400 (2) 397-413 (2003). DOI: 10.1051/0004-6361:20021641 ([astro-ph/0210618](https://arxiv.org/abs/astro-ph/0210618)).

If using the version of `IllinoisGRMHD` with piecewise polytropic *or* tabulated (coming soon!) EOS support, then the following citation is also required:

* **(Required)** Etienne, Z. B., Werneck, L., Paschalidis, V., Haas R., Mösta P., and Shapiro, S. L., *IllinoisGRMHD github repository* (2019). Source Code URL: https://github.com/zachetienne/nrpytutorial/tree/master/IllinoisGRMHD/.

# Table of Contents
$$\label{toc}$$

This module is organized as follows

0. [Step 0](#src_dir): **Source directory creation**
1. [Step 1](#a_i_rhs_no_gauge_terms__c): **The `A_i_rhs_no_gauge_terms.C` file**
    1. [Step 1.a](#rhs_from_adirn): *Determining the appropriate RHS based on $A_{i}$*
    1. [Step 1.b](#vi_bi_staggering): *Handling the staggering of $v^{i}$ and $B^{i}$*
    1. [Step 1.c](#staggered_hll): *Computing the generalized HLL formula for $A_{i}$*
1. [Step 2](#code_validation): **Code validation**
1. [Step 3](#latex_pdf_output): **Output this notebook to $\LaTeX$-formatted PDF file**

# Step 0: Source directory creation \[Back to [top](#toc)\]
$$\label{src_dir}$$

We will now use the [cmdline_helper.py NRPy+ module](Tutorial-Tutorial-cmdline_helper.ipynb) to create the source directory within the `IllinoisGRMHD` NRPy+ directory, if it does not exist yet.

```python
# Step 0: Creation of the IllinoisGRMHD source directory
# Step 0a: Add NRPy's directory to the path
# https://stackoverflow.com/questions/16780014/import-file-from-parent-directory
import os,sys
nrpy_dir_path = os.path.join("..","..")
if nrpy_dir_path not in sys.path:
    sys.path.append(nrpy_dir_path)

# Step 0b: Load up cmdline_helper and create the directory
import cmdline_helper as cmd
outdir = os.path.join("..","src")
cmd.mkdir(outdir)
```

# Step 1: The `A_i_rhs_no_gauge_terms.C` file \[Back to [top](#toc)\]
$$\label{a_i_rhs_no_gauge_terms__c}$$

In this file we implement the right-hand side (RHS) of the evolution equation of the vector potentials $A_{i}$, i.e. the [induction equation](https://en.wikipedia.org/wiki/Induction_equation), excluding gauge terms (see also equation (12) in [Etienne *et al.*](https://arxiv.org/pdf/1501.07276.pdf)). In other words, we implement:

$$
\partial_{t}A_{i}^{\rm NG} \equiv \partial_{t}A_{i} - \left[-\partial_{i}\left(\alpha\Phi - \beta^{j}A_{j}\right)\right]= \partial_{t}A_{i} - \left[\text{gauge terms}\right]_{i} = \psi^{6}\epsilon_{ijk}v^{j}B^{k}\ .
$$

where $\epsilon_{ijk}$ is the [Levi-Civita symbol](https://en.wikipedia.org/wiki/Levi-Civita_symbol) with $\epsilon_{123} = \epsilon_{xyz}=1$, odd permutations of the indices resulting in $-1$, even permutations in $+1$, and if two or more indices are identical in $0$. In components, then, we have

$$
\boxed{
\begin{align}
\partial_{t}A_{x}^{\rm NG} &= \psi^{6}\left(v^{y}B^{z} - v^{z}B^{y}\right) \\
\partial_{t}A_{y}^{\rm NG} &= \psi^{6}\left(v^{z}B^{x} - v^{x}B^{z}\right) \\
\partial_{t}A_{z}^{\rm NG} &= \psi^{6}\left(v^{x}B^{y} - v^{y}B^{x}\right)
\end{align}
}\ .
$$

## Step 1.a: Determining the appropriate RHS based on $A_{i}$ \[Back to [top](#toc)\]
$$\label{rhs_from_adirn}$$

One of the inputs of the function is the variable `A_dirn`, which we will denote by $a=1,2,3$, which is an integer that specifies the direction of $A_{i}$ we are interested in. Now, in order to properly determine the RHS we want to use, we need to remember the following:

* The $v^{i}$ arrays store $\left(v^{x},v^{y},v^{z}\right)$ at consecutive indices, i.e. $\left(\ldots,n-1,\underbrace{n,n+1,n+2}_{\text{indices corresponding to } v^{i}},n+3,\ldots\right)$ ;
* The $B^{i}$ arrays store $\left(B^{x},B^{y},B^{z}\right)$ at consecutive indices, i.e. $\left(\ldots,m-1,\underbrace{m,m+1,m+2}_{\text{indices corresponding to } B^{i}},m+3,\ldots\right)$ .

Consider, then, the quantities:

\begin{align}
O_{1} &\equiv {\rm mod}\left[(a-1)+1,3\right]\ ,\\
O_{2} &\equiv {\rm mod}\left[(a-1)+2,3\right]\ ,
\end{align}

where $O$ stands for *offset*. Let $V$ and $B$ correspond to the $v^{i}$ and $B^{i}$ arrays, respectively, with $V(n)\to v^{x}$ and $B(m)\to B^{x}$. Then, we have the following table:

| $a$ | $A_{a}$ | $O_{1}$ | $O_{2}$ | $V[n+O_{1}]$ | $V[n+O_{2}]$ | $B[m+O_{1}]$ | $B[m+O_{2}]$ |
|:---:|:-------:|:-------:|:-------:|:------------:|:------------:|:------------:|:------------:|
|  1  | $A_{x}$ |    1    |    2    | $\to v^{y}$  | $\to v^{z}$  | $\to B^{y}$  | $\to B^{z}$  |
|  2  | $A_{y}$ |    2    |    0    | $\to v^{z}$  | $\to v^{x}$  | $\to B^{z}$  | $\to B^{x}$  |
|  3  | $A_{z}$ |    0    |    1    | $\to v^{x}$  | $\to v^{y}$  | $\to B^{x}$  | $\to B^{y}$  |

Thus, we can write, in general

$$
\boxed{\partial_{t}A_{a}^{\rm NG} = \psi^{6}V[n+O_{1}]B[m+O_{2}] - V[n+O_{2}]B[m+O_{1}]}\ .
$$

In this first step, we determine the values of $O_{1}$ and $O_{2}$, which are referred to in the code by `v1_offset` and `v2_offset`, respectively. We then also set variables $v_{1}$, $v_{2}$, $B_{1}$, $B_{2}$ which point to the array elements $V[n+O_{1}]$, $V[n+O_{2}]$, $B[m+O_{1}]$, $B[m+O_{2}]$, respectively. Notice that because we have $v^{i}$ and $B^{i}$ on different staggered points, we will set more than one pointer at this stage.

```python
%%writefile $outdir/A_i_rhs_no_gauge_terms.C
/* Compute the part of A_i_rhs that excludes the gauge terms. I.e., we set
 *   A_i_rhs = \partial_t A_i = \psi^{6} (v^z B^x - v^x B^z)   here.
 */
static void A_i_rhs_no_gauge_terms(const int A_dirn,const cGH *cctkGH,const int *cctk_lsh,const int *cctk_nghostzones,gf_and_gz_struct *OUT_PRIMS_R,gf_and_gz_struct *OUT_PRIMS_L,
                                   CCTK_REAL *phi_interped,CCTK_REAL *cmax_1,CCTK_REAL *cmin_1,CCTK_REAL *cmax_2,CCTK_REAL *cmin_2, CCTK_REAL *A3_rhs) {
  // If A_dirn=1, then v1_offset=1 (v1=VY) and v2_offset=2 (v2=VZ)
  // If A_dirn=2, then v1_offset=2 (v1=VZ) and v2_offset=0 (v2=VX)
  // If A_dirn=3, then v1_offset=0 (v1=VX) and v2_offset=1 (v2=VY)
  int v1_offset = ((A_dirn-1)+1)%3,           v2_offset = ((A_dirn-1)+2)%3;

  CCTK_REAL *v1rr=OUT_PRIMS_R[VXR+v1_offset].gf, *v2rr=OUT_PRIMS_R[VXR+v2_offset].gf;
  CCTK_REAL *v1rl=OUT_PRIMS_L[VXR+v1_offset].gf, *v2rl=OUT_PRIMS_L[VXR+v2_offset].gf;
  CCTK_REAL *v1lr=OUT_PRIMS_R[VXL+v1_offset].gf, *v2lr=OUT_PRIMS_R[VXL+v2_offset].gf;
  CCTK_REAL *v1ll=OUT_PRIMS_L[VXL+v1_offset].gf, *v2ll=OUT_PRIMS_L[VXL+v2_offset].gf;

  CCTK_REAL *B1r=OUT_PRIMS_R[BX_STAGGER+v1_offset].gf, *B1l=OUT_PRIMS_L[BX_STAGGER+v1_offset].gf;
  CCTK_REAL *B2r=OUT_PRIMS_R[BX_STAGGER+v2_offset].gf, *B2l=OUT_PRIMS_L[BX_STAGGER+v2_offset].gf;
```

    Overwriting ../src/A_i_rhs_no_gauge_terms.C

## Step 1.b: Handling the staggering of $v^{i}$ and $B^{i}$ \[Back to [top](#toc)\]
$$\label{vi_bi_staggering}$$

Now that we have the correct $v^{i}$ and $B^{i}$ component, we must be careful about how these quantities are staggered with respect to $A_{i}$. Remember that the staggering of these functions is the following:

| Direction | $a$ |          $A_{a}$           |   $v^{n+O_{1},n+O_{2}}$    |       $B^{m+O_{1}}$        |       $B^{m+O_{2}}$       |
|:---------:|:---:|:--------------------------:|:--------------------------:|:--------------------------:|:-------------------------:|
|     x     |  1  |$\left(i,j+\frac{1}{2},k+\frac{1}{2}\right)$|$\left(i,j-\frac{1}{2},k-\frac{1}{2}\right)$|$\left(i,j+\frac{1}{2},k-\frac{1}{2}\right)$|$\left(i,j-\frac{1}{2},k+\frac{1}{2}\right)$|
|     y     |  2  |$\left(i+\frac{1}{2},j,k+\frac{1}{2}\right)$|$\left(i-\frac{1}{2},j,k-\frac{1}{2}\right)$|$\left(i-\frac{1}{2},j,k+\frac{1}{2}\right)$|$\left(i+\frac{1}{2},j,k-\frac{1}{2}\right)$|
|     z     |  3  |$\left(i+\frac{1}{2},j+\frac{1}{2},k\right)$|$\left(i-\frac{1}{2},j-\frac{1}{2},k\right)$|$\left(i+\frac{1}{2},j-\frac{1}{2},k\right)$|$\left(i-\frac{1}{2},j+\frac{1}{2},k\right)$|

Now, obtaining, for example, $v^{n+O_{1},n+O_{2}}=v^{y,z}$ (first case in the table above) at the correct point, i.e. $\left(i,j+\frac{1}{2},k+\frac{1}{2}\right)$, would correspond to shifting the gridpoints of the $v^{y,z}$ gridfunctions up by $1$ in the $y,z$-directions. Similarly, obtaining $B^{m+O_{1}}=B^{y}$ at the point $\left(i,j+\frac{1}{2},k+\frac{1}{2}\right)$ corresponds shifting its index up by $1$ in the $z$-direction, while obtaining $B^{m+O_{2}}=B^{z}$ at the point $\left(i,j+\frac{1}{2},k+\frac{1}{2}\right)$ corresponds shifting its index up by $1$ in the $y$-direction.

Below, we set all these offsets by defining the `vs_ijk_offset`, `B1_ijk_offset`, and `B2_ijk_offset` arrays, respectively.

```python
%%writefile -a $outdir/A_i_rhs_no_gauge_terms.C

  /**** V DEPENDENCIES ****/
  /* In the case of Ax_rhs, we need v{y,z}{r,l} at (i,j+1/2,k+1/2).
   *    However, v{y,z}{r,l}{r,l} are defined at (i,j-1/2,k-1/2), so
   *    v{y,z}{r,l} at (i,j+1/2,k+1/2) is stored at v{y,z}{r,l}{r,l}(i,j+1,k+1).
   * In the case of Ay_rhs, we need v{x,z}{r,l} at (i+1/2,j,k+1/2).
   *    However, v{x,z}{r,l}{r,l} are defined at (i-1/2,j,k-1/2), so
   *    v{x,z}{r,l} at (i+1/2,j,k+1/2) is stored at v{x,z}{r,l}{r,l}(i+1,j,k+1).
   * In the case of Az_rhs, we need v{x,y}{r,l} at (i+1/2,j+1/2,k).
   *    However, v{x,y}{r,l}{r,l} are defined at (i-1/2,j-1/2,k), so
   *    v{x,y}{r,l} at (i+1/2,j+1/2,k) is stored at v{x,y}{r,l}{r,l}(i+1,j+1,k). */
  static const int vs_ijk_offset[4][3] = { {0,0,0} , {0,1,1} , {1,0,1} , {1,1,0} };

  /**** B DEPENDENCIES ****/
  /* In the case of Ax_rhs, we need B{y,z}{r,l} at (i,j+1/2,k+1/2).
   *    However, By_stagger{r,l} is defined at (i,j+1/2,k-1/2), and
   *             Bz_stagger{r,l} is defined at (i,j-1/2,k+1/2), so
   *             By_stagger{r,l} at (i,j+1/2,k+1/2) is stored at By_stagger{r,l}(i,j,k+1), and
   *             Bz_stagger{r,l} at (i,j+1/2,k+1/2) is stored at Bz_stagger{r,l}(i,j+1,k).
   * In the case of Ay_rhs, we need B{z,x}_stagger{r,l} at (i+1/2,j,k+1/2).
   *    However, Bz_stagger{r,l} is defined at (i-1/2,j,k+1/2), and
   *             Bx_stagger{r,l} is defined at (i+1/2,j,k-1/2), so
   *             Bz_stagger{r,l} at (i+1/2,j,k+1/2) is stored at Bz_stagger{r,l}(i+1,j,k), and
   *             Bx_stagger{r,l} at (i+1/2,j,k+1/2) is stored at Bx_stagger{r,l}(i,j,k+1).
   * In the case of Az_rhs, we need B{x,y}_stagger{r,l} at (i+1/2,j+1/2,k).
   *    However, Bx_stagger{r,l} is defined at (i+1/2,j-1/2,k), and
   *             By_stagger{r,l} is defined at (i-1/2,j+1/2,k), so
   *             Bx_stagger{r,l} at (i+1/2,j+1/2,k) is stored at Bx_stagger{r,l}(i,j+1,k), and
   *             By_stagger{r,l} at (i+1/2,j+1/2,k) is stored at By_stagger{r,l}(i+1,j,k).
   */
  static const int B1_ijk_offset[4][3] = { {0,0,0} , {0,0,1} , {1,0,0} , {0,1,0} };
  static const int B2_ijk_offset[4][3] = { {0,0,0} , {0,1,0} , {0,0,1} , {1,0,0} };
```

    Appending to ../src/A_i_rhs_no_gauge_terms.C

## Step 1.c: Computing the generalized HLL formula for $A_{i}$ \[Back to [top](#toc)\]
$$\label{staggered_hll}$$

We now define the following quantities:

$$
\left(
B^{m+O_{1}}_{L},
B^{m+O_{1}}_{R},
B^{m+O_{2}}_{L},
B^{m+O_{2}}_{R}
\right)\ .
$$

Then, define $\left(\mathcal{E}_{a}\right)_{AB} \equiv \left(\partial_{t}A_{a}^{\rm NG}\right)_{AB}$, where $A$ and $B$ can be $L$ or $R$ (for right and left face values), which allow us to compute

$$
\begin{align}
\left(\mathcal{E}_a\right)_{RR} &= \psi^{6}\left(v^{n+O_{1}}_{R}B^{m+O_{2}}_{R} - v^{n+O_{2}}_{R}B^{m+O_{1}}_{R}\right)\ .\\
\left(\mathcal{E}_a\right)_{RL} &= \psi^{6}\left(v^{n+O_{1}}_{R}B^{m+O_{2}}_{R} - v^{n+O_{2}}_{R}B^{m+O_{1}}_{L}\right)\ ,\\
\left(\mathcal{E}_a\right)_{LR} &= \psi^{6}\left(v^{n+O_{1}}_{L}B^{m+O_{2}}_{L} - v^{n+O_{2}}_{L}B^{m+O_{1}}_{R}\right)\ ,\\
\left(\mathcal{E}_a\right)_{LL} &= \psi^{6}\left(v^{n+O_{1}}_{L}B^{m+O_{2}}_{L} - v^{n+O_{2}}_{L}B^{m+O_{1}}_{L}\right)\ .
\end{align}
$$

Then, having also set the characteristic speeds $c^{\pm}_{m + O_{1}}$ and $c^{\pm}_{m + O_{2}}$, we compute the standard HLL formula generalized to staggered gridfunctions (see equation (33) in [Etienne *et al.*](https://arxiv.org/pdf/1501.07276.pdf))

$$
\boxed{
\begin{align}
\left(\mathcal{E}_a\right)^{\rm HLL}
&=
\frac{
c^{+}_{m+O_{1}}c^{+}_{m+O_{2}}\left(\mathcal{E}_a\right)_{LL} +
c^{+}_{m+O_{1}}c^{-}_{m+O_{2}}\left(\mathcal{E}_a\right)_{LR} +
c^{-}_{m+O_{1}}c^{+}_{m+O_{2}}\left(\mathcal{E}_a\right)_{RL} +
c^{-}_{m+O_{1}}c^{-}_{m+O_{2}}\left(\mathcal{E}_a\right)_{RR}
}
{\left(c^{+}_{m+O_{1}}+c^{-}_{m+O_{1}}\right)\left(c^{+}_{m+O_{2}}+c^{-}_{m+O_{2}}\right)}\\
&+
\frac{c^{+}_{m+O_{1}}c^{-}_{m+O_{1}}}{c^{+}_{m+O_{1}}+c^{-}_{m+O_{1}}}\left(B^{m+O_{2}}_{R}-B^{m+O_{2}}_{L}\right)
+
\frac{c^{+}_{m+O_{2}}c^{-}_{m+O_{2}}}{c^{+}_{m+O_{2}}+c^{-}_{m+O_{2}}}\left(B^{m+O_{1}}_{R}-B^{m+O_{1}}_{L}\right)
\end{align}
}\ .
$$

As a more practical example, which has a far less cluttered notation, consider the formula above for $a=z$. We then have

$$
\begin{align}
\left(\mathcal{E}_z\right)^{\rm HLL}
&=
\frac{
c^{+}_{x}c^{+}_{y}\left(\mathcal{E}_z\right)_{LL} +
c^{+}_{x}c^{-}_{y}\left(\mathcal{E}_z\right)_{LR} +
c^{-}_{x}c^{+}_{y}\left(\mathcal{E}_z\right)_{RL} +
c^{-}_{x}c^{-}_{y}\left(\mathcal{E}_z\right)_{RR}
}
{\left(c^{+}_{x}+c^{-}_{x}\right)\left(c^{+}_{y}+c^{-}_{y}\right)}\\
&+
\frac{c^{+}_{x}c^{-}_{x}}{c^{+}_{x}+c^{-}_{x}}\left(B^{y}_{R}-B^{y}_{L}\right)
+
\frac{c^{+}_{y}c^{-}_{y}}{c^{+}_{y}-c^{-}_{y}}\left(B^{x}_{R}-B^{x}_{L}\right)\ ,
\end{align}
$$

which is precisely equation (33) in [Etienne *et al.*](https://arxiv.org/pdf/1501.07276.pdf).

```python
%%writefile -a $outdir/A_i_rhs_no_gauge_terms.C

#pragma omp parallel for
  for(int k=cctk_nghostzones[2];k<cctk_lsh[2]-cctk_nghostzones[2];k++) for(int j=cctk_nghostzones[1];j<cctk_lsh[1]-cctk_nghostzones[1];j++) for(int i=cctk_nghostzones[0];i<cctk_lsh[0]-cctk_nghostzones[0];i++) {
        int index=CCTK_GFINDEX3D(cctkGH,i,j,k);
        // The following lines set the indices appropriately. See justification in exorbitant comments above.
        int index_v =CCTK_GFINDEX3D(cctkGH,i+vs_ijk_offset[A_dirn][0],j+vs_ijk_offset[A_dirn][1],k+vs_ijk_offset[A_dirn][2]);
        int index_B1=CCTK_GFINDEX3D(cctkGH,i+B1_ijk_offset[A_dirn][0],j+B1_ijk_offset[A_dirn][1],k+B1_ijk_offset[A_dirn][2]);
        int index_B2=CCTK_GFINDEX3D(cctkGH,i+B2_ijk_offset[A_dirn][0],j+B2_ijk_offset[A_dirn][1],k+B2_ijk_offset[A_dirn][2]);

        // Stores 1/sqrt(gamma)==exp(6 phi) at (i+1/2,j+1/2,k) for Az, (i+1/2,j,k+1/2) for Ay, and (i,j+1/2,k+1/2) for Az.
        CCTK_REAL psi6_interped=exp(6.0*(phi_interped[index]));

        CCTK_REAL B1lL = B1l[index_B1];
        CCTK_REAL B1rL = B1r[index_B1];
        CCTK_REAL B2lL = B2l[index_B2];
        CCTK_REAL B2rL = B2r[index_B2];

        CCTK_REAL A3_rhs_rr = psi6_interped*(v1rr[index_v]*B2rL - v2rr[index_v]*B1rL);
        CCTK_REAL A3_rhs_rl = psi6_interped*(v1rl[index_v]*B2rL - v2rl[index_v]*B1lL);
        CCTK_REAL A3_rhs_lr = psi6_interped*(v1lr[index_v]*B2lL - v2lr[index_v]*B1rL);
        CCTK_REAL A3_rhs_ll = psi6_interped*(v1ll[index_v]*B2lL - v2ll[index_v]*B1lL);

        // All variables for the A_i_rhs computation are now at the appropriate staggered point,
        //   so it's time to compute the HLL flux!

        // Note that with PPM, cmin and cmax are defined between ijk=3 and ijk<cctk_lsh[]-2 for all directions.
        CCTK_REAL cmax_1L = cmax_1[index_B2];
        CCTK_REAL cmin_1L = cmin_1[index_B2];
        CCTK_REAL cmax_2L = cmax_2[index_B1];
        CCTK_REAL cmin_2L = cmin_2[index_B1];

        CCTK_REAL B1tilder_minus_B1tildel = psi6_interped*( B1rL - B1lL );
        CCTK_REAL B2tilder_minus_B2tildel = psi6_interped*( B2rL - B2lL );

        /*---------------------------
         * Implement 2D HLL flux
         * [see Del Zanna, Bucciantini & Londrillo A&A 400, 397 (2003), Eq. (44)]
         *
         * Note that cmax/cmin (\alpha^{\pm}  as defined in that paper) is at a slightly DIFFERENT
         * point than that described in the Del Zanna et al paper (e.g., (i+1/2,j,k) instead of
         * (i+1/2,j+1/2,k) for F3).  Yuk Tung Liu discussed this point with M. Shibata,
         * who found that the effect is negligible.
         ---------------------------*/
        A3_rhs[index] = (cmax_1L*cmax_2L*A3_rhs_ll + cmax_1L*cmin_2L*A3_rhs_lr +
                         cmin_1L*cmax_2L*A3_rhs_rl + cmin_1L*cmin_2L*A3_rhs_rr)
          /( (cmax_1L+cmin_1L)*(cmax_2L+cmin_2L) )
          - cmax_1L*cmin_1L*(B2tilder_minus_B2tildel)/(cmax_1L+cmin_1L)
          + cmax_2L*cmin_2L*(B1tilder_minus_B1tildel)/(cmax_2L+cmin_2L);
      }
}

```

    Appending to ../src/A_i_rhs_no_gauge_terms.C

# Step 2: Code validation \[Back to [top](#toc)\]
$$\label{code_validation}$$

First we download the original `IllinoisGRMHD` source code and then compare it to the source code generated by this tutorial notebook.

```python
# # Verify if the code generated by this tutorial module
# # matches the original IllinoisGRMHD source code

# # First download the original IllinoisGRMHD source code
# import urllib
# from os import path

# original_IGM_file_url  = "https://bitbucket.org/zach_etienne/wvuthorns/raw/5611b2f0b17135538c9d9d17c7da062abe0401b6/IllinoisGRMHD/src/A_i_rhs_no_gauge_terms.C"
# original_IGM_file_name = "A_i_rhs_no_gauge_terms-original.C"
# original_IGM_file_path = os.path.join(IGM_src_dir_path,original_IGM_file_name)

# # Then download the original IllinoisGRMHD source code
# # We try it here in a couple of ways in an attempt to keep
# # the code more portable
# try:
#     original_IGM_file_code = urllib.request.urlopen(original_IGM_file_url).read().decode("utf-8")
#     # Write down the file the original IllinoisGRMHD source code
#     with open(original_IGM_file_path,"w") as file:
#         file.write(original_IGM_file_code)
# except:
#     try:
#         original_IGM_file_code = urllib.urlopen(original_IGM_file_url).read().decode("utf-8")
#         # Write down the file the original IllinoisGRMHD source code
#         with open(original_IGM_file_path,"w") as file:
#             file.write(original_IGM_file_code)
#     except:
#         # If all else fails, hope wget does the job
#         !wget -O $original_IGM_file_path $original_IGM_file_url

# # Perform validation
# Validation__A_i_rhs_no_gauge_terms__C  = !diff $original_IGM_file_path $outfile_path__A_i_rhs_no_gauge_terms__C

# if Validation__A_i_rhs_no_gauge_terms__C == []:
#     # If the validation passes, we do not need to store the original IGM source code file
#     !rm $original_IGM_file_path
#     print("Validation test for A_i_rhs_no_gauge_terms.C: PASSED!")
# else:
#     # If the validation fails, we keep the original IGM source code file
#     print("Validation test for A_i_rhs_no_gauge_terms.C: FAILED!")
#     # We also print out the difference between the code generated
#     # in this tutorial module and the original IGM source code
#     print("Diff:")
#     for diff_line in Validation__A_i_rhs_no_gauge_terms__C:
#         print(diff_line)
```

# Step 3: Output this notebook to $\LaTeX$-formatted PDF file \[Back to [top](#toc)\]
$$\label{latex_pdf_output}$$

The following code cell converts this Jupyter notebook into a proper, clickable $\LaTeX$-formatted PDF file. After the cell is successfully run, the generated PDF may be found in the root NRPy+ tutorial directory, with filename
[Tutorial-IllinoisGRMHD__A_i_rhs_no_gauge_terms.pdf](Tutorial-IllinoisGRMHD__A_i_rhs_no_gauge_terms.pdf) (Note that clicking on this link may not work; you may need to open the PDF file through another means).

```python
latex_nrpy_style_path = os.path.join(nrpy_dir_path,"latex_nrpy_style.tplx")
#!jupyter nbconvert --to latex --template $latex_nrpy_style_path --log-level='WARN' Tutorial-IllinoisGRMHD__A_i_rhs_no_gauge_terms.ipynb
#!pdflatex -interaction=batchmode Tutorial-IllinoisGRMHD__A_i_rhs_no_gauge_terms.tex
#!pdflatex -interaction=batchmode Tutorial-IllinoisGRMHD__A_i_rhs_no_gauge_terms.tex
#!pdflatex -interaction=batchmode Tutorial-IllinoisGRMHD__A_i_rhs_no_gauge_terms.tex
!rm -f Tut*.out Tut*.aux Tut*.log
```


# Creating Synthetic Proton Radiographs by Particle Tracing

[SyntheticProtronRadiography]: ../../api/plasmapy.diagnostics.proton_radiography.SyntheticProtonRadiograph.rst#plasmapy.diagnostics.proton_radiography.SyntheticProtonRadiograph

Proton radiography is a diagnostic technique often used to interrogate the electric and magnetic fields inside high energy density plasmas. The area of interest is positioned between a bright source of protons and a detector plane. Electric and magnetic fields in the plasma deflect the protons, producing patterns on the detector. Since this represents a non-linear and line-integrated measurement of the fields, the interpretation of these "proton radiographs" is complicated.

The [SyntheticProtronRadiography] class creates a synthetic proton radiographs given a grid of electric and magnetic field (produced either by simulations or analytical models). After the geometry of the problem has been set up, a particle tracing algorithm is run, pushing the protons through the field region. After all of the protons have reached the detector plane, a synthetic proton radiograph is created by making a 2D histogram in that plane.

```python
import astropy.constants as const
import astropy.units as u
import matplotlib.pyplot as plt
import numpy as np

from mpl_toolkits.mplot3d import Axes3D

from plasmapy.diagnostics import proton_radiography as prad
from plasmapy.plasma.grids import CartesianGrid
```

[CartesianGrid]: ../../api/plasmapy.plasma.grids.CartesianGrid.rst#plasmapy.plasma.grids.CartesianGrid

To illustrate the use of this package, we'll first create an example [CartesianGrid] object and fill it with the analytical electric field produced by a sphere of Gaussian potential.

```python
# Create a Cartesian grid
L = 1 * u.mm
grid = CartesianGrid(-L, L, num=100)

# Create a spherical potential with a Gaussian radial distribution
radius = np.linalg.norm(grid.grid, axis=3)
arg = (radius / (L / 3)).to(u.dimensionless_unscaled)
potential = 2e5 * np.exp(-(arg ** 2)) * u.V

# Calculate E from the potential
Ex, Ey, Ez = np.gradient(potential, grid.dax0, grid.dax1, grid.dax2)
Ex = -np.where(radius < L / 2, Ex, 0)
Ey = -np.where(radius < L / 2, Ey, 0)
Ez = -np.where(radius < L / 2, Ez, 0)

# Add those quantities to the grid
grid.add_quantities(E_x=Ex, E_y=Ey, E_z=Ez, phi=potential)

# Plot the E-field
fig = plt.figure(figsize=(8, 8))
ax = fig.add_subplot(111, projection="3d")
ax.view_init(30, 30)

# skip some points to make the vector plot intelligable
s = tuple([slice(None, None, 6)] * 3)

ax.quiver(
    grid.pts0[s].to(u.mm).value,
    grid.pts1[s].to(u.mm).value,
    grid.pts2[s].to(u.mm).value,
    grid["E_x"][s],
    grid["E_y"][s],
    grid["E_z"][s],
    length=1e-6,
)

ax.set_xlabel("X (mm)")
ax.set_ylabel("Y (mm)")
ax.set_zlabel("Z (mm)")
ax.set_xlim(-1, 1)
ax.set_ylim(-1, 1)
ax.set_zlim(-1, 1)
ax.set_title("Gaussian Potential Electric Field")
```

[astropy.units.Quantity]: https://docs.astropy.org/en/stable/units/quantity.html#quantity

Prior to running the particle tracing algorithm, the simulation instance must be instantiated by providing some information about the setup, including the locations of the source and detector relative to the origin of the grid.

The source and detector coordinates are entered as a 3-tuple in one of three coordinate systems: Cartesian ($x$, $y$, $z$), spherical ($r$, $\theta$, $\phi$) or cylindrical ($r$, $\theta$, $z$). All values should be [astropy.units.Quantity] instances with units of either length or angle. The vector from the source to the detector should pass through the origin to maximize the number of particles that pass through the simulated fields.

```python
source = (0 * u.mm, -10 * u.mm, 0 * u.mm)
detector = (0 * u.mm, 100 * u.mm, 0 * u.mm)

sim = prad.SyntheticProtonRadiograph(grid, source, detector, verbose=True)
```

[create_particles()]: ../../api/plasmapy.diagnostics.proton_radiography.SyntheticProtonRadiograph.rst#plasmapy.diagnostics.proton_radiography.SyntheticProtonRadiograph.create_particles

Note that, since the example grid did not include a B-field, the B-field is assumed to be zero and a warning is printed.

Next, a distribution of `nparticles` simulated particles of energy `particle_energy` is created using the [create_particles()] function. Setting the `max_theta` parameter eliminates particles with large angles (relative to the source-detector axis) which otherwise would likely not hit the detector. Particles with angles less than $\theta_{max}$ but greater than $\theta_{track}$ in the setup figure above will not cross the grid. These particles are retained, but are coasted directly to the detector plane instead of being pushed through the grid.

The `particle` keyword sets the type of the particles being traced. The default particle is protons, which is set here explicitly to demonstrate the use of the keyword.

By default, the particle velocities are initialized with random angles (a Monte-Carlo approach) with a uniform flux per unit solid angle. However, particles can also be initialized in other ways by setting the `distribution` keyword.

```python
sim.create_particles(1e5, 3 * u.MeV, max_theta=np.pi / 15 * u.rad, particle="p")
```

[AbstractGrid]: ../../api/plasmapy.plasma.grids.AbstractGrid.rst#plasmapy.plasma.grids.AbstractGrid

[run()]: ../../api/plasmapy.diagnostics.proton_radiography.SyntheticProtonRadiograph.rst#plasmapy.diagnostics.proton_radiography.SyntheticProtonRadiograph.run

The simulation is now ready to run. In brief, the steps of the simulation cycle are as follows:

1. Particles that will never hit the field grid are ignored (until a later step, when they will be automatically advanced to the detector plane).

2. Particles are advanced to the time when the first particle enters the simulation volume. This is done in one step to save computation time.

3. While particles are on the grid, the particle pusher advances them each timestep by executing the following steps:

    A. The fields at each particle's location are interpolated using the interpolators defined in the [AbstractGrid] subclasses.

    B. The simulation timestep is automatically (and adaptively) calculated based on the proton energy, grid resolution, and field amplitudes. This timestep can be clamped or overridden by setting the `dt` keyword in the [run()] function.

    C. An implementation of the Boris particle push algorithm is used to advance the velocities and positions of the particles in the interpolated fields.

4. After all of the particles have left the grid, all particles are advanced to the detector plane (again saving time). Particles that are headed away from the detector plane at this point are deleted, as those particles will never
be detected.

When the simulation runs, a progress meter will show the number of particles currently on the grid. This bar will start at zero, increase as particles enter the grid, then decrease as they leave it. When almost all particles have left the grid, the simulation ends.

```python
sim.run()
```

The following plot illustrates that, after the simulation has ended, all particles have been advanced to the detector plane.

```python
fig = plt.figure(figsize=(8, 8))
ax = fig.add_subplot(111, projection="3d")
ax.view_init(30, 150)
ax.set_xlabel("X (cm)")
ax.set_ylabel("Y (cm)")
ax.set_zlabel("Z (cm)")

# Plot the source-to-detector axis
ax.quiver(
    sim.source[0] * 100,
    sim.source[1] * 100,
    sim.source[2] * 100,
    sim.detector[0] * 100,
    sim.detector[1] * 100,
    sim.detector[2] * 100,
    color="black",
)

# Plot the simulation field grid volume
ax.scatter(0, 0, 0, color="green", marker="s", linewidth=5, label="Simulated Fields")

# Plot the the proton source and detector plane locations
ax.scatter(
    sim.source[0] * 100,
    sim.source[1] * 100,
    sim.source[2] * 100,
    color="red",
    marker="*",
    linewidth=5,
    label="Source",
)

ax.scatter(
    sim.detector[0] * 100,
    sim.detector[1] * 100,
    sim.detector[2] * 100,
    color="blue",
    marker="*",
    linewidth=10,
    label="Detector",
)

# Plot the final proton positions of some (not all) of the protons
ind = slice(None, None, 200)
ax.scatter(
    sim.x[ind, 0] * 100, sim.x[ind, 1] * 100, sim.x[ind, 2] * 100, label="Protons",
)

ax.legend()
```

A 'synthetic proton radiograph' can now be constructed by creating a 2D histogram of proton positions in the image plane. The synthetic radiograph function takes two keywords:

- 'size' gives the locations of the lower left and upper right corners of the detector grid in image plane coordinates.

- 'bins' is the number of histogram bins to be used in the horizontal and vertical directions. Using more bins creates a higher resolution image, but at the cost of more noise.

```python
# A function to reduce repetative plotting
def plot_radiograph(hax, vax, intensity):
    fig, ax = plt.subplots(figsize=(8, 8))
    plot = ax.pcolormesh(
        hax.to(u.cm).value,
        vax.to(u.cm).value,
        intensity.T,
        cmap="Blues_r",
        shading="auto",
    )
    cb = fig.colorbar(plot)
    cb.ax.set_ylabel("Intensity")
    ax.set_aspect("equal")
    ax.set_xlabel("X (cm), Image plane")
    ax.set_ylabel("Z (cm), Image plane")
    ax.set_title("Synthetic Proton Radiograph")

size = np.array([[-1, 1], [-1, 1]]) * 1.5 * u.cm
bins = [200, 200]
hax, vax, intensity = sim.synthetic_radiograph(size=size, bins=bins)
plot_radiograph(hax, vax, intensity)
```

As expected, the outward-pointing electric field in the sphere has deflected the protons out of the central region, leaving a dark shadow.

Kugland et al. 2012 and Bott et al. 2017 define the dimensionless "contrast parameter" that separates different regimes of proton radiography:

\begin{equation}
\mu = \frac{l \alpha}{a}
\end{equation}
Where $l$ is the distance from the source to the grid, $a$ is the spatial scale of the scattering electromagnetic fields, and $\alpha$ is the particle deflection angle. The value of $\mu$ can fall in one of three regimes:

\begin{align}
\mu &\ll 1 \rightarrow \text{ linear}\\
\mu &< \mu_c \rightarrow \text{ nonlinear injective}\\
\mu &> \mu_c \rightarrow \text{ caustic}\\
\end{align}

where $\mu_c \sim 1$ is a characteristic value at which particle paths cross, leading to the formation of bright caustics. Correctly placing a radiograph in the correct regime is necessary to determine which analysis techniques can be applied to it.

The maximum deflection angle can be calculated after the simulation has run by comparing the initial and final velocity vectors of each particle

```python
max_deflection = sim.max_deflection
print(f"Maximum deflection α = {np.rad2deg(max_deflection):.2f}")
```

The spatial scale of the field constructed in this example is $\sim$ 1 mm, and $l$ is approximately the distance from the source to the grid origin. Therefore, we can calculate the value of $\mu$

```python
a = 1 * u.mm
l = np.linalg.norm(sim.source * u.m).to(u.mm)
mu = l * max_deflection.value / a
print(f"a = {a}")
print(f"l = {l:.1f}")
print(f"μ = {mu:.2f}")
```

which places this example in the non-linear injective regime.

## Options

[create_particles()]: ../../api/plasmapy.diagnostics.proton_radiography.SyntheticProtonRadiograph.rst#plasmapy.diagnostics.proton_radiography.SyntheticProtonRadiograph.create_particles

For sake of comparison, here is the result achieved by setting `distribution = 'uniform'` in the [create_particles()] function.

```python
sim.create_particles(
    1e5, 3 * u.MeV, max_theta=np.pi / 15 * u.rad, distribution="uniform"
)
sim.run()
size = np.array([[-1, 1], [-1, 1]]) * 1.5 * u.cm
bins = [200, 200]
hax, vax, intensity = sim.synthetic_radiograph(size=size, bins=bins)
plot_radiograph(hax, vax, intensity)
```


```python
import sympy as sym
from sympy.solvers import solve
from sympy import Symbol
from sympy.abc import x,y,z,a,b,c,d,e,f,g,h,i,j,k,l
import dill
import time
import numpy as np
```

```python
# Helper functions to simplify final expressions
def collect_by_list(expr_in, sort_list):
    expr = sym.expand(expr_in)
    res = expr
    for s in sort_list:
        res = sym.rcollect(res, s)
    return res

def get_symbol_list(expr_in, return_counts=False):
    expr = sym.expand(expr_in)
    syms = {}
    for a in expr.atoms():
        if type(a) is sym.Symbol:
            syms[a] = expr.count(a)
    symlist = list(sorted(syms, key=syms.__getitem__, reverse=True))
    if return_counts:
        countlist = list(sorted(syms.values(), reverse=True))
        return symlist, countlist
    else:
        return symlist

def collect_auto(expr_in, print_ops=False, syms_to_prioritize=[]):
    slist = get_symbol_list(expr_in)
    sort_list = syms_to_prioritize
    for s in slist:
        if s not in syms_to_prioritize:
            sort_list.append(s)
    res = collect_by_list(expr_in, sort_list)
    if print_ops:
        print(sym.count_ops(res))
    return res

def collect_terms_in_fraction(expr_in, terms_to_prioritize=[]):
    numerator, denominator = sym.fraction(expr_in)
    num_simp = collect_auto(numerator, syms_to_prioritize=terms_to_prioritize)
    den_simp = collect_auto(denominator, syms_to_prioritize=terms_to_prioritize)
    return num_simp/den_simp
```

# Full System of Equations

```python
dKMgO_KMgO = Symbol('dKMgO_KMgO')
dKFeO_KFeO = Symbol('dKFeO_KFeO')
dKSiO2_KSiO2 = Symbol('dKSiO2_KSiO2')
dKMgSiO3_KMgSiO3 = Symbol('dKMgSiO3_KMgSiO3')
dKFeSiO3_KFeSiO3 = Symbol('dKFeSiO3_KFeSiO3')

dM_Mg = Symbol('dM_Mg')
dM_Fe = Symbol('dM_Fe')
dM_Si = Symbol('dM_Si')
dM_O = Symbol('dM_O')
dM_c = Symbol('dM_c')

dM_MgO = Symbol('dM_MgO')
dM_FeO = Symbol('dM_FeO')
dM_SiO2 = Symbol('dM_SiO2')
dM_MgSiO3 = Symbol('dM_MgSiO3')
dM_FeSiO3 = Symbol('dM_FeSiO3')
dM_m = Symbol('dM_m')

M_Mg = Symbol('M_Mg')
M_Fe = Symbol('M_Fe')
M_Si = Symbol('M_Si')
M_O = Symbol('M_O')
M_c = Symbol('M_c')

M_MgO = Symbol('M_MgO')
M_FeO = Symbol('M_FeO')
M_SiO2 = Symbol('M_SiO2')
M_MgSiO3 = Symbol('M_MgSiO3')
M_FeSiO3 = Symbol('M_FeSiO3')
M_m = Symbol('M_m')
```

```python
# Total Moles
# eq_mantle_total_moles = M_MgO + M_FeO + M_SiO2 + M_MgSiO3 + M_FeSiO3 - M_m
# eq_core_total_moles = M_Mg + M_Fe + M_Si + M_O - M_c
eq_mantle_dtotal_moles = dM_MgO + dM_FeO + dM_SiO2 + dM_MgSiO3 + dM_FeSiO3 - dM_m
eq_core_dtotal_moles = dM_Mg + dM_Fe + dM_Si + dM_O - dM_c

# mantle interactions
eq_K_MgSiO3 = dM_MgO/M_MgO + dM_SiO2/M_SiO2 - dM_MgSiO3/M_MgSiO3 - dM_m/M_m - dKMgSiO3_KMgSiO3
eq_K_FeSiO3 = dM_FeO/M_FeO + dM_SiO2/M_SiO2 - dM_FeSiO3/M_FeSiO3 - dM_m/M_m - dKFeSiO3_KFeSiO3

# core interactions
eq_K_MgO = dM_Mg/M_Mg + dM_O/M_O + dM_m/M_m - 2*dM_c/M_c - dM_MgO/M_MgO - dKMgO_KMgO
eq_K_FeO = dM_Fe/M_Fe + dM_O/M_O + dM_m/M_m - 2*dM_c/M_c - dM_FeO/M_FeO - dKFeO_KFeO
eq_K_SiO2 = dM_Si/M_Si + 2*dM_O/M_O + dM_m/M_m - 3*dM_c/M_c - dM_SiO2/M_SiO2 - dKSiO2_KSiO2

# species continuity
eq_dM_Mg = dM_MgO + dM_MgSiO3 + dM_Mg
eq_dM_Fe = dM_FeO + dM_FeSiO3 + dM_Fe
eq_dM_Si = dM_SiO2 + dM_MgSiO3 + dM_FeSiO3 + dM_Si
eq_dM_O =  dM_MgO + dM_FeO + 2*dM_SiO2 + 3*dM_MgSiO3 + 3*dM_FeSiO3 + dM_O

equations = [eq_mantle_dtotal_moles, eq_core_dtotal_moles,
            eq_K_MgSiO3, eq_K_FeSiO3,
            eq_K_MgO, eq_K_FeO, eq_K_SiO2,
            eq_dM_Fe, eq_dM_Mg, eq_dM_O, eq_dM_Si]
solve_for = [dM_c, dM_Fe, dM_FeO, dM_FeSiO3, dM_m, dM_Mg, dM_MgO, dM_MgSiO3, dM_O, dM_Si, dM_SiO2]
```

```python
start = -time.time()
solution = solve(equations, solve_for)
time_elapsed = start+time.time()
```

```python
print('{:.1f} hrs to compute full solution'.format(time_elapsed/60/60))
```

    1.9 hrs to compute full solution

```python
# Save unsimplified full solution straight from the computation
dill.dump(solution,open('computed_solution.m','wb'))
```

```python
solution = dill.load(open('computed_solution.m','rb'))
```

# Simplify Solution

```python
# Simplify the solution using the helper functions at the top
terms_to_prioritize = [dKMgO_KMgO, dKFeO_KFeO, dKSiO2_KSiO2, dKMgSiO3_KMgSiO3, dKFeSiO3_KFeSiO3]
simplified = {}
for k,v in solution.items():
    simplified[k] = collect_terms_in_fraction(v, terms_to_prioritize=terms_to_prioritize)
```

```python
# Demonstrate how much simpler the solutions are compared to those computed by sympy
print('{:.0f} operations in full solution'.format(sym.count_ops(solution[dM_Si])))
print('{:.0f} operations in simplified solution'.format(sym.count_ops(simplified[dM_Si])))
```

    3745 operations in full solution
    1507 operations in simplified solution

```python
# save simplified solution
dill.dump(simplified, open('simplified_solution.m','wb'))
```

# Check by plugging in random values into Variables

```python
def gen_and_check_rand_values(eqns, vars_to_rand=None):
    rand_values = gen_rand_vals(vars_to_rand=vars_to_rand)
    results = eval_eqns(eqns, rand_values)
    return rand_values, results

def gen_rand_vals(vars_to_rand = None):
    if vars_to_rand is None:
        vars_to_rand = [M_Mg, M_Fe, M_Si, M_O, M_c, M_MgO, M_FeO, M_SiO2, M_MgSiO3, M_FeSiO3, M_m, dKMgO_KMgO, dKFeO_KFeO, dKSiO2_KSiO2, dKMgSiO3_KMgSiO3, dKFeSiO3_KFeSiO3]
    rand_values = {}
    for v in vars_to_rand:
        rand_values[v] = np.random.rand()
    return rand_values

def eval_eqns(eqns, rand_values_dict):
    results = {}
    for symb in eqns.keys():
        results[symb] = eq_rand_values[symb].subs(rand_values_dict)
    return results

```

```python
rand_values0, results0 = gen_and_check_rand_values(simplified)
rand_values1, results1 = gen_and_check_rand_values(simplified)
```

```python
rand_results = ((rand_values0, results0),
(rand_values1, results1))
dill.dump(rand_results, open('rand_results.m','wb'))
```

# Import Simplified Solution

```python
# open simplified solution
simplified = dill.load(open('simplified_solution2.m','rb'))
```

```python
eqns_file = open('eqns_funcs.py','w')
for k,v in simplified.items():
    eqns_file.write('\tdef '+str(k)+'_dTc(self, Moles, dKs, dMi_b):\n')
    eqns_file.write('\t\t\'\'\'compute {} given Moles, dKDs, and dMm_b/dT\'\'\'\n'.format(k))
    eqns_file.write('\t\tdM_MgO_er, dM_SiO2_er, dM_FeO_er, dM_MgSiO3_er, dM_FeSiO3_er = dMi_b\n')
    eqns_file.write('\t\tM_Mg, M_Si, M_Fe, M_O, M_c, M_MgO, M_SiO2, M_FeO, M_MgSiO3, M_FeSiO3, M_m = self.unwrap_Moles(Moles)\n')
    eqns_file.write('\t\tdKMgO_KMgO, dKSiO2_KSiO2, dKFeO_KFeO, dKMgSiO3_KMgSiO3, dKFeSiO3_KFeSiO3 = dKs\n')
    eqns_file.write('\t\treturn '+str(v)+'\n\n')
eqns_file.close()

```

```python

```

MgSiSystemOfEquations/Solve MgO System with Sympy.ipynb


```
# default_exp intro_to_control_theory
```

```
%load_ext autoreload
%autoreload 2
```

```
from IPython.display import Image
from IPython.display import HTML

# For animations
from matplotlib import animation, rc

# equivalent to rcParams['animation.html'] = 'html5'
rc('animation', html='html5')
```

```
#export
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
```

```
%matplotlib inline
```

```
#hide
from nbdev.showdoc import *
```

------------------------

# The Control Problem

This notebook provides an overview of the big picture problem that we’re trying to solve as control system engineers.

- Control theory allows to solve a number of engineering problems, not only as control engineers but as any engineer
    - Switching power regulators
    - Automatic gain control circuits that automatically increase or decrease the gain of a signal
    - Isolation system in a motor mount that is sensitive to vibrations
    - Industrial robotics
    - etc.

Control system is:
- Building models of your system
- Simulate them to make predictions
- Understanding the dynamics and how they interact with the rest of the system
- Filtering and rejecting noise
- Selecting or designing hardware (sensors, actuators)
- Testing the system in expected and unexpected environments
- It is understanding your system!

------------------

## What is a system?

The concept is straigthforward, but the term is sometime applied very generically

For us:

> A **system** is a collection of interconnected parts that form a larger more complex whole

Engineering problems are usually complex.
- Divide complex projects into smaller parts, or _systems_ makes it possible to simplify the problem and to specialise in specific areas
- Usually more than one layer of complexity
- Each one of the interconnected parts that form a larger system can be a complex system in its own right!

### Control systems

As a control engineer:
- Goal is to create something that meets the functional or performance requirements you set for the project.
- The collection of the interconnected parts that are created specifically to meet these requirements are the _control system_ (at least in general)
- More specifically: **A control system is a mechanism (hardware or software) that alters the future state of a system**

- For any project however the control system again might be a collection of interconnected parts that require specialists:
    - sensor experts,
    - actuators experts,
    - digital signal processing experts,
    - state estimation experts,
    - etc.

For example:

And of course, the breaking system itself is just one of the main interconnected parts that create the car.

### System as a box

- We represent systems graphically as a box
- Arrows going into the box are inputs
- Arrows going out of the box are output

- The system inside the box is described through a math model (e.g. equation of motion)
- The "box" can be used to describe systems that are simple or complex

So if we want to start representing it more formally:

- $u$: control inputs

- $\xi$: disturbances

- $f$: system (physics is involved here!)

- $\theta$: system parameters (e.g., mass, inertia, spring constants, etc. )

---------------------------

## Three different problems
- Three parts to our simple block diagram
    - The system
    - The inputs (that drive the system)
    - The output (that the system generates)

At any given time one of the three parts are unknown, and the part that is unknown defines the problem that you are solving.

### The system identification problem

- As a practicing engineer you won’t always be given a model of your system
- You will need to determine it yourself
- This is done through a process called **System Identification**

- What is the mathematical equation that will convert my known inputs into my measured outputs?
- What should I model?

#### Black box vs White box

**Black box**:
- You are given a box that you cannot open, but you are asked to model what is inside
- Subject what is inside the box to various known inputs, measure the output and then infer what is inside the box based on the relationship between the input and the output.

**White box**
- Now you can see exactly what is inside the box (hardware, software).
- You can write the mathematical equations of the dynamics directly (e.g. Netwon's equations of motion).

For example, for a sping-mass system:

 $$F = m\ddot{x} + kx$$

- Even the while box method might require to run tests and applying inputs to measure outputs to calculate the parameters of your system.
- E.g., modeling a linear spring: you know the equation but what is the exact spring constant?

Usually, you need to do a bit of both.

------------------------

#### Example: modeling a car

Let's consider an example: car cruise control, using a simplified mathematical model.

$$m\frac{dv}{dt} + \alpha|v|v+\beta v = \gamma u-mgsin(\theta)$$

- Input: gas pedal
- Output: speed (speedometer)
- Disturbance: the slope of the road

Assumptions to simplify the model:
- Thrust is proportional to how much pressure we put on the pedal and this directly translates into how much is open the gas valve
- Frictions and drags are linear with speed
- Small angles: $\theta < 30^o$ so that $sin(\theta) \approx \theta$

Using our assumptions, the previous model can be simplified as:

$$m\frac{dv}{dt} + \alpha|v|v+\beta v = \gamma u-mgsin(\theta) \approx m\frac{dv}{dt} + \beta v = \gamma u-mg\theta$$

Before we simplify the model though, we can re-write it in a **standard representation** (state space representation):

$$\mathbf{\dot{x}} = [\dot{x_1}, \dot{x_2}]^T$$

\begin{cases}
 \dot{x_1} &= x_2 \\
 \dot{x_2} &= -\frac{\alpha}{m}|x_2|x_2 - \frac{\beta}{m}x_2 + \frac{\gamma}{m}u -gsin(\theta) \\
 y &=x_2
\end{cases}

$$\mathbf{x}(t_0) = x_0$$

- Velocity is the derivative of the position
- Two state variables: position and velocity
- Two inputs: $u$ and $\theta$

- The state $\mathbf{x}$ includes the minimal amount of information at the current time to predict its behaviour in the future, only based on the knowledge of its future inputs.

- In the case where we have differential equations from actual physical systems we also need the initial conditions.

- Note that there is no time in the equations above: **time-invariant** system

#### Standard representations

- "standard representations" are the bulding blocks of the engineering language
- Analysis and design are based on having the system in a standard representation
- Specific problems typically need to be translated into a standard representation
- Control System Software typically assumes that the system is already in a standard representation (and expects inputs based on that)

We can code up our standard representation of the car in Python:

```
#export
class Car:
    _g = 9.8 # Gravity

    def __init__(self, x0, params):
        self._x_1 = x0[0] # position (along the road)
        self._x_2 = x0[1] # velocity (along the road)
        self._m, self._alpha, self._beta, self._gamma = params

    def step(self, dt, u, theta):
        self.theta = theta
        self._x_1 = self._x_1 + dt*self._x_2
        self._x_2 = self._x_2 + dt*(-self._alpha/self._m*abs(self._x_2)*self._x_2 - \
                                    self._beta/self._m*self._x_2 + self._gamma/self._m*u - \
                                    Car._g*np.sin(theta))

    def speedometer(self):
        v = self._x_2
        return (v,)

    # Utility function to simplify plotting
    def sensor_i(self):
        # Rotation matrix to get back to the main frame.
        R = np.array(((np.cos(theta), -np.sin(theta)), (np.sin(theta), np.cos(theta))))
        x_i, y_i = R.dot(np.array([[self._x_1],[0]]))
        v = self._x_2
        return (x_i, y_i, v)
```

We can linearise the previous model of the car around an equilibrium ($\dot{\mathbf{x}}=0$) as below.

\begin{equation}
\frac{df}{dx}=
\begin{bmatrix}
0 & 1\\
0 & -\frac{\alpha}{m}|x_2|-\frac{\beta}{m}
\end{bmatrix}
\end{equation}

\begin{equation}
\frac{df}{du}=
\begin{bmatrix}
0 & 0\\
\frac{\gamma}{m} & -gcos(\theta)
\end{bmatrix}
\end{equation}

which, at the equilibrium $x_1=0, x_2=0$ (note that $x_1$ really does not affect the result):

\begin{equation}
A=\frac{df}{dx}=
\begin{bmatrix}
0 & 1\\
0 & -\frac{\beta}{m}
\end{bmatrix}
\end{equation}

\begin{equation}
B=\frac{df}{du}=
\begin{bmatrix}
0 & 0\\
\frac{\gamma}{m} & -g
\end{bmatrix}
\end{equation}

and

$$\dot{\mathbf{x}}=A\mathbf{x}+B\mathbf{u}$$
$$y=[0 \; 1]\mathbf{x}$$

```
#export
class LinearCar:
    _g = 9.8

    def __init__(self, x0, params):
        self._x_1 = x0[0] # position (along the road)
        self._x_2 = x0[1] # velocity
        self._m, self._alpha, self._beta, self._gamma = params

    def step(self, dt, u, theta):
        self._theta = theta
        A = np.array([[0, 1], [0, -self._beta/self._m]])
        B = np.array([[0, 0], [self._gamma/self._m, -LinearCar._g]])

        x = np.array([[self._x_1],[self._x_2]])
        U = np.array([[u],[theta]])
        self._x_1 = (self._x_1 + dt*(A[0,np.newaxis,:].dot(x) + B[0,np.newaxis,:].dot(U))).item()
        self._x_2 = (self._x_2 + dt*(A[1,np.newaxis,:].dot(x) + B[1,np.newaxis,:].dot(U))).item()

    def speedometer(self):
        v = self._x_2
        return (v,)

    # Utility function to simplify plotting
    def sensor_i(self):
        # Rotation matrix to get back to the inertial frame..
        R = np.array(((np.cos(self._theta), -np.sin(self._theta)),
                      (np.sin(self._theta), np.cos(self._theta))))
        x_i, y_i = R.dot(np.array([[self._x_1],[0]]))
        v = self._x_2
        return (x_i, y_i, v)
```

#### Comments
- Multiple models can be used to represent a physical system
- There are a number of parameters in our models ($m$, $\alpha$, $\beta$, $\gamma$)
- Their value depend on the specific car you are modeling
- It might not be easy to have good values for these parameters

## The simulation problem

- Predict how your outputs change given a known set of inputs and the mathematical model of the system
- Usually a lot of design time is spent in this stage
- It can be difficult to figure out the set of inputs and their range to characterise the operational envelope of the system

You need to run a simulation to answer:
- Does my system model match my test data?
- Will my system work in all operating environments?
- What happens when...
- Simulations can also be used to limit the number of field tests (which are usually expensive or might let you avoid dangerous maneouvers)

If we go back to our Python car, we can now easily simulate it:

```
# We assume we have identified the model parameters
m = 10
alpha = 1
beta = 1
gamma = 1
params = (m, alpha, beta, gamma)

# We select the car initial conditions (position and velocity)
x_0 = (0,0)

# We create our car
car = Car(x_0, params)
```

```
# We define out inputs:
theta = np.radians(20) # disturbance
u = 0                  # Input

# And finally we define the simulation parameters
t0, tf, dt = 0, 10, 0.1 # time

position = []
velocity = []
time = []
for t in np.arange(t0, tf, dt):
    car.step(dt, u, theta)
    x, y, v = car.sensor_i()
    position.append((x,y))
    velocity.append(v)
    time.append(t)

print('simulation complete')
```

    simulation complete

Done! Now we can see what happened..

Let's plot the results:

```
fig, ax = plt.subplots();

plt.plot(time, velocity)
plt.xlabel('time (s)')
plt.ylabel('speed (m/s)');
```

What happens if we simulate our LinearCar?

```
# We select the car initial conditions (position and velocity)
x_0 = (0,0)

# We create our car
car = LinearCar(x_0, params)

# We define out inputs:
theta = np.radians(20) # disturbance
u = 0                  # Input

# And finally we define the simulation parameters
t0, tf, dt = 0, 10, 0.1 # time

lin_position = []
lin_velocity = []
time = []
for t in np.arange(t0, tf, dt):
    car.step(dt, u, theta)
    x, y, v = car.sensor_i()
    lin_position.append((x,y)), lin_velocity.append(v)
    time.append(t)

print('simulation complete')
```

    simulation complete

```
fig, ax = plt.subplots();

plt.plot(time, lin_velocity)
plt.xlabel('time (s)')
plt.ylabel('speed (m/s)');
```

- Multiple models can be used to represent a physical system and the complexity of the model depends on the objectives

### More complex representation

We can have much more complex representation of our simulation:

```
# First set up the figure, the axis, and the plot elements we want to animate
fig, ax = plt.subplots();

ax.set_xlim((min(position)[0], max(position)[0]))
ax.set_ylim((min(position)[1]*2, max(position)[1]))
line, = ax.plot([], [], lw=4);
```

```
# draw terrain
def terrain(theta_rad, x_0, x_range):
    y_range = theta*(x_range-x_0[0])+x_0[1]-10
    close_triangle = (x_range[0], y_range[-1])
    x_range = np.append(x_range, close_triangle[0])
    y_range = np.append(y_range, close_triangle[1])
    X = np.matrix([x_range, y_range]).transpose()
    patch = plt.Polygon(X, color='yellow')
    return patch

x_range = np.linspace(int(position[0][0]), int(position[-1][0]), num=20)
patch = terrain(theta_rad=theta, x_0=x_0, x_range=x_range)

# initialization function: plot the background of each frame
def init():
    ax.add_patch(patch)
    return patch,

# animation function. This is called sequentially
def animate(i):
    #line.set_data(time[max(0,i-2):i], position[max(0,i-2):i])
    x_min, x_max = position[max(0,i-2)][0], position[i][0]
    y_min, y_max = position[max(0,i-2)][1], position[i][1]
    line.set_data([x_min, x_max], [y_min, y_max])
    return (line,)

# call the animator. blit=True means only re-draw the parts that have changed.
anim = animation.FuncAnimation(fig, animate, init_func=init,
                               frames=len(time), interval=40, blit=True,
                               repeat_delay=10000, repeat=True);

HTML(anim.to_html5_video())
```

----------------------------

## The Control Problem

- We know the model of the system
- We know how we want the system outputs to behave
- We can determine the appropriate inputs through various control methods.

_Control problem - how do we generate the appropriate system input that will produce the desired output?_

- Control theory gives you the tools needed to answer this question.
- Without control theory, the designer is relegated to choosing a control system through trial and error.

- How can I get my system to meet my performance requirements?
- How can I automate a process that currently involves humans in the
loop?
- How can my system operate in a dynamic and noisy environment?

---------------------------

# Why do we need feedback control?

### Open-loop control

- Now the box isn’t just any system, it’s specifically a system that we want to control.
- We call the system that is being controlled as the **process** (or controlled system).
- The **inputs** into the process are variables that we have access to and can change based on whichever control scheme we choose (manipulated variables).
- Actuators manipulate these variables. An actuator is a generic term that refers to a device or motor that is responsible for controlling a system.
- The actuators are driven by an actuating signal that is generated by the controller.

This type of control system is referred to as open-loop since the inputs into the controller are not fed back from the output of the process.

- Open loop control is reserved for simple processes that have well-defined input to output behaviors

Some examples:
- Dishwasher
- Lawn sprinklers

### Feedback-control

For any arbitrary process, an open-loop control system is typically not sufficient.

This is because:
- there are disturbances that affect your system that are random by nature and beyond your control;
- the process itself might have variations (e.g., variation of resistence due to temperature)

Since an open-loop control has no knowledge of the process output has no way of responding to these variations.

So what can we do about this?
- We add feedback to our system!
- We accept the fact that disturbances and process variations are going to influence the controlled variable.
- Instead of living with the resulting error, we add a sensor that will measure the controlled variable and pass it along to our controller.

A feedback control system is able to react to changes to the controlled variable automatically by constantly driving the error term to zero.

- The feedback structure is very powerful and robust
- With the addition of the feedback structure we also have new problems
- We need to think about:
   - the accuracy of the controlled variable at steady state,
   - the speed with which the system can respond to changes and reject disturbances,
   - the stability of the system as a whole.

- We have also added sensors, and they have noise and other inaccuracies that get injected into our loop and affect the performance.
   - We can add redundant sensors (so that we can measure different state variables)
   - We filter the output of the sensors to reduce the noise
   - We fuse them together to create a more accurate estimate of the true state.

### What is a control system?

- A control system is a mechanism that alters the behavior (or the future state) of a system;
- The future behavior of the system must tend towards a state that is desired.
- This means that you have to know what you want your system to do and then design your control system to generate that desired outcome.

--------------------------------

## LTI systems

General form of a system:

$$
\begin{cases}
\dot{x}(t) = f(x(t), u(t), t)\\
y(t) = g(x(t), u(t), t)
\end{cases}
$$

When $f$ and $g$ are linear in $x$ and $u$ then the system can be written in the form:

$$
\begin{cases}
\dot{x}(t) = A(t)x(t) + B(t)u(t)\\
y(t) = C(t)x(t) + D(t)u(t)
\end{cases}
$$

- The system is said linear in this case,
- Matrices $A(t), B(t), C(t), D(t)$ can be in general function of time,
- If they are not, we talk about **Linear Time-Invariant Systems**

$$
\begin{cases}
\dot{x}(t) = Ax(t) + Bu(t)\\
y(t) = Cx(t) + Du(t)
\end{cases}
$$

All LTI systems have the following defining properties:
- Homogeneity:
   - If you scale the input $u(t)$ then the output will be scaled by the same factor:
   - $au(t) \Rightarrow ay(t)$ (e.g. if you double the input, the output would also double).

   - ex. step input of amplitude 1 and 2:

- Superposition (Additivity)
  - Suppose that:
    - input $x_1(t)$ produces output $y_1(t)$
    - input $x_2(t)$ produces output $y_2(t)$
  - If you add two inputs together, then the output is the superposition of the two separate outputs, i.e. the sum of the individual outputs:

Sometimes, these two properties are stated together: "if the input is scaled and summed, then the output will also be scaled and summed by the same amount".
- A system that respect these two property is a _linear system_

- Time Invariance
    - The system behaves the same regardless of when the action takes place
    - Formally there is no explicit dependency of time in the equations
    - The same input translated in time produces the same output also translated in time:

    - An input of $x(t-\tau)$ produces an output of $y(t-\tau)$.

- These conditions are very restritive
- However, linear systems can be solved!

- Wide range of systems can be approximated accurately by an LTI model

### Impulse Response

- LTI systems can be characterised by their response to an impulse function (the output of the system when presented with a brief input signal)

Let's see what happens when we apply an impulse to our car.

Before we do that, we need to define an impulse function in Python.

```
#export
def step(t, step_time=0):
    """Heaviside step function"""
    return 1 * (t >= step_time)

def delta(t, delta_t=0, eps=None): # Impulse
    if np.isscalar(t) and eps is None:
        raise Exception('eps must be defined for scalar values.')
    if eps is None and len(t) > 1:
        _eps=t[1]-t[0]
    else:
        _eps = eps
    return 1/_eps*(step(t, delta_t-_eps/2)-step(t, delta_t+_eps/2))
```

And here is what it looks like:

```
fig, ax = plt.subplots(1, 1, figsize=(3, 3))

t = np.linspace(0, 5, 60)
ax.plot(t, delta(t, delta_t=1), linewidth=3, color='blue')
ax.set_xlabel('time (s)')
fig.tight_layout()
```

We are now ready to apply it to our car:

```
# We assume we have identified the model parameters
m = 10
alpha = 1 # it is not used in the linear system
beta = 10 # friction
gamma = 1 # coeff. applied to the input.
params = (m, alpha, beta, gamma)

# We select the car initial conditions (position and velocity)
x_0 = (0,0)

# We create our car
car = LinearCar(x_0, params)

theta = np.radians(0) # disturbance (deg) - we can also try np.radians(20)

# And finally we define the simulation parameters
t0, tf, dt = 0, 10, 0.1 # time

position = []
velocity = []
input_values = []
time = []
for t in np.arange(t0, tf, dt):
    # HERE, we apply the impulse
    u = delta(t, delta_t=0, eps=0.01)

    car.step(dt, u, theta)
    x, y, v = car.sensor_i()
    position.append((x,y)), velocity.append(v)
    time.append(t)
    input_values.append(u)

print('simulation complete')
```

    simulation complete

```
plt.plot(np.arange(t0, tf, dt), velocity, linewidth=4, color='blue', label='t_0')
plt.xlabel('time (s)'), plt.ylabel('velocity (m/s)');
plt.ylim(0,)
plt.grid();
```

Because of time invariance, if we have another impulse we can expect the same response at this second time, and if we hit twice as hard, then the response is twice as large

```
def multiple_impulses(t):
    u1 = 1*delta(t, delta_t=0, eps=0.01)
    u2 = 2*delta(t, delta_t=1, eps=0.01)
    u3 = 2*delta(t, delta_t=4, eps=0.01)

    return u1 + u2 + u3
```

```
car = LinearCar(x0=(0,0), params=params)

position = []
velocity = []
input_values = []
time = []
for t in np.arange(t0, tf, dt):
    # HERE, we apply the impulse
    u = multiple_impulses(t)

    car.step(dt, u, theta)
    x, y, v = car.sensor_i()
    position.append((x,y)), velocity.append(v)
    time.append(t)
    input_values.append(u)

print('simulation complete')
```

    simulation complete

```
plt.plot(np.arange(t0, tf, dt), velocity, linewidth=4, color='blue', label='t_0')
plt.xlabel('time (s)'), plt.ylabel('velocity (m/s)');
plt.ylim(0,)
plt.grid();
```

- Because of the superposition principle, the full response is the summation of the signals.

- If we have a more complex signal (e.g. ramp) we can break it down to a sequence of impulses (the output is the response to each of the impulses)

```
#export
def ramp_as_impulses(t, time_vector):
    u = t*delta(time_vector, delta_t=t, eps=.01)
    return u
```

And we can verify that this is a ramp:

```
input_u = []

for t in np.arange(t0, tf, .4):
    input_u.append(ramp_as_impulses(t, np.arange(t0, tf, .4)))
```

```
len(input_u)
```

    25

```
fig, ax = plt.subplots(1, 1, figsize=(3, 3))

ax.plot(np.arange(t0, tf, .4), input_u, linewidth=3, color='blue')
ax.set_xlabel('time (s)')
fig.tight_layout()
```

```
car = LinearCar(x0=(0,0), params=params)

position = []
velocity = []
input_values = []
time = []
for index, t in enumerate(np.arange(t0, tf, dt)):
    # HERE, we apply the ramp as a sequence of impulses
    u = ramp_as_impulses(t, np.arange(t0, tf, dt))
    #print('time:', t, '-> index:', index, '->u[i]:', u[index])
    car.step(dt, u[index], theta)
    x, y, v = car.sensor_i()
    position.append((x,y)), velocity.append(v)
    time.append(t)
    input_values.append(u)

print('simulation complete')
```

    simulation complete

```
plt.plot(np.arange(t0, tf, dt), velocity, linewidth=4, color='blue', label='t_0')
plt.xlabel('time (s)'), plt.ylabel('velocity (m/s)');
plt.ylim(0,)
plt.grid();
```

- In reality, the impulses and responses will be infinitesimal
- In this case, the summation in the time domain is a convolution $u(t) \circledast H(t)$, where $H(t)$ is the impulse response of the system.
- This can be a difficult integration..

- If we use the Laplace transform and move to the s-domain however, convolutions become multiplications, and things are simpler.

- As a control engineers we would like to design our systems to be as close as possible to be LTI, or so that the non LTI parts can be ignored.
- So that the standard LTI tools can be used.

------------------------------