```python
# Erasmus+ ICCT project (2018-1-SI01-KA203-047081)

# Toggle cell visibility

from IPython.display import HTML
tag = HTML('''
Toggle cell visibility <a href="javascript:code_toggle()">here</a>.''')
display(tag)

# Hide the code completely

# from IPython.display import HTML
# tag = HTML('''<style>
# div.input {
#     display:none;
# }
# </style>''')
# display(tag)

```

Toggle cell visibility here.

## State feedback control

This example shows the effect of full state feedback.

Given the linear time invariant system:

\begin{cases}
\dot{\textbf{x}}=A\textbf{x}+B\textbf{u} \\
\textbf{y}=C\textbf{x},
\end{cases}

and the control law:

$\textbf{u}=-K\textbf{x}+\textbf{v},$

this example shows the free and forced responses of the close loop system:

$$
\dot{\textbf{x}}=A\textbf{x}-BK\textbf{x}+B\textbf{v} = (A-BK)\textbf{x}+B\textbf{v}.
$$

### How to use this notebook?
Try to change the values of $K$ or directly set the eigenvalues of $(A-BK)$ and get the corresponding controller gains:
- Create an unstable system and make it stable with full state feedback.
- Create a system with a slow response and make it faster with full state feedback.
- Create a system that is not fully controllable try to change all its eigenvalues in closed loop. Is it possible to achieve that?
- Create a system that is not fully controllable and unstable and try to make it stable with full state feedback. Can you tell in which cases it is possible to stabilize it with closed-loop control?

```python
%matplotlib inline
import control as control
import numpy
import sympy as sym
from IPython.display import display, Markdown
import ipywidgets as widgets
import matplotlib.pyplot as plt

#print a matrix latex-like
def bmatrix(a):
     """Returns a LaTeX bmatrix - by Damir Arbula (ICCT project)

     :a: numpy array
     :returns: LaTeX bmatrix as a string
     """
     if len(a.shape) > 2:
         raise ValueError('bmatrix can at most display two dimensions')
     lines = str(a).replace('[', '').replace(']', '').splitlines()
     rv = [r'\begin{bmatrix}']
     rv += ['  ' + ' & '.join(l.split()) + r'\\' for l in lines]
     rv +=  [r'\end{bmatrix}']
     return '\n'.join(rv)

# Display formatted matrix:
def vmatrix(a):
    if len(a.shape) > 2:
         raise ValueError('bmatrix can at most display two dimensions')
    lines = str(a).replace('[', '').replace(']', '').splitlines()
    rv = [r'\begin{vmatrix}']
    rv += ['  ' + ' & '.join(l.split()) + r'\\' for l in lines]
    rv +=  [r'\end{vmatrix}']
    return '\n'.join(rv)

#matrixWidget is a matrix looking widget built with a VBox of HBox(es) that returns a numPy array as value !
class matrixWidget(widgets.VBox):
    def updateM(self,change):
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.M_[irow,icol] = self.children[irow].children[icol].value
                #print(self.M_[irow,icol])
        self.value = self.M_

    def dummychangecallback(self,change):
        pass

    def __init__(self,n,m):
        self.n = n
        self.m = m
        self.M_ = numpy.matrix(numpy.zeros((self.n,self.m)))
        self.value = self.M_
        widgets.VBox.__init__(self,
                             children = [
                                 widgets.HBox(children =
                                              [widgets.FloatText(value=0.0, layout=widgets.Layout(width='90px')) for i in range(m)]
                                             )
                                 for j in range(n)
                             ])

        #fill in widgets and tell interact to call updateM each time a children changes value
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].value = self.M_[irow,icol]
                self.children[irow].children[icol].observe(self.updateM, names='value')
        #value = Unicode('example@example.com', help="The email value.").tag(sync=True)
        self.observe(self.updateM, names='value', type= 'All')

    def setM(self, newM):
        #disable callbacks, change values, and reenable
        self.unobserve(self.updateM, names='value', type= 'All')
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].unobserve(self.updateM, names='value')
        self.M_ = newM
        self.value = self.M_
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].value = self.M_[irow,icol]
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].observe(self.updateM, names='value')
        self.observe(self.updateM, names='value', type= 'All')

                #self.children[irow].children[icol].observe(self.updateM, names='value')

#overlaod class for state space systems that DO NOT remove "useless" states (what "professor" of automatic control would do this?)
class sss(control.StateSpace):
    def __init__(self,*args):
        #call base class init constructor
        control.StateSpace.__init__(self,*args)
    #disable function below in base class
    def _remove_useless_states(self):
        pass
```

```python
# Preparatory cell

A = numpy.matrix('0 1 0; 0 0 1; 0 2 -3')
B = numpy.matrix('0; 0; 1')
C = numpy.matrix('1 0 0; 0 1 0; 0 0 1')
X0 = numpy.matrix('2; 2; 2')
K = numpy.matrix([8,14,3])
sol1 = numpy.linalg.eig(A)

Aw = matrixWidget(3,3)
Aw.setM(A)
Bw = matrixWidget(3,1)
Bw.setM(B)
Cw = matrixWidget(3,3)
Cw.setM(C)
X0w = matrixWidget(3,1)
X0w.setM(X0)
Kw = matrixWidget(1,3)
Kw.setM(K)

eig1c = matrixWidget(1,1)
eig2c = matrixWidget(2,1)
eig3c = matrixWidget(1,1)
eig1c.setM(numpy.matrix([-2]))
eig2c.setM(numpy.matrix([[-2],[0]]))
eig3c.setM(numpy.matrix([-2]))
```

```python
# Misc

#create dummy widget
DW = widgets.FloatText(layout=widgets.Layout(width='0px', height='0px'))

#create button widget
START = widgets.Button(
    description='Test',
    disabled=False,
    button_style='', # 'success', 'info', 'warning', 'danger' or ''
    tooltip='Test',
    icon='check'
)

def on_start_button_clicked(b):
    #This is a workaround to have intreactive_output call the callback:
    #   force the value of the dummy widget to change
    if DW.value> 0 :
        DW.value = -1
    else:
        DW.value = 1
    pass
START.on_click(on_start_button_clicked)

# Define type of method
selm = widgets.Dropdown(
    options= ['Set K', 'Set the eigenvalues'],
    value= 'Set K',
    description='',
    disabled=False
)

# Define the number of complex eigenvalues for the observer
selc = widgets.Dropdown(
    options= ['0 complex eigenvalues', '2 complex eigenvalues'],
    value= '0 complex eigenvalues',
    description='Eigenvalues:',
    disabled=False
)

#define type of ipout
selu = widgets.Dropdown(
    options=['impulse', 'step', 'sinusoid', 'square wave'],
    value='impulse',
    description='Type of input:',
    disabled=False
)
# Define the values of the input
u = widgets.FloatSlider(
    value=1,
    min=0,
    max=20.0,
    step=0.1,
    description='input u:',
    disabled=False,
    continuous_update=False,
    orientation='horizontal',
    readout=True,
    readout_format='.1f',
)
period = widgets.FloatSlider(
    value=0.5,
    min=0.05,
    max=1,
    step=0.05,
    description='Period: ',
    disabled=False,
    continuous_update=False,
    orientation='horizontal',
    readout=True,
    readout_format='.2f',
)
```

```python
# Support functions

def eigen_choice(selc):
    if selc == '0 complex eigenvalues':
        eig1c.children[0].children[0].disabled = False
        eig2c.children[1].children[0].disabled = True
        eigc = 0
    if selc == '2 complex eigenvalues':
        eig1c.children[0].children[0].disabled = True
        eig2c.children[1].children[0].disabled = False
        eigc = 2
    return eigc

def method_choice(selm):
    if selm == 'Set K':
        method = 1
        selc.disabled = True
    if selm == 'Set the eigenvalues':
        method = 2
        selc.disabled = False
    return method
```

```python
def main_callback(Aw, Bw, X0w, K, eig1c, eig2c, eig3c, u, period, selm, selc, selu, DW):
    A, B = Aw, Bw
    sols = numpy.linalg.eig(A)
    eigc = eigen_choice(selc)
    method = method_choice(selm)

    if method == 1:
        sol = numpy.linalg.eig(A-B*K)
    if method == 2:
        if eigc == 0:
            K = control.acker(A, B, [eig1c[0,0], eig2c[0,0], eig3c[0,0]])
            Kw.setM(K)
        if eigc == 2:
            K = control.acker(A, B, [eig1c[0,0],
                                      numpy.complex(eig2c[0,0],eig2c[1,0]),
                                      numpy.complex(eig2c[0,0],-eig2c[1,0])])
            Kw.setM(K)
        sol = numpy.linalg.eig(A-B*K)
    print('The system\'s eigenvalues are:',round(sols[0][0],4),',',round(sols[0][1],4),'and',round(sols[0][2],4))
    print('The controlled system\'s eigenvalues are:',round(sol[0][0],4),',',round(sol[0][1],4),'and',round(sol[0][2],4))

    sys = sss(A,B,C,sym.zeros(3,1))
    sysc = sss(A-B*K, B, numpy.eye(3), numpy.zeros(3).reshape((3,1)))
    T = numpy.linspace(0, 6, 1000)

    if selu == 'impulse': #selu
        U = [0 for t in range(0,len(T))]
        U[0] = u
        T, yout, xout = control.forced_response(sys,T,U,X0w)
        T, youtc, xoutc = control.forced_response(sysc,T,U,X0w)
    if selu == 'step':
        U = [u for t in range(0,len(T))]
        T, yout, xout = control.forced_response(sys,T,U,X0w)
        T, youtc, xoutc = control.forced_response(sysc,T,U,X0w)
    if selu == 'sinusoid':
        U = u*numpy.sin(2*numpy.pi/period*T)
        T, yout, xout = control.forced_response(sys,T,U,X0w)
        T, youtc, xoutc = control.forced_response(sysc,T,U,X0w)
    if selu == 'square wave':
        U = u*numpy.sign(numpy.sin(2*numpy.pi/period*T))
        T, yout, xout = control.forced_response(sys,T,U,X0w)
        T, youtc, xoutc = control.forced_response(sysc,T,U,X0w)

    fig = plt.figure(num='Simulation', figsize=(16,10))

    fig.add_subplot(311)
    plt.ylabel('$X_1$ vs $X_{1f}$')
    plt.plot(T,xout[0])
    plt.plot(T,xoutc[0])
    plt.xlabel('time [s]')
    plt.legend(['Open Loop','State Feedback'])
    plt.axvline(x=0,color='black',linewidth=0.8)
    plt.axhline(y=0,color='black',linewidth=0.8)
    plt.grid()

    fig.add_subplot(312)
    plt.ylabel('$X_2$ vs $X_{2f}$')
    plt.plot(T,xout[1])
    plt.plot(T,xoutc[1])
    plt.xlabel('time [s]')
    plt.legend(['Open Loop','State Feedback'])
    plt.axvline(x=0,color='black',linewidth=0.8)
    plt.axhline(y=0,color='black',linewidth=0.8)
    plt.grid()

    fig.add_subplot(313)
    plt.ylabel('$X_3$ vs $X_{3f}$')
    plt.plot(T,xout[2])
    plt.plot(T,xoutc[2])
    plt.xlabel('time [s]')
    plt.legend(['Open Loop','State Feedback'])
    plt.axvline(x=0,color='black',linewidth=0.8)
    plt.axhline(y=0,color='black',linewidth=0.8)
    plt.grid()

alltogether = widgets.VBox([widgets.HBox([selm,
                                          selc,
                                          selu]),
                            widgets.Label(' ',border=3),
                            widgets.HBox([widgets.Label('K:',border=3), Kw,
                                          widgets.Label(' ',border=3),
                                          widgets.Label(' ',border=3),
                                          widgets.Label('Eigenvalues:',border=3),
                                          eig1c,
                                          eig2c,
                                          eig3c,
                                          widgets.Label(' ',border=3),
                                          widgets.Label(' ',border=3),
                                          widgets.Label('X0:',border=3), X0w]),
                            widgets.Label(' ',border=3),
                            widgets.HBox([u,
                                          period,
                                          START]),
                            widgets.Label(' ',border=3),
                            widgets.HBox([widgets.Label('Dynamics matrix A:',border=3),
                                          Aw,
                                          widgets.Label('Input matrix B:',border=3),
                                          Bw])])
out = widgets.interactive_output(main_callback, {'Aw':Aw, 'Bw':Bw, 'X0w':X0w, 'K':Kw, 'eig1c':eig1c, 'eig2c':eig2c, 'eig3c':eig3c,
                                                 'u':u, 'period':period, 'selm':selm, 'selc':selc, 'selu':selu, 'DW':DW})
out.layout.height = '680px'
display(out, alltogether)
```

    Output(layout=Layout(height='680px'))

    VBox(children=(HBox(children=(Dropdown(options=('Set K', 'Set the eigenvalues'), value='Set K'), Dropdown(desc…

```python

```


# Let's start!
OPEN the jupyter notebook **Tutorial1-Part1** downloaded from the **indico timetable: https://indico.cern.ch/event/1088622/timetable/#20220111** to work locally or from the following link: **https://github.com/fusterma/JUAS2022** to work online.

# Tutorials summary

The goal of these workshops is to do numerical exercices using MAD-X to visualize transverse dynamics concepts from a different point of view.

**Friday 14th of January**
- **Tutorial 1 - Part 1**: Introduction to the tools, small numerical exercises (all together).
- **Tutorial 1 - Part 2**: My first circular accelerator: FODO cell – optics and first matching (groups of 3/4 students).
- **Tutorial 1 - Part 3**: Adding dipoles to the FODO cell – MAD-X matching block (groups of 3/4 students).

$\color{red}{\text{WEEKEND: homework exercice}}$

**Monday 17th of January**
- **Tutorial 2 - Part 1**: Natural chromaticity – MAD-X tracking module (groups of 3/4 students).
- **Tutorial 2 - Part 2**: Chromaticity correction – impact of non-linearities (groups of 3/4 students).
- **Tutorial 2 - Part 3**: Design of a transfer line  - optics and matching (groups of 3/4 students).

$\color{red}{\text{Homework + Tutorial 1 and Tutorial 2 jupyter-notebooks}}$ (to be delivered as late on Wednesday 18th to nuria.fuster@ific.uv.es). This will be considered as a BONUS to pass the accelerator design workshop oral exam.

$\color{red}{\text{VERY IMPORTANT}}$: Save your jupyter-notebooks and download them to your computer after finishing the tutorials!! Otherwise your work will be lost!

- The tutorials solutions will be uploaded on the indico timetable on Wednesday 19th.

$\color{blue}{\text{Notes}}$:

- For most of the tutorials we will split in groups of 3/4 students. These groups will be kept also for the accelerator design workshops on the third and fourth weeks of the JUAS course.

- The timing of the tutorials (except Tutorial 1 - Part 1) will be:
    - 5 minutes for the introduction to the problem.
    - 25 minutes to work within your team and with your tutor on the problem.
    - 15 minutes for going through the solutions and discussion.

- Tutors (Axel, Guido, Tessa, Davide and Nuria) will go around to help you and answer questions!

- You can work as a team in the groups or by yourself but when you need help please ask your team mates or the tutor and use the screen share option.

- The problems are long with many questions, we don't expect you to solve all of them. Some BONUS questions are there for discussion or homework.

- We encourage you to use the Slack MAD-X channel during the workshops for the discussion part, during the weekend and all JUAS to post questions.

# Tutorial 1: Part 1

Objectives:

- [Get familiar with the jupyter-notebooks.](#introjupyter)

- [Get familiar with the basic python commands that we will use during the tutorials.](#intropython)

- [How do we compute the optics of a lattice?](#firstexercice)

- [Get familiar with python commands to send the information to the MAD-X code and review the main MADX blocks for optics calculations.](#intromadx)

# Jupyter notebook

- **OPEN** a jupyter notebook go to **FILE -> OPEN**.

- **EDIT/INSERT/DELATE** a cell.

- **RUN** (press bottom on the top command line or press CAPS+ENTER).

- If working online **SAVE and DOWNLOAD**: after we finish one tutorial you need to SAVE and DOWNLOAD the jupyter-notebook into your PC. Otherwise your progres will be lost!

- **SAVE TO BROWSER STORAGE** using the "cloud" icon (for those working on BINDER).

# Basic python commands

The python universe has a huge number of libraries that extend the capabilities of python.
Nearly all of these are open source. For this workshop we will use the following:

```python
############################
# Import special libraries #
############################
#For plotting
from matplotlib import pyplot as plt
# For numerical calulations (np.max(), np.min(), np.mean()...)
import numpy as np
# For symbolic computation (solving algebra problems)
import sympy as sp
# For structuring the data, visualization of tables and data manipultion
import pandas as pd
# Library that allows us to use the MAD-X models
from cpymad.madx import Madx
# Plot display
%matplotlib notebook
```

If you want to learn more about **python**:

https://www.youtube.com/watch?v=kqtD5dpn9C8

https://www.kaggle.com/learn/python

More about the **cpymad** library: http://hibtc.github.io/cpymad/getting-started

# Scalars, arrays and matrices in Python

```python
# Scalar
a=20
b=30
print(a*b)
c=a+b
print(c)
```

    600
    50

```python
# Arrays and matrices
sp.Matrix([1,2,3,4]) # 1D array
```

$\displaystyle \left[\begin{matrix}1\\2\\3\\4\end{matrix}\right]$

```python
sp.Matrix([[1,2],[3,4]]) # 2x2 matrix
```

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

```python
A=sp.Matrix([[1,2],[3,4]])
B=sp.Matrix([[1,2],[3,4]])
A+B
```

$\displaystyle \left[\begin{matrix}2 & 4\\6 & 8\end{matrix}\right]$

```python
A*B
```

$\displaystyle \left[\begin{matrix}7 & 10\\15 & 22\end{matrix}\right]$

# Plots in Python

```python
# Plot
%matplotlib notebook
plt.rcParams['savefig.dpi'] = 90
plt.rcParams['figure.dpi'] = 90

x=[0,1,2,3,4,5,6,7,8,9,10]
y=[0,1,2,3,4,5,6,7,8,9,10]
plt.plot(x,y,'.-b',label='test')
plt.legend()
plt.grid()
plt.xlabel('s [m]')
plt.ylabel('x[m]')
```

    <IPython.core.display.Javascript object>

    Text(0, 0.5, 'x[m]')

# How do we compute the optics of a lattice?

- I want to motivate the use of optics codes such as MAD-X and at the same time illustrate the basic numerical approach behind some of the methods.

- The TWISS method is based on matrix multiplications where first and second order transport matrices are used to get the optics of the machine.

# FODO cell
- Compute the linear optics functions of a FODO cell, which is the simplier combination of quadrupoles required to focuse the beam in both, vertical and horizontal planes.

# Thin lens approximation (f >> $l_q$)

- To do some first estimations analytically one uses the thin lens approximation.

```python
# Symbolic computation

# Symbols defintion
K = sp.Symbol("K", positive = True)
Lq = sp.Symbol("Lq", positive = True)
Ld = sp.Symbol("Ld", positive = True)
```

```python
# Could you try to program the matrices and compute the FODO transfer matrix?
# HINT: A=sp.Matrix[[1,0],[1,0]]
```

```python
#Matrices definition

Mfoc=sp.Matrix([[1,0],[-K*Lq,1]])

Mdefoc=sp.Matrix([[1,0],[K*Lq,1]])

Mdrift=sp.Matrix([[1,Ld],[0,1]])
```

```python
#################################################
# Transport matrix of a focusing quadrupople    #
#################################################
Mfoc
```

$\displaystyle \left[\begin{matrix}1 & 0\\- K Lq & 1\end{matrix}\right]$

```python
#################################################
# Transport matrix of  a defocusing quadrupople #
#################################################
Mdefoc
```

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

```python
##############################################
# Transport matrix of a drift                #
##############################################
Mdrift
```

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

```python
#Matrix multiplication
M=Mdrift*Mdefoc*Mdrift*Mfoc
#Matrix simplification
M=sp.simplify(M)
#Print of the matrix
M
```

$\displaystyle \left[\begin{matrix}- K^{2} Ld^{2} Lq^{2} - K Ld Lq + 1 & Ld \left(K Ld Lq + 2\right)\\- K^{2} Ld Lq^{2} & K Ld Lq + 1\end{matrix}\right]$

```python
# Matrix elements computation
# f=200 m, lq=1 m, ld= 30 m
M_thin = M.subs(K, 1/(200*1)).subs(Lq, 1).subs(Ld,30) # units K in m-2, Lq and Ld in m
M_thin
```

$\displaystyle \left[\begin{matrix}0.8275 & 64.5\\-0.00075 & 1.15\end{matrix}\right]$

```python
#And for 3 FODO cells?
M3=M*M*M
M3=sp.simplify(M3)
M3
```

$\displaystyle \left[\begin{matrix}- K^{6} Ld^{6} Lq^{6} - K^{5} Ld^{5} Lq^{5} + 5 K^{4} Ld^{4} Lq^{4} + 4 K^{3} Ld^{3} Lq^{3} - 6 K^{2} Ld^{2} Lq^{2} - 3 K Ld Lq + 1 & Ld \left(K^{5} Ld^{5} Lq^{5} + 2 K^{4} Ld^{4} Lq^{4} - 4 K^{3} Ld^{3} Lq^{3} - 8 K^{2} Ld^{2} Lq^{2} + 3 K Ld Lq + 6\right)\\K^{2} Ld Lq^{2} \left(- K^{4} Ld^{4} Lq^{4} + 4 K^{2} Ld^{2} Lq^{2} - 3\right) & K^{5} Ld^{5} Lq^{5} + K^{4} Ld^{4} Lq^{4} - 4 K^{3} Ld^{3} Lq^{3} - 3 K^{2} Ld^{2} Lq^{2} + 3 K Ld Lq + 1\end{matrix}\right]$

```python
M_test = M3.subs(K, 1/(200*1)).subs(Lq, 1).subs(Ld,30) # units K in m-2, Lq and Ld in m
M_test
```

$\displaystyle \left[\begin{matrix}0.430943921875 & 187.727653125\\-0.0021828796875 & 1.3695821875\end{matrix}\right]$

# What can we do with the  transfer matrix?
This matrix describes the optical properties of the lattice and defines the beam parameters.

   - We can propagate the phase space coordinates of a particle with a given set of initial coordinates.

```python
# Try with paralel particle going through the center of the first quadrupole or with a certain amplitude
x=sp.Matrix([[1],[0]])
x
```

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

```python
x2=M_thin*x
x2
```

$\displaystyle \left[\begin{matrix}0.8275\\-0.00075\end{matrix}\right]$

- We can compute the periodic solution TWISS functions.

```python
# Transfer matrix
R11, R12, R21, R22 = sp.symbols('R11,R12,R21,R22')

Mt=sp.Matrix([[R11,R12],[R21,R22]])

Mt
```

$\displaystyle \left[\begin{matrix}R_{11} & R_{12}\\R_{21} & R_{22}\end{matrix}\right]$

```python
# In case of periodic conditions in the accelerator there is another way to describe the particles trajectories.
# Periodic solution one-turn-transfer matrix in terms of twiss functions:

a, b, g, m = sp.symbols(r'\alpha,\beta, \gamma,\mu')

M=sp.Matrix([[sp.cos(m)+a*sp.sin(m),b*sp.sin(m)],[-g*sp.sin(m),sp.cos(m)-a*sp.sin(m)]])

M
```

$\displaystyle \left[\begin{matrix}\alpha \sin{\left(\mu \right)} + \cos{\left(\mu \right)} & \beta \sin{\left(\mu \right)}\\- \gamma \sin{\left(\mu \right)} & - \alpha \sin{\left(\mu \right)} + \cos{\left(\mu \right)}\end{matrix}\right]$

```python
#For the phase advance we use the trace of the matrix
sp.Eq(sp.cos(m),(R11+R22)/2)
```

$\displaystyle \cos{\left(\mu \right)} = \frac{R_{11}}{2} + \frac{R_{22}}{2}$

```python
#For the beta function we use the R12 matrix element
sp.Eq(b,R12/sp.sin(m))
```

$\displaystyle \beta = \frac{R_{12}}{\sin{\left(\mu \right)}}$

```python
#For the alfa function we use the trace of the matrix also
sp.Eq(a,(R11-R22)/(2*sp.sin(m)))
```

$\displaystyle \alpha = \frac{R_{11} - R_{22}}{2 \sin{\left(\mu \right)}}$

```python
#For the gamma we use the R21 element
sp.Eq(g,-(R21/sp.sin(m)))
```

$\displaystyle \gamma = - \frac{R_{21}}{\sin{\left(\mu \right)}}$

Once you have computed the periodic TWISS functions you can propagate them to any point in the machine using the transfer matrix of the TWISS functions from Transverse dynamics course.

```python
sp.Matrix([[R11**2, -2*R12*R11, R12**2],[-R11*R21,R12*R21+R22*R11, -R12*R22],[R21**2,-2*R22*R21, R22**2]])
```

$\displaystyle \left[\begin{matrix}R_{11}^{2} & - 2 R_{11} R_{12} & R_{12}^{2}\\- R_{11} R_{21} & R_{11} R_{22} + R_{12} R_{21} & - R_{12} R_{22}\\R_{21}^{2} & - 2 R_{21} R_{22} & R_{22}^{2}\end{matrix}\right]$

**Using the periodic one-turn-matrix and the stability condition of a FODO cell, one can define some interesting relations between the TWISS parameters and the magnetic properties of the lattice.**

# Figure 1: Relation between $\Delta \mu$, K, $L_{cell}$, $L_q$

```python
# Relation between the phase advance of the cell and K, Lcell, Lq (from periodic solution an stbility condition)
a, b, g, m, d, Lq, Lc, K, pi = sp.symbols(r'\alpha,\beta, \gamma,\mu, \Delta, L_{q}, L_{cell} K \pi')
sp.Eq(d*m/pi,2/pi*sp.asin(K*Lq*Lc/4))
```

$\displaystyle \frac{\Delta \mu}{\pi} = \frac{2 \operatorname{asin}{\left(\frac{K L_{cell} L_{q}}{4} \right)}}{\pi}$

```python
# Parametric plots
%matplotlib notebook
plt.rcParams['savefig.dpi'] = 90
plt.rcParams['figure.dpi'] = 90

x=np.arange(0,4.01,0.01)
y=2*np.arcsin(x/4)/np.pi
fig, ax1 = plt.subplots()
ax1.plot(x,y,'-')
ax1.set_ylabel("$\Delta \mu / \pi [rad]$", fontsize=16)
ax1.set_xlabel("$K*L_{quad}*L_{cell}$ [-]", fontsize=16)
ax1.grid()
ax1.tick_params(axis='both', labelsize=16)
plt.tight_layout()
```

    <IPython.core.display.Javascript object>

# Exercice:
- What is the quadrupole strenght to match a FODO cell phase advance of 45$^\circ$ if the $L_{quad}$=5 m and $L_{cell}$=100 m?

- And for a FODO cell phase advance of 90$^\circ$?

- What is the maximum phase advance in a FODO cell?

```python
#for 45 degrees K*Lcell*lq=1.528
#K[m^(-2)]=
1.528/100/5
```

    0.003056

```python
#for 90 degrees K*Lcell*lq=2.842
#K[m^(-2)]=
2.842/100/5

#For a larger phase advance one needs to increase the strength of the quads
```

    0.005684

```python
#The function goes assimptotically to 1 corresponding to a phase advance of 180 degrees.
```

# Figure 2: Relation between $\beta_{max}$ and $\beta_{min}$ with K, $L_{cell}$, $L_q$

```python
# Relation between the beta of the cell and K, Lcell, Lq
a, bmin, bmax, g, m, d, Lq, Lc, K, pi = sp.symbols(r'\alpha,\beta_{min}, \beta_{max}, \gamma,\mu, \Delta, L_{q}, L_{cell} K \pi')
sp.Eq(bmin/Lc,(1-(K*Lq*Lc/4))/(sp.sin(2*sp.asin(K*Lq*Lc/4))))
```

$\displaystyle \frac{\beta_{min}}{L_{cell}} = \frac{- \frac{K L_{cell} L_{q}}{4} + 1}{\sin{\left(2 \operatorname{asin}{\left(\frac{K L_{cell} L_{q}}{4} \right)} \right)}}$

```python
sp.Eq(bmax/Lc,(1+(K*Lq*Lc/4))/(sp.sin(2*sp.asin(K*Lq*Lc/4))))
```

$\displaystyle \frac{\beta_{max}}{L_{cell}} = \frac{\frac{K L_{cell} L_{q}}{4} + 1}{\sin{\left(2 \operatorname{asin}{\left(\frac{K L_{cell} L_{q}}{4} \right)} \right)}}$

```python
%matplotlib notebook
plt.rcParams['savefig.dpi'] = 90
plt.rcParams['figure.dpi'] = 90

x=np.arange(0.5,3.90,0.01)
betamax=(1+(x/4))/(np.sin(2*np.arcsin(x/4)))
betamin=(1-(x/4))/(np.sin(2*np.arcsin(x/4)))
fig, ax1 = plt.subplots()
ax1.plot(x,betamax,'-',label=r"$\beta_{max}/L_{cell}$")
ax1.plot(x,betamin,'-',label=r"$\beta_{min}/L_{cell}$")
ax1.set_ylabel("[-]", fontsize=16)
ax1.set_xlabel("$K*L_{quad}*L_{cell}$ [-]", fontsize=16)
plt.grid()
plt.legend()
plt.tick_params(axis='both', labelsize=16)
plt.tight_layout()
```

    <IPython.core.display.Javascript object>

# Exercice:
- What is K and $L_{cell}$ to match a FODO cell with a phase advance of 90$^\circ$ and a $\beta_{max}$ of 200 m? The  $L_{quad}$=2 m.

HINT: You may need to combine the data from both plots.

```python
# From Figure 1 we get that for 90 degrees K*Lcell*Lq=2.842, for this value in Figure 2 we get that bmax/Lcell=1.960
# Lcell=
200/1.69
```

    118.34319526627219

```python
#And replacing in K*Lcell*Lq=1.528 from Figure 1.
# K=
2.842/118/2
```

    0.012042372881355932

The exact solution of the particle motion has to be calculted in full detail but using some approximations we can make the first steps easier and estimate the order of magnitud of some magnetic properties of our lattice.

# Thick lens computation

```python
K = sp.Symbol("K")
Lq = sp.Symbol("Lq")
Ld= sp.Symbol("Ld")

Mfoc=sp.Matrix([[sp.cos(sp.sqrt(K)*Lq),1/(sp.sqrt(K))*sp.sin(sp.sqrt(K)*Lq)],[-(sp.sqrt(K))*sp.sin(sp.sqrt(K)*Lq),sp.cos(sp.sqrt(K)*Lq)]])

Mdefoc=sp.Matrix([[sp.cosh(sp.sqrt(K)*Lq),1/(sp.sqrt(K))*sp.sinh(sp.sqrt(K)*Lq)],[(sp.sqrt(K))*sp.sinh(sp.sqrt(K)*Lq),sp.cosh(sp.sqrt(K)*Lq)]])

Mdrift=sp.Matrix([[1,Ld],[0,1]])
```

```python
#################################################
# Transport matrix of a focusing quadrupople    #
#################################################
Mfoc
```

$\displaystyle \left[\begin{matrix}\cos{\left(\sqrt{K} Lq \right)} & \frac{\sin{\left(\sqrt{K} Lq \right)}}{\sqrt{K}}\\- \sqrt{K} \sin{\left(\sqrt{K} Lq \right)} & \cos{\left(\sqrt{K} Lq \right)}\end{matrix}\right]$

```python
################################################
# Transport matrix of a defocusing quadrupople #
################################################
Mdefoc
```

$\displaystyle \left[\begin{matrix}\cosh{\left(\sqrt{K} Lq \right)} & \frac{\sinh{\left(\sqrt{K} Lq \right)}}{\sqrt{K}}\\\sqrt{K} \sinh{\left(\sqrt{K} Lq \right)} & \cosh{\left(\sqrt{K} Lq \right)}\end{matrix}\right]$

```python
##############################################
# Transport matrix of a dipole               #
##############################################
Mdrift
```

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

```python
M=Mdrift*Mdefoc*Mdrift*Mfoc
M=sp.simplify(M)
M
```

$\displaystyle \left[\begin{matrix}\left(\sqrt{K} Ld \sinh{\left(\sqrt{K} Lq \right)} + \cosh{\left(\sqrt{K} Lq \right)}\right) \cos{\left(\sqrt{K} Lq \right)} - \left(2 \sqrt{K} Ld \cosh{\left(\sqrt{K} Lq \right)} + K Ld^{2} \sinh{\left(\sqrt{K} Lq \right)} + \sinh{\left(\sqrt{K} Lq \right)}\right) \sin{\left(\sqrt{K} Lq \right)} & \frac{\left(\sqrt{K} Ld \sinh{\left(\sqrt{K} Lq \right)} + \cosh{\left(\sqrt{K} Lq \right)}\right) \sin{\left(\sqrt{K} Lq \right)} + \left(2 \sqrt{K} Ld \cosh{\left(\sqrt{K} Lq \right)} + K Ld^{2} \sinh{\left(\sqrt{K} Lq \right)} + \sinh{\left(\sqrt{K} Lq \right)}\right) \cos{\left(\sqrt{K} Lq \right)}}{\sqrt{K}}\\- \sqrt{K} \left(\left(\sqrt{K} Ld \sinh{\left(\sqrt{K} Lq \right)} + \cosh{\left(\sqrt{K} Lq \right)}\right) \sin{\left(\sqrt{K} Lq \right)} - \cos{\left(\sqrt{K} Lq \right)} \sinh{\left(\sqrt{K} Lq \right)}\right) & \left(\sqrt{K} Ld \sinh{\left(\sqrt{K} Lq \right)} + \cosh{\left(\sqrt{K} Lq \right)}\right) \cos{\left(\sqrt{K} Lq \right)} + \sin{\left(\sqrt{K} Lq \right)} \sinh{\left(\sqrt{K} Lq \right)}\end{matrix}\right]$

```python
# Matrix elements computation
# f=200 m, lq=1 m, ld= 30 m
M_thick = M.subs(K, 1/(200*1)).subs(Lq, 1).subs(Ld,30) # units K in m-2, Lq and Ld in m
M_thick
```

$\displaystyle \left[\begin{matrix}0.821745966061752 & 66.6422445425746\\-0.000766666456349212 & 1.15474570697446\end{matrix}\right]$

```python
M_thin
```

$\displaystyle \left[\begin{matrix}0.8275 & 64.5\\-0.00075 & 1.15\end{matrix}\right]$

```python
#And for 3 FODO cells?
M3=M*M*M
```

In real world applications, lattices (including FODO) are not designed by hand but
dedicated software is used to do the design and simulation as for example MAD-X.

The TWISS in MADX it is based in matrix multiplications similar to what has been shown here but accounting also for second order matrices.

# Wha is next?

- Now we are going to do optics calculations using the MAD-X TWISS command (handle thousands of elements).

- We will use the MATCHING MAD-X tool to compute the required magnetic properties for a desired TWISS functions.

- We will use MAD-X to visulize the impact of some properties of the lattice on the TWISS and single particle DYNAMICS.

# An introduction to MAD-X using the python interface

In this first part we are going to get familiar with MAD-X syntax.

For more information please refer to the [MAD-X online manual](http://cern.ch/madx/releases/last-rel/madxuguide.pdf).

**Basic steps:**

   - Load the cpymad library.
   - Instantiate the MAD-X class (we create an object of the class).
   - Access the methods in the class.
       - From the methods available we will be mainly using the method "input" to send to MAD-X the commands.

```python
#Load the cpymad library
from cpymad.madx import Madx
```

```python
#Launching MAD-X
myMad = Madx(stdout=True)
```

```python
#String that will be interpreted by MAD-X
myString='''
stop;
'''
```

```python
#Using the "input" method to send the commandas to the MAD-X class
myMad.input(myString);
```

With the 'stop;' instruction we exit from MAD-X, so, as done in the following cell we need to re-instantiate our MAD-X object with the
**myMad = Madx()** instruction.

---
It is a good practice to make header, please use '!' to comment the single line.

```python
myMad = Madx(stdout=True)
```

```python
# Define and print a value
myString='''

!***************************************
! It is a good practice to make a header
!***************************************

a= 20;
value a;

'''
myMad.input(myString);
```

---
Use the **help** keyword (very rudimental help)

```python
# To get information about the MAD-X methods (twiss, beam,match... ) use the commnd "help"
myString='''
help, twiss;
'''
myMad.input(myString);
```

```python
myString='''
help, drift;
'''
myMad.input(myString);
```

# Let's define the main ingredients to get some results from MAD-X!

-Definition of machine parameters

-Magnets definition

-Sequence definition

-Beam definition

-Activate the sequence

-Actions

```python
myString='''

! *********************************************************************
! Definition of some parameters
! *********************************************************************

l_cell=60;
quadrupoleLenght=1;
myK:=0.005;// m^-2

! *********************************************************************
! Definition of magnets
! *********************************************************************
QF: quadrupole, L=quadrupoleLenght, K1:=myK;
QD: quadrupole, L=quadrupoleLenght, K1:=-myK;

! *********************************************************************
! Definition of sequence
! *********************************************************************
myCell:sequence, refer=entry, L=L_CELL;
quadrupole1: QF, at=0;
marker1: marker, at=15;
quadrupole2: QD, at=30;
endsequence;

! *********************************************************************
! Definition of beam
! *********************************************************************
beam, particle=proton, energy=1;

! *********************************************************************
! Use of the sequence
! *********************************************************************
use, sequence=myCell;

! *********************************************************************
! TWISS
! *********************************************************************

select, flag=TWISS, column=keyword, name, s, betx, bety,alfx, alfy, x, y, dx, dy;

twiss, file=Test_Nuria.madx;

plot, haxis=s, vaxis=betx,bety,dx,colour=100,file=Test_Nuria;

'''
myMad.input(myString);
```

The OUTPUT generated by MADX can be found by accessing the jupyter-notebook files-view.
- For accessing: CLIC on the jupyter-logo on the top of the page using the right buttom of your mouse and use the option "Open Link in a New Tab".

**Output:**

- SUMM table

- TWISS table

- TWISS .txt file

- TWISS .ps plot

# Accessing the data

- Open the files generated by MAD-X.
- Use python and output the required data on the jupyter-notebook.
    - Using MAD-X commands and the **input()** method.
    - Using **cpymad** methods:
        - **myMad.table.twiss.dframe()**
        - **myMad.table.summ.dframe()**

```python
#######################
#Using MAD-X commands #
#######################

myString='''
value, table(SUMM,Q1);
value, table(SUMM,betxmax);
'''
myMad.input(myString);
```

And for the vertical plane?

```python
#######################
#Using MAD-X commands #
#######################

myString='''
value, table(SUMM,Q2);
value, table(SUMM,betymax);
'''
myMad.input(myString);
```

```python
myString='''
value, table(TWISS,MYCELL$END,betx);
value, table(TWISS,MYCELL$END,bety);
'''
myMad.input(myString);
```

# Using python pandas library

Pandas dataframe are very convenient, have a look in https://pandas.pydata.org/Pandas_Cheat_Sheet.pdf.

```python
# Using another method from cpymad "table.twiss.dframe()"
myDF=myMad.table.twiss.dframe()
```

```python
myDF
```

      name
      keyword
      s
      betx
      alfx
      mux
      bety
      alfy
      muy
      x
      ...
      sig54
      sig55
      sig56
      sig61
      sig62
      sig63
      sig64
      sig65
      sig66
      n1

      #s
      mycell$start:1
      marker
      0.0
      434.996645
      -1.086796
      0.000000
      376.179327
      0.941387
      0.000000
      0.0
      ...
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0

      quadrupole1
      quadrupole1:1
      quadrupole
      1.0
      434.996645
      1.086796
      0.000366
      376.179327
      -0.941387
      0.000423
      0.0
      ...
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0

      drift_0[0]
      drift_0:0
      drift
      15.0
      405.549112
      1.016599
      0.005672
      403.520929
      -1.011585
      0.006144
      0.0
      ...
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0

      marker1
      marker1:1
      marker
      15.0
      405.549112
      1.016599
      0.005672
      403.520929
      -1.011585
      0.006144
      0.0
      ...
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0

      drift_1[0]
      drift_1:0
      drift
      30.0
      376.179327
      0.941387
      0.011785
      434.996645
      -1.086796
      0.011843
      0.0
      ...
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0

      quadrupole2
      quadrupole2:1
      quadrupole
      31.0
      376.179327
      -0.941387
      0.012209
      434.996645
      1.086796
      0.012209
      0.0
      ...
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0

      drift_2[0]
      drift_2:0
      drift
      60.0
      434.996645
      -1.086796
      0.023628
      376.179327
      0.941387
      0.023628
      0.0
      ...
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0

      #e
      mycell$end:1
      marker
      60.0
      434.996645
      -1.086796
      0.023628
      376.179327
      0.941387
      0.023628
      0.0
      ...
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0

8 rows × 256 columns

```python
myDF[['name','s','betx','bety','alfx','alfy']]
```

      name
      s
      betx
      bety
      alfx
      alfy

      #s
      mycell$start:1
      0.0
      434.996645
      376.179327
      -1.086796
      0.941387

      quadrupole1
      quadrupole1:1
      1.0
      434.996645
      376.179327
      1.086796
      -0.941387

      drift_0[0]
      drift_0:0
      15.0
      405.549112
      403.520929
      1.016599
      -1.011585

      marker1
      marker1:1
      15.0
      405.549112
      403.520929
      1.016599
      -1.011585

      drift_1[0]
      drift_1:0
      30.0
      376.179327
      434.996645
      0.941387
      -1.086796

      quadrupole2
      quadrupole2:1
      31.0
      376.179327
      434.996645
      -0.941387
      1.086796

      drift_2[0]
      drift_2:0
      60.0
      434.996645
      376.179327
      -1.086796
      0.941387

      #e
      mycell$end:1
      60.0
      434.996645
      376.179327
      -1.086796
      0.941387

```python
myDF["s"]
```

    #s              0.0
    quadrupole1     1.0
    drift_0[0]     15.0
    marker1        15.0
    drift_1[0]     30.0
    quadrupole2    31.0
    drift_2[0]     60.0
    #e             60.0
    Name: s, dtype: float64

```python
myDF["betx"]
```

    #s             434.996645
    quadrupole1    434.996645
    drift_0[0]     405.549112
    marker1        405.549112
    drift_1[0]     376.179327
    quadrupole2    376.179327
    drift_2[0]     434.996645
    #e             434.996645
    Name: betx, dtype: float64

```python
myDF2=myMad.table.summ.dframe()
```

```python
myDF2
```

      length
      orbit5
      alfa
      gammatr
      q1
      dq1
      betxmax
      dxmax
      dxrms
      xcomax
      ...
      ycorms
      deltap
      synch_1
      synch_2
      synch_3
      synch_4
      synch_5
      synch_6
      synch_8
      nflips

      #e
      60.0
      -0.0
      1.110223e-16
      9.490627e+07
      0.023628
      -0.068435
      434.996645
      0.0
      0.0
      0.0
      ...
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0
      0.0

1 rows × 27 columns

```python
myDF2["q1"]
```

    #e    0.023628
    Name: q1, dtype: float64

# Basic plot

```python
#Plot
%matplotlib notebook
plt.rcParams['savefig.dpi'] = 90
plt.rcParams['figure.dpi'] = 90

plt.plot(myDF['s'],myDF['betx'],'.-b',label='$\\beta_x$')
plt.plot(myDF['s'],myDF['bety'],'.-r',label='$\\beta_y$')
#Labels of the plot
plt.xlabel('s [m]')
plt.ylabel('[m]')
#Legend and grid
plt.legend(loc='best')
plt.grid()
```

    <IPython.core.display.Javascript object>

# For reference...

---
This is an example to get familiar with the use of the physical constants and the formatting of the output. Have a look on the difference.

```python
myString='''
a=pi;
value a;
set, format="22.20e";
value a;
'''
myMad.input(myString);
```

#
This is an example to get familiar with if and deferred expression. Please note the after the block delimited with {...} the ; can be omitted. Pay attention to circular call!

```python
myString='''
if (1==1){
option, echo=false, info=true;
a=pi;
b:=a;
c=a;
value a;
value b;
value c;
a=CLIGHT*cos(a);
value a;
value b;
value c;}
! BEWARE of circular call!
!a:=a+1;
! When evaluating you will get a fatal error
! value a;
option, echo=true, info=true;
'''
myMad.input(myString);
```

# ---
This is an example to get familiar with **while** and **macros** loops.

```python
myString='''
a(myvariable1,myvariable2): macro = {
value, myvariable1;
value, myvariable1*myvariable2;
}

N=1;
while (N<10){
exec, a(N,N);
N=N+1;
}
'''
myMad.input(myString);
```

---
### List of functions
In MAD-X the following functions are available

- SQRT(x) square root,
- LOG(x) natural logarithm,
- LOG10(x) logarithm base 10,
- EXP(x) exponential,
- SIN(x) trigonometric sine,
- COS(x) trigonometric cosine,
- TAN(x) trigonometric tangent,
- ASIN(x) arc sine,
- ACOS(x) arc cosine,
- ATAN(x) arc tangent,
- SINH(x) hyperbolic sine,
- COSH(x) hyperbolic cosine,
- TANH(x) hyperbolic tangent,
- SINC(x) cardinal sine function,
- ABS(x) absolute value,
- ERF(x) Gauss error,
- ERFC(x) complementary error,
- FLOOR(x) floor, largest previous integer,
- CEIL(x) ceiling, smallest next integer,
- ROUND(x) round, closest integer,
- FRAC(x) fractional part of number,
- RANF() random number, uniformly distributed in [0,1],
- GAUSS() random number, gaussian distribution with unit standard deviation,
- TGAUSS(x) random number, gaussian distribution with unit standard deviation, truncated at x standard deviations;

---
### List of physical constant

| MAD-X name  | symbol  |  value |unit|
|:-:|:-:|:-:|:-:|
|PI| π |4 * atan(1)| 1|
|TWOPI|2π| 2 * PI| 1|
|DEGRAD| 180/π |180 / PI| deg/rad|
|RADDEG| π/180 |PI / 180 |rad/deg|
|E| e |exp(1) |1|
|EMASS| me |0.510998928e−3| GeV|
|PMASS| mp |0.938272046| GeV|
|NMASS| u |0.931494061| GeV|
|MUMASS| mµ| 0.1056583715 |GeV|
|CLIGHT| c| 299792458| m/s|
|QELECT| e| 1.602176565e−19| A.s|
|HBAR| ¯h| 6.58211928e−25| MeV.s|
|ERAD| re| 2.8179403267e−15| m|
|PRAD| re(me/mp)| ERAD*EMASS/PMASS| m|


```javascript
%%javascript
$('#appmode-leave').hide();
$('#copy-binder-link').hide();
$('#visit-repo-link').hide();
```

# Water anomalies
Despite its simple molecular structure, water is an exceptionally complicated fluid.
Many of its properties do not follow the trends obeyed by other liquids and are often referred to as water anomalies.
Scientists often report more than 50 _anomalous_ properties of water, and some of the most well-known examples are
1. Water has an unusually high melting point for a molecule of such a low molecular weight.
2. Water has an unusually high boiling point for a molecule of such a low molecular weight.
3. A liquid-liquid transition occurs at about 330 K.
4. Pressure reduces ice's melting point.
5. Cold liquid water has a high density that increases on warming (up to 3.984 °C).

Most of these anomalous behaviours have been explained and there is ample scientific (and non-scientific) literature discussing them. As an example, this website [https://water.lsbu.ac.uk/water/water_anomalies.html](https://water.lsbu.ac.uk/water/water_anomalies.html) will provide a good overview, and plenty of references, on the topic.

In this numerical workshop you will use a computational technique called Molecular Dynamics (MD) to study the how the water density changes with temperature.
MD is one of the most widely used type of atomistic simulations, and it is now routinely used in many research groups to complement experimental studies.

## Molecular dynamics
Molecular dynamics is conceptually very simple, an iterative solution of Newton's equations of motions at the atomic level, but subtly complicated to use for direct quantitative comparison with experiments.
Although a detailed description of MD is beyond the scope of this laboratory, it is worth discussing some basic ideas for you to start appreciating the power and limitations of this technique. There are plenty of webpage and tutorials that describe the working principles of MD; Wikipedia has a fairly good an general overview of this topic [https://en.wikipedia.org/wiki/Molecular_dynamics]( https://en.wikipedia.org/wiki/Molecular_dynamics).
Another very good reference, albeit a bit dated, is this collection of notes from the 1997 ICTP Spring Colleges in Computational Physics[Molecular Dynamics primer](https://web.mst.edu/~vojtat/class_5403/ercolessi.pdf).

In MD the atoms are treated as point particles with a mass and a partial charge. Their interactions are described by simple empirical equations, such as the Coulomb and the van der Waals  (dispersion) forces, supplemented with two-, three- or four-body interactions to better capture the covalent nature of the intramolecular bonds.
For example, in classical molecular dynamics the interaction *energy* between two non-bonded atoms separated by a distance, $r$, can be written as

\begin{equation}
U_{ij} = \frac{1}{4\pi\varepsilon_0}\frac{q_i q_j}{r} + \frac{A}{r^{12}} - \frac{B}{r^6} \tag{1}
\end{equation}

Where the first term is the Coulomb interaction and the last two the repulsive and attractive parts of the van der Waals interactions.
On the other hand the bonded two-, three- and four-body interactions between covalently bonded atoms are typically described by *harmonic* potentials

\begin{eqnarray}
U_{ij}^b &=& K_b(b_{ij}-b_0)^2 \tag{2} \\
U_{ijk}^a &=& K_\theta(\theta_{ijk}-\theta_0)^2 \tag{3} \\
U_{ijkl}^t &=& K_\phi[1+\cos(n\phi_{jikl}-\phi_0)]^2 \tag{3} \\
\end{eqnarray}

where $b_{ij}$, $\theta_{ijk}$ and $\phi_{jikl}$ are the bond lengths, angle and torsional angle between the atoms and the other quantities are fitting parameters, which are key to determine the accuracy of the simulations.

Once the interaction energy is known we can then compute the forces on the atoms as the sum of all pair-wise interactions

\begin{equation}
F_i = -\nabla_i U = \sum_{j\neq i} F_{ij} = -\Bigg[
  \sum \frac{\partial U_{ij}}{\partial x_i} +
  \sum \frac{\partial U_{ij}^a}{\partial x_i} +
  \sum \frac{\partial U_{ijk}^b}{\partial x_i} +
  \sum \frac{\partial U_{ijkl}^t}{\partial x_i} \Bigg] \tag{5}
\end{equation}

Then, by knowing the positions, velocities and forces for all the atoms at a certain time $t$, we can use the Newton's equations of motions to *predict* the positions and velocities of the particles after a certain (short) amount of time as passed

\begin{eqnarray}
a_i(t) &=& \frac{F_i(t)}{m_i} \tag{6} \\
v_i(t+\delta t) &=& v_i(t) + a_i(t)\delta t \tag{7} \\
x_i(t+\delta t) &=& x_i(t) + v_i(t)\delta t + \frac{1}{2}a_i(t)\delta t^2 \tag{8} \\
\end{eqnarray}

where $a_i$, $v_i$ and $x_i$ are the acceleration, velocity and position of particle $i$, and $\delta t$ is called the time step.
These three equations (or some variants of them) are usually called **equations of motions**.
Now that we have the new atomic positions we can compute the new forces on the atoms, and use again the Newton's equations of motions to *propagate* the atoms' positions further.
This iterative procedure will generate a **trajectory** for the atoms, and by using energies and velocities collected along the way we will also get information about the temperature, pressure and other thermodynamic quantities of the system.

### Importance of the time step
The time step is one of the most important quantities in MD, and it is key to understand the potentials and limitations of atomistic molecular dynamics simulations.
In fact, for the above equations of motions to be valid, the time step has to be short enough to describe the fastest **atomic** motion in the system, which in the case of water is the O-H stretching mode.
The O-H stretching has a vibrational frequency of approximately $1\times10^{14}$Hz, *i.e* it takes about $1\times10^{-14}$s to complete one oscillation. Therefore, if we want to describe this very fast atomic motion using discrete points in time we would need 10-20 snapshots. Hence the time step has to be of the order of 1~fs ($1\times10^{-15}$s) or less.

Now, let’s imagine running a simulation with a 1 fs time step and that the computer take 10 ms to calculate energies, forces and do one cycle of the equations of motions.
In the table below you can see how long it would you take to simulate a chemical or physical process depending on the time scale it experimentally occurs

| Experimental time scale  | Phenomenon   | Simulation time
| :-----: | :--------: |:---------
| 10 fs   | O-H vibration                     | 0.1 s
| 1 ps    | H-bond persistence                | 10 s
| 1 ns    | Ion permeation through a membrane | 3 hours
| 1$\mu$s | Conformational rearrangement      | 115 days
| 1 ms    | Protein folding (fast)            | 317 days
| 1 s     | Protein folding (typical)         | 317,000 days

Obviously, the time required to do one MD cycle depends on the number of operations the computer has to perform, hence it increases with the system size.
Although computational power has increased exponentially since MD was first introduced in the 1940s and we can now afford to study systems of millions of atoms or of hundreds of nm in size, there are still strong limitations to what can be reliably simulated due to the finite (small) number of atoms is included in the system (compared to Avogadro's number) and the short time scale that the simulation can span.

### Ensemble
MD codes are more complicated that a simple iterative solution of Newton's equations of motions and they include algorithms to control the temperature, pressure and other thermodynamics quantities of the system.
Of particular relevance for this experience is the need to use **thermostats** and **barostats** to fix the boundary conditions of the simulations.
For this laboratory is not important to know the working details of these algorithms, but is key that you are aware that the variables relating to the temperature and pressure of the simulations are input parameters that may need to be changed.

## Scope of the laboratory
The scope of this virtual experiment is to introduce you to atomistic molecular dynamics simulations and to compute the variations of the water density as a function of temperature, and to compare it with experimental values. As briefly mentioned above, the accuracy of the simulation depends on the parameters that are used to compute the intermolecular interactions.
The water models available for this laboratory are
* SPC/E
* TIP3P
* TIP4P/ew
* TIP5P

which is a very small selections of the available models for water.
The models are listed in increasing level of complexity and one would reasonably expect that the more complex model gives more accurate results.

In this laboratory, you will choose one water model and run a series of simulations to compute the water density at various temperatures and determine the volumetric thermal expansion coefficient of water $\alpha$, which is defined as

\begin{equation*}
\alpha(T) = \frac{1}{V}\bigg(\frac{\mathrm{d}V}{\mathrm{d}T}\bigg)_P = \bigg(\frac{\mathrm{d}\ln V}{\mathrm{d}T}\bigg)_P \tag{9}
\end{equation*}

where the subscript _P_ means that the simulations are performed at constant pressure, and we have explicitly indicated the the volumetric thermal expansion coefficient depends on the temperature. In this laboratory we will assume that the temperature dependence of $\alpha(T)$ can be described with a simple polynomial expansion, which means that $\ln V(T)$ can also be described with a polynomial expansion

\begin{eqnarray*}
\ln V(T) &=& a + bT + cT^2 + \dots \\
\frac{\mathrm{d}\ln V}{\mathrm{d}T} = \alpha(T) &=& b + 2cT + \dots
\end{eqnarray*}

From each simulation you will compute the average volume of the simulation cell, discarding the initial portion of the simulation, where the system is out of equilibrium.

You will then fit the logarithm of the average volumes _vs_ temperature with a polynomial function, and compute the value of its derivative, $\alpha$, at a few selected temperatures.

## Your first MD simulation

You will now run your first MD simulation.
In the next Jupyter Notebook we will go under the hood of a molecular dynamics code in written python to see how the ensemble parameters are passed to the code, how to modify them and what outputs the code produces.

This first simulation will also give you an indication of how long a typical run takes. Please note that the timing depends on the load of the machine, so the more users are working on this laboratory to slower the calculations might get. This could be the "worst case scenario" given that all the class would be accessing the VM.

[Run Molecular Dynamics](templateMD.ipynb)

## Section 3 - Molecular dynamics simulations of water

### Section 3.1 - Introduction

**Task 1** - Describe the scope of the laboratory

**Task 2** - Describe the water model that you have chosen

**Task 3** - Describe The set of temperatures that you have chosen

**Task 4** - Detail the length of the simulations you chose.

### Section 3.1 - Results

**Task 5** - How you computed the average, standard error and standard deviation of the volume/density

**Task 6** - A table with the average (molar) volumes and densities

**Task 7** - A plot of $\ln V$ _vs_ T and the polynomial fitting function

**Task 8** - The volumetric thermal expansion coefficient of water at 20$^\circ$C and 50$^\circ$C

### Section 3.1 - Discussion

**Task 9** - A comparison between your values for the density and the thermal expansion coefficient with literature values for "real" water and obtained from other simulations with the same water model.

```python

```


##### Copyright 2020 The TensorFlow Authors.

```python
#@title Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
```

# Quantum data

    View on TensorFlow.org

    Run in Google Colab

    View source on GitHub

    Download notebook

Building off of the comparisons made in the [MNIST](https://www.tensorflow.org/quantum/tutorials/mnist) tutorial, this tutorial explores the recent work of [Huang et al.](https://arxiv.org/abs/2011.01938) that shows how different datasets affect performance comparisons. In the work, the authors seek to understand how and when classical machine learning models can learn as well as (or better than) quantum models. The work also showcases an empirical performance separation between classical and quantum machine learning model via a carefully crafted dataset. You will:

1.   Prepare a reduced dimension Fashion-MNIST dataset.
2.   Use quantum circuits to re-label the dataset and compute Projected Quantum Kernel features (PQK).
3.   Train a classical neural network on the re-labeled dataset and compare the performance with a model that has access to the PQK features.

## Setup

```python
!pip -q install tensorflow==2.3.1 tensorflow-quantum
```

    WARNING: pip is being invoked by an old script wrapper. This will fail in a future version of pip.
    Please see https://github.com/pypa/pip/issues/5599 for advice on fixing the underlying issue.
    To avoid this problem you can invoke Python with '-m pip' instead of running pip directly.

```python
import cirq
import sympy
import numpy as np
import tensorflow as tf
import tensorflow_quantum as tfq

# visualization tools
%matplotlib inline
import matplotlib.pyplot as plt
from cirq.contrib.svg import SVGCircuit
np.random.seed(1234)
```

## 1. Data preparation

You will begin by preparing the fashion-MNIST dataset for running on a quantum computer.

### 1.1 Download fashion-MNIST

The first step is to get the traditional fashion-mnist dataset. This can be done using the `tf.keras.datasets` module.

```python
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.fashion_mnist.load_data()

# Rescale the images from [0,255] to the [0.0,1.0] range.
x_train, x_test = x_train/255.0, x_test/255.0

print("Number of original training examples:", len(x_train))
print("Number of original test examples:", len(x_test))
```

    Number of original training examples: 60000
    Number of original test examples: 10000

Filter the dataset to keep just the T-shirts/tops and dresses, remove the other classes. At the same time convert the label, `y`, to boolean: True for 0 and False for 3.

```python
def filter_03(x, y):
    keep = (y == 0) | (y == 3)
    x, y = x[keep], y[keep]
    y = y == 0
    return x,y
```

```python
x_train, y_train = filter_03(x_train, y_train)
x_test, y_test = filter_03(x_test, y_test)

print("Number of filtered training examples:", len(x_train))
print("Number of filtered test examples:", len(x_test))
```

    Number of filtered training examples: 12000
    Number of filtered test examples: 2000

```python
print(y_train[0])

plt.imshow(x_train[0, :, :])
plt.colorbar()
```

### 1.2 Downscale the images

Just like the MNIST example, you will need to downscale these images in order to be within the boundaries for current quantum computers. This time however you will use a PCA transformation to reduce the dimensions instead of a `tf.image.resize` operation.

```python
def truncate_x(x_train, x_test, n_components=10):
  """Perform PCA on image dataset keeping the top `n_components` components."""
  n_points_train = tf.gather(tf.shape(x_train), 0)
  n_points_test = tf.gather(tf.shape(x_test), 0)

  # Flatten to 1D
  x_train = tf.reshape(x_train, [n_points_train, -1])
  x_test = tf.reshape(x_test, [n_points_test, -1])

  # Normalize.
  feature_mean = tf.reduce_mean(x_train, axis=0)
  x_train_normalized = x_train - feature_mean
  x_test_normalized = x_test - feature_mean

  # Truncate.
  e_values, e_vectors = tf.linalg.eigh(
      tf.einsum('ji,jk->ik', x_train_normalized, x_train_normalized))
  return tf.einsum('ij,jk->ik', x_train_normalized, e_vectors[:,-n_components:]), \
    tf.einsum('ij,jk->ik', x_test_normalized, e_vectors[:, -n_components:])
```

```python
DATASET_DIM = 10
x_train, x_test = truncate_x(x_train, x_test, n_components=DATASET_DIM)
print(f'New datapoint dimension:', len(x_train[0]))
```

    New datapoint dimension: 10

The last step is to reduce the size of the dataset to just 1000 training datapoints and 200 testing datapoints.

```python
N_TRAIN = 1000
N_TEST = 200
x_train, x_test = x_train[:N_TRAIN], x_test[:N_TEST]
y_train, y_test = y_train[:N_TRAIN], y_test[:N_TEST]
```

```python
print("New number of training examples:", len(x_train))
print("New number of test examples:", len(x_test))
```

    New number of training examples: 1000
    New number of test examples: 200

## 2. Relabeling and computing PQK features

You will now prepare a "stilted" quantum dataset by incorporating quantum components and re-labeling the truncated fashion-MNIST dataset you've created above. In order to get the most seperation between quantum and classical methods, you will first prepare the PQK features and then relabel outputs based on their values.

### 2.1 Quantum encoding and PQK features
You will create a new set of features, based on `x_train`, `y_train`, `x_test` and `y_test` that is defined to be the 1-RDM on all qubits of:

$V(x_{\text{train}} / n_{\text{trotter}}) ^ {n_{\text{trotter}}} U_{\text{1qb}} | 0 \rangle$

Where $U_\text{1qb}$ is a wall of single qubit rotations and $V(\hat{\theta}) = e^{-i\sum_i \hat{\theta_i} (X_i X_{i+1} + Y_i Y_{i+1} + Z_i Z_{i+1})}$

First, you can generate the wall of single qubit rotations:

```python
def single_qubit_wall(qubits, rotations):
  """Prepare a single qubit X,Y,Z rotation wall on `qubits`."""
  wall_circuit = cirq.Circuit()
  for i, qubit in enumerate(qubits):
    for j, gate in enumerate([cirq.X, cirq.Y, cirq.Z]):
      wall_circuit.append(gate(qubit) ** rotations[i][j])

  return wall_circuit
```

You can quickly verify this works by looking at the circuit:

```python
SVGCircuit(single_qubit_wall(
    cirq.GridQubit.rect(1,4), np.random.uniform(size=(4, 3))))
```

Next you can prepare $V(\hat{\theta})$ with the help of `tfq.util.exponential` which can exponentiate any commuting `cirq.PauliSum` objects:

```python
def v_theta(qubits):
  """Prepares a circuit that generates V(\theta)."""
  ref_paulis = [
      cirq.X(q0) * cirq.X(q1) + \
      cirq.Y(q0) * cirq.Y(q1) + \
      cirq.Z(q0) * cirq.Z(q1) for q0, q1 in zip(qubits, qubits[1:])
  ]
  exp_symbols = list(sympy.symbols('ref_0:'+str(len(ref_paulis))))
  return tfq.util.exponential(ref_paulis, exp_symbols), exp_symbols
```

This circuit might be a little bit harder to verify by looking at, but you can still examine a two qubit case to see what is happening:

```python
test_circuit, test_symbols = v_theta(cirq.GridQubit.rect(1, 2))
print(f'Symbols found in circuit:{test_symbols}')
SVGCircuit(test_circuit)
```

    Symbols found in circuit:[ref_0]

Now you have all the building blocks you need to put your full encoding circuits together:

```python
def prepare_pqk_circuits(qubits, classical_source, n_trotter=10):
  """Prepare the pqk feature circuits around a dataset."""
  n_qubits = len(qubits)
  n_points = len(classical_source)

  # Prepare random single qubit rotation wall.
  random_rots = np.random.uniform(-2, 2, size=(n_qubits, 3))
  initial_U = single_qubit_wall(qubits, random_rots)

  # Prepare parametrized V
  V_circuit, symbols = v_theta(qubits)
  exp_circuit = cirq.Circuit(V_circuit for t in range(n_trotter))

  # Convert to `tf.Tensor`
  initial_U_tensor = tfq.convert_to_tensor([initial_U])
  initial_U_splat = tf.tile(initial_U_tensor, [n_points])

  full_circuits = tfq.layers.AddCircuit()(
      initial_U_splat, append=exp_circuit)
  # Replace placeholders in circuits with values from `classical_source`.
  return tfq.resolve_parameters(
      full_circuits, tf.convert_to_tensor([str(x) for x in symbols]),
      tf.convert_to_tensor(classical_source*(n_qubits/3)/n_trotter))
```

Choose some qubits and prepare the data encoding circuits:

```python
qubits = cirq.GridQubit.rect(1, DATASET_DIM + 1)
q_x_train_circuits = prepare_pqk_circuits(qubits, x_train)
q_x_test_circuits = prepare_pqk_circuits(qubits, x_test)
```

Next, compute the PQK features based on the 1-RDM of the dataset circuits above and store the results in `rdm`, a `tf.Tensor` with shape `[n_points, n_qubits, 3]`. The entries in `rdm[i][j][k]` = $\langle \psi_i | OP^k_j | \psi_i \rangle$ where `i` indexes over datapoints, `j` indexes over qubits and `k` indexes over $\lbrace \hat{X}, \hat{Y}, \hat{Z} \rbrace$ .

```python
def get_pqk_features(qubits, data_batch):
  """Get PQK features based on above construction."""
  ops = [[cirq.X(q), cirq.Y(q), cirq.Z(q)] for q in qubits]
  ops_tensor = tf.expand_dims(tf.reshape(tfq.convert_to_tensor(ops), -1), 0)
  batch_dim = tf.gather(tf.shape(data_batch), 0)
  ops_splat = tf.tile(ops_tensor, [batch_dim, 1])
  exp_vals = tfq.layers.Expectation()(data_batch, operators=ops_splat)
  rdm = tf.reshape(exp_vals, [batch_dim, len(qubits), -1])
  return rdm
```

```python
x_train_pqk = get_pqk_features(qubits, q_x_train_circuits)
x_test_pqk = get_pqk_features(qubits, q_x_test_circuits)
print('New PQK training dataset has shape:', x_train_pqk.shape)
print('New PQK testing dataset has shape:', x_test_pqk.shape)
```

    New PQK training dataset has shape: (1000, 11, 3)
    New PQK testing dataset has shape: (200, 11, 3)

### 2.2 Re-labeling based on PQK features
Now that you have these quantum generated features in `x_train_pqk` and `x_test_pqk`, it is time to re-label the dataset. To achieve maximum seperation between quantum and classical performance you can re-label the dataset based on the spectrum information found in `x_train_pqk` and `x_test_pqk`.

Note: This preparation of your dataset to explicitly maximize the seperation in performance between the classical and quantum models might feel like cheating, but it provides a **very** important proof of existance for datasets that are hard for classical computers and easy for quantum computers to model. There would be no point in searching for quantum advantage in QML if you couldn't first create something like this to demonstrate advantage.

```python
def compute_kernel_matrix(vecs, gamma):
  """Computes d[i][j] = e^ -gamma * (vecs[i] - vecs[j]) ** 2 """
  scaled_gamma = gamma / (
      tf.cast(tf.gather(tf.shape(vecs), 1), tf.float32) * tf.math.reduce_std(vecs))
  return scaled_gamma * tf.einsum('ijk->ij',(vecs[:,None,:] - vecs) ** 2)

def get_spectrum(datapoints, gamma=1.0):
  """Compute the eigenvalues and eigenvectors of the kernel of datapoints."""
  KC_qs = compute_kernel_matrix(datapoints, gamma)
  S, V = tf.linalg.eigh(KC_qs)
  S = tf.math.abs(S)
  return S, V
```

```python
S_pqk, V_pqk = get_spectrum(
    tf.reshape(tf.concat([x_train_pqk, x_test_pqk], 0), [-1, len(qubits) * 3]))

S_original, V_original = get_spectrum(
    tf.cast(tf.concat([x_train, x_test], 0), tf.float32), gamma=0.005)

print('Eigenvectors of pqk kernel matrix:', V_pqk)
print('Eigenvectors of original kernel matrix:', V_original)
```

    Eigenvectors of pqk kernel matrix: tf.Tensor(
    [[-2.09569391e-02  1.05973557e-02  2.16634180e-02 ...  2.80352887e-02
       1.55521873e-02  2.82677952e-02]
     [-2.29303762e-02  4.66355234e-02  7.91163836e-03 ... -6.14174758e-04
      -7.07804322e-01  2.85902526e-02]
     [-1.77853629e-02 -3.00758495e-03 -2.55225878e-02 ... -2.40783971e-02
       2.11018627e-03  2.69009806e-02]
     ...
     [ 6.05797209e-02  1.32483775e-02  2.69536003e-02 ... -1.38843581e-02
       3.05043962e-02  3.85345481e-02]
     [ 6.33309558e-02 -3.04112374e-03  9.77444276e-03 ...  7.48321265e-02
       3.42793856e-03  3.67484428e-02]
     [ 5.86028099e-02  5.84433973e-03  2.64811981e-03 ...  2.82612257e-02
      -3.80136147e-02  3.29943895e-02]], shape=(1200, 1200), dtype=float32)
    Eigenvectors of original kernel matrix: tf.Tensor(
    [[ 0.03835681  0.0283473  -0.01169789 ...  0.02343717  0.0211248
       0.03206972]
     [-0.04018159  0.00888097 -0.01388255 ...  0.00582427  0.717551
       0.02881948]
     [-0.0166719   0.01350376 -0.03663862 ...  0.02467175 -0.00415936
       0.02195409]
     ...
     [-0.03015648 -0.01671632 -0.01603392 ...  0.00100583 -0.00261221
       0.02365689]
     [ 0.0039777  -0.04998879 -0.00528336 ...  0.01560401 -0.04330755
       0.02782002]
     [-0.01665728 -0.00818616 -0.0432341  ...  0.00088256  0.00927396
       0.01875088]], shape=(1200, 1200), dtype=float32)

Now you have everything you need to re-label the dataset! Now you can consult with the flowchart to better understand how to maximize performance seperation when re-labeling the dataset:

In order to maximize the seperation between quantum and classical models, you will attempt to maximize the geometric difference between the original dataset and the PQK features kernel matrices $g(K_1 || K_2) = \sqrt{ || \sqrt{K_2} K_1^{-1} \sqrt{K_2} || _\infty}$ using `S_pqk, V_pqk` and `S_original, V_original`. A large value of $g$ ensures that you initially move to the right in the flowchart down towards a prediction advantage in the quantum case.

Note: Computing quantities for $s$ and $d$ are also very useful when looking to better understand performance seperations. In this case ensuring a large $g$ value is enough to see performance seperation.

```python
def get_stilted_dataset(S, V, S_2, V_2, lambdav=1.1):
  """Prepare new labels that maximize geometric distance between kernels."""
  S_diag = tf.linalg.diag(S ** 0.5)
  S_2_diag = tf.linalg.diag(S_2 / (S_2 + lambdav) ** 2)
  scaling = S_diag @ tf.transpose(V) @ \
            V_2 @ S_2_diag @ tf.transpose(V_2) @ \
            V @ S_diag

  # Generate new lables using the largest eigenvector.
  _, vecs = tf.linalg.eig(scaling)
  new_labels = tf.math.real(
      tf.einsum('ij,j->i', tf.cast(V @ S_diag, tf.complex64), vecs[-1])).numpy()
  # Create new labels and add some small amount of noise.
  final_y = new_labels > np.median(new_labels)
  noisy_y = (final_y ^ (np.random.uniform(size=final_y.shape) > 0.95))
  return noisy_y
```

```python
y_relabel = get_stilted_dataset(S_pqk, V_pqk, S_original, V_original)
y_train_new, y_test_new = y_relabel[:N_TRAIN], y_relabel[N_TRAIN:]
```

## 3. Comparing models
Now that you have prepared your dataset it is time to compare model performance. You will create two small feedforward neural networks and compare performance when they are given access to the PQK features found in `x_train_pqk`.

### 3.1 Create PQK enhanced model
Using standard `tf.keras` library features you can now create and a train a model on the `x_train_pqk` and `y_train_new` datapoints:

```python
#docs_infra: no_execute
def create_pqk_model():
    model = tf.keras.Sequential()
    model.add(tf.keras.layers.Dense(32, activation='sigmoid', input_shape=[len(qubits) * 3,]))
    model.add(tf.keras.layers.Dense(16, activation='sigmoid'))
    model.add(tf.keras.layers.Dense(1))
    return model

pqk_model = create_pqk_model()
pqk_model.compile(loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),
              optimizer=tf.keras.optimizers.Adam(learning_rate=0.003),
              metrics=['accuracy'])

pqk_model.summary()
```

    Model: "sequential"
    _________________________________________________________________
    Layer (type)                 Output Shape              Param #
    =================================================================
    dense (Dense)                (None, 32)                1088
    _________________________________________________________________
    dense_1 (Dense)              (None, 16)                528
    _________________________________________________________________
    dense_2 (Dense)              (None, 1)                 17
    =================================================================
    Total params: 1,633
    Trainable params: 1,633
    Non-trainable params: 0
    _________________________________________________________________

```python
#docs_infra: no_execute
pqk_history = pqk_model.fit(tf.reshape(x_train_pqk, [N_TRAIN, -1]),
          y_train_new,
          batch_size=32,
          epochs=1000,
          verbose=0,
          validation_data=(tf.reshape(x_test_pqk, [N_TEST, -1]), y_test_new))
```

### 3.2 Create a classical model
Similar to the code above you can now also create a classical model that doesn't have access to the PQK features in your stilted dataset. This model can be trained using `x_train` and `y_label_new`.

```python
#docs_infra: no_execute
def create_fair_classical_model():
    model = tf.keras.Sequential()
    model.add(tf.keras.layers.Dense(32, activation='sigmoid', input_shape=[DATASET_DIM,]))
    model.add(tf.keras.layers.Dense(16, activation='sigmoid'))
    model.add(tf.keras.layers.Dense(1))
    return model

model = create_fair_classical_model()
model.compile(loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),
              optimizer=tf.keras.optimizers.Adam(learning_rate=0.03),
              metrics=['accuracy'])

model.summary()
```

    Model: "sequential_1"
    _________________________________________________________________
    Layer (type)                 Output Shape              Param #
    =================================================================
    dense_3 (Dense)              (None, 32)                352
    _________________________________________________________________
    dense_4 (Dense)              (None, 16)                528
    _________________________________________________________________
    dense_5 (Dense)              (None, 1)                 17
    =================================================================
    Total params: 897
    Trainable params: 897
    Non-trainable params: 0
    _________________________________________________________________

```python
#docs_infra: no_execute
classical_history = model.fit(x_train,
          y_train_new,
          batch_size=32,
          epochs=1000,
          verbose=0,
          validation_data=(x_test, y_test_new))
```

### 3.3 Compare performance
Now that you have trained the two models you can quickly plot the performance gaps in the validation data between the two. Typically both models will achieve > 0.9 accuaracy on the training data. However on the validation data it becomes clear that only the information found in the PQK features is enough to make the model generalize well to unseen instances.

```python
#docs_infra: no_execute
plt.figure(figsize=(10,5))
plt.plot(classical_history.history['accuracy'], label='accuracy_classical')
plt.plot(classical_history.history['val_accuracy'], label='val_accuracy_classical')
plt.plot(pqk_history.history['accuracy'], label='accuracy_quantum')
plt.plot(pqk_history.history['val_accuracy'], label='val_accuracy_quantum')
plt.xlabel('Epoch')
plt.ylabel('Accuracy')
plt.legend()
```

Success: You have engineered a stilted quantum dataset that can intentionally defeat classical models in a fair (but contrived) setting. Try comparing results using other types of classical models. The next step is to try and see if you can find new and interesting datasets that can defeat classical models without needing to engineer them yourself!

## 4. Important conclusions

There are several important conclusions you can draw from this and the [MNIST](https://www.tensorflow.org/quantum/tutorials/mnist) experiments:

1. It's very unlikely that the quantum models of today will beat classical model performance on classical data. Especially on today's classical datasets that can have upwards of a million datapoints.

2. Just because the data might come from a hard to classically simulate quantum circuit, doesn't necessarily make the data hard to learn for a classical model.

3. Datasets (ultimately quantum in nature) that are easy for quantum models to learn and hard for classical models to learn do exist, regardless of model architecture or training algorithms used.


##### Copyright 2020 The OpenFermion Developers

```python
#@title Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
```

# The Jordan-Wigner and Bravyi-Kitaev Transforms

    View on QuantumLib

    Run in Google Colab

    View source on GitHub

    Download notebook

## Setup

Install the OpenFermion package:

```python
try:
    import openfermion
except ImportError:
    !pip install git+https://github.com/quantumlib/OpenFermion.git@master#egg=openfermion
```

## Ladder operators and the canonical anticommutation relations

A system of $N$ fermionic modes is
described by a set of fermionic *annihilation operators*
$\{a_p\}_{p=0}^{N-1}$ satisfying the *canonical anticommutation relations*
$$\begin{align}
    \{a_p, a_q\} &= 0, \label{eq:car1} \\
    \{a_p, a^\dagger_q\} &= \delta_{pq}, \label{eq:car2}
  \end{align}$$ where $\{A, B\} := AB + BA$. The adjoint
$a^\dagger_p$ of an annihilation operator $a_p$ is called a *creation
operator*, and we refer to creation and annihilation operators as
fermionic *ladder operators*.
In a finite-dimensional vector space the anticommutation relations have the following consequences:

-   The operators $\{a^\dagger_p a_p\}_{p=0}^{N-1}$ commute with each
    other and have eigenvalues 0 and 1. These are called the *occupation
    number operators*.

-   There is a normalized vector $\lvert{\text{vac}}\rangle$, called the *vacuum
    state*, which is a mutual 0-eigenvector of all
    the $a^\dagger_p a_p$.

-   If $\lvert{\psi}\rangle$ is a 0-eigenvector of $a_p^\dagger a_p$, then
    $a_p^\dagger\lvert{\psi}\rangle$ is a 1-eigenvector of $a_p^\dagger a_p$.
    This explains why we say that $a_p^\dagger$ creates a fermion in
    mode $p$.

-   If $\lvert{\psi}\rangle$ is a 1-eigenvector of $a_p^\dagger a_p$, then
    $a_p\lvert{\psi}\rangle$ is a 0-eigenvector of $a_p^\dagger a_p$. This
    explains why we say that $a_p$ annihilates a fermion in mode $p$.

-   $a_p^2 = 0$ for all $p$. One cannot create or annihilate a fermion
    in the same mode twice.

-   The set of $2^N$ vectors
    $$\lvert n_0, \ldots, n_{N-1} \rangle :=
      (a^\dagger_0)^{n_0} \cdots (a^\dagger_{N-1})^{n_{N-1}} \lvert{\text{vac}}\rangle,
      \qquad n_0, \ldots, n_{N-1} \in \{0, 1\}$$
    are orthonormal. We can assume they form a basis for the entire vector space.

-   The annihilation operators $a_p$ act on this basis as follows:
    $$\begin{aligned} a_p \lvert n_0, \ldots, n_{p-1}, 1, n_{p+1}, \ldots, n_{N-1} \rangle &= (-1)^{\sum_{q=0}^{p-1} n_q} \lvert n_0, \ldots, n_{p-1}, 0, n_{p+1}, \ldots, n_{N-1} \rangle \,, \\ a_p \lvert n_0, \ldots, n_{p-1}, 0, n_{p+1}, \ldots, n_{N-1} \rangle &= 0 \,.\end{aligned}$$

See [here](http://michaelnielsen.org/blog/archive/notes/fermions_and_jordan_wigner.pdf) for a derivation and discussion of these
consequences.

## Mapping fermions to qubits with transforms

To simulate a system of fermions on a quantum computer, we must choose a representation of the ladder operators on the Hilbert space of the qubits. In other words, we must designate a set of qubit operators (matrices) which satisfy the canonical anticommutation relations. Qubit operators are written in terms of the Pauli matrices $X$, $Y$, and $Z$. In OpenFermion a representation is specified by a transform function which maps fermionic operators (typically instances of FermionOperator) to qubit operators (instances of QubitOperator). In this demo we will discuss the Jordan-Wigner and Bravyi-Kitaev transforms, which are implemented by the functions `jordan_wigner` and `bravyi_kitaev`.

### The Jordan-Wigner Transform
Under the Jordan-Wigner Transform (JWT), the annihilation operators are mapped to qubit operators as follows:
$$\begin{aligned}
    a_p &\mapsto \frac{1}{2} (X_p + \mathrm{i}Y_p) Z_1 \cdots Z_{p - 1} \\
    &= (\lvert{0}\rangle\langle{1}\rvert)_p Z_1 \cdots Z_{p - 1} \\
    &=: \tilde{a}_p.
\end{aligned}$$

This operator has the following action on a computational basis vector
$\lvert z_0, \ldots, z_{N-1} \rangle$:
$$\begin{aligned}
    \tilde{a}_p \lvert z_0 \ldots, z_{p-1}, 1, z_{p+1}, \ldots, z_{N-1} \rangle &=
    (-1)^{\sum_{q=0}^{p-1} z_q} \lvert z_0 \ldots, z_{p-1}, 0, z_{p+1}, \ldots, z_{N-1} \rangle \\
    \tilde{a}_p \lvert z_0 \ldots, z_{p-1}, 0, z_{p+1}, \ldots, z_{N-1} \rangle &= 0.
  \end{aligned}$$

Note that $\lvert n_0, \ldots, n_{N-1} \rangle$ is a basis vector in the Hilbert space of fermions, while $\lvert z_0, \ldots, z_{N-1} \rangle$ is a basis vector in the Hilbert space of qubits. Similarly, in OpenFermion $a_p$ is a FermionOperator while $\tilde{a}_p$ is a QubitOperator.

Let's instantiate some FermionOperators, map them to QubitOperators using the JWT, and check that the resulting operators satisfy the expected relations.

```python
from openfermion import *

# Create some ladder operators
annihilate_2 = FermionOperator('2')
create_2 = FermionOperator('2^')
annihilate_5 = FermionOperator('5')
create_5 = FermionOperator('5^')

# Construct occupation number operators
num_2 = create_2 * annihilate_2
num_5 = create_5 * annihilate_5

# Map FermionOperators to QubitOperators using the JWT
annihilate_2_jw = jordan_wigner(annihilate_2)
create_2_jw = jordan_wigner(create_2)
annihilate_5_jw = jordan_wigner(annihilate_5)
create_5_jw = jordan_wigner(create_5)
num_2_jw = jordan_wigner(num_2)
num_5_jw = jordan_wigner(num_5)

# Create QubitOperator versions of zero and identity
zero = QubitOperator()
identity = QubitOperator(())

# Check the canonical anticommutation relations
assert anticommutator(annihilate_5_jw, annihilate_2_jw) == zero
assert anticommutator(annihilate_5_jw, annihilate_5_jw) == zero
assert anticommutator(annihilate_5_jw, create_2_jw) == zero
assert anticommutator(annihilate_5_jw, create_5_jw) == identity

# Check that the occupation number operators commute
assert commutator(num_2_jw, num_5_jw) == zero

# Print some output
print("annihilate_2_jw = \n{}".format(annihilate_2_jw))
print('')
print("create_2_jw = \n{}".format(create_2_jw))
print('')
print("annihilate_5_jw = \n{}".format(annihilate_5_jw))
print('')
print("create_5_jw = \n{}".format(create_5_jw))
print('')
print("num_2_jw = \n{}".format(num_2_jw))
print('')
print("num_5_jw = \n{}".format(num_5_jw))
```

### The parity transform

By comparing the action of $\tilde{a}_p$ on $\lvert z_0, \ldots, z_{N-1} \rangle$ in the JWT with the action of $a_p$ on $\lvert n_0, \ldots, n_{N-1} \rangle$ (described in the first section of this demo), we can see that the JWT is associated with a particular mapping of bitstrings $e: \{0, 1\}^N \to \{0, 1\}^N$, namely, the identity map $e(x) = x$. In other words, under the JWT, the fermionic basis vector $\lvert n_0, \ldots, n_{N-1} \rangle$ is represented by the computational basis vector $\lvert z_0, \ldots, z_{N-1} \rangle$, where $z_p = n_p$ for all $p$. We can write this as
$$\lvert x \rangle \mapsto \lvert e(x) \rangle,$$
where the vector on the left is fermionic and the vector on the right is qubit. We call the mapping $e$ an *encoder*.

There are other transforms which are associated with different encoders. To see why we might be interested in these other transforms, observe that under the JWT, $\tilde{a}_p$ acts not only on qubit $p$ but also on qubits $0, \ldots, p-1$. This means that fermionic operators with low weight can get mapped to qubit operators with high weight, where by weight we mean the number of modes or qubits an operators acts on. There are some disadvantages to having high-weight operators; for instance, they may require more gates to simulate and are more expensive to measure on some near-term hardware platforms. In the worst case, the annihilation operator on the last mode will map to an operator which acts on all the qubits. To emphasize this point let's apply the JWT to the annihilation operator on mode 99:

```python
print(jordan_wigner(FermionOperator('99')))
```

The purpose of the string of Pauli $Z$'s is to introduce the phase factor $(-1)^{\sum_{q=0}^{p-1} n_q}$ when acting on a computational basis state; when $e$ is the identity encoder, the modulo-2 sum $\sum_{q=0}^{p-1} n_q$ is computed as $\sum_{q=0}^{p-1} z_q$, which requires reading $p$ bits and leads to a Pauli $Z$ string with weight $p$. A simple solution to this problem is to consider instead the encoder defined by
$$e(x)_p = \sum_{q=0}^p x_q \quad (\text{mod 2}),$$
which is associated with the mapping of basis vectors
$\lvert n_0, \ldots, n_{N-1} \rangle \mapsto \lvert z_0, \ldots, z_{N-1} \rangle,$
where $z_p = \sum_{q=0}^p n_q$ (again addition is modulo 2). With this encoding, we can compute the sum $\sum_{q=0}^{p-1} n_q$ by reading just one bit because this is the value stored by $z_{p-1}$. The associated transform is called the parity transform because the $p$-th qubit is storing the parity (modulo-2 sum) of modes $0, \ldots, p$. Under the parity transform, annihilation operators are mapped as follows:
$$\begin{aligned}
    a_p &\mapsto \frac{1}{2} (X_p Z_{p - 1} + \mathrm{i}Y_p) X_{p + 1} \cdots X_{N} \\
    &= \frac{1}{4} [(X_p + \mathrm{i} Y_p) (I + Z_{p - 1}) -
                (X_p - \mathrm{i} Y_p) (I - Z_{p - 1})]
               X_{p + 1} \cdots X_{N} \\
    &= [(\lvert{0}\rangle\langle{1}\rvert)_p (\lvert{0}\rangle\langle{0}\rvert)_{p - 1} -
        (\lvert{0}\rangle\langle{1}\rvert)_p (\lvert{1}\rangle\langle{1}\rvert)_{p - 1}]
       X_{p + 1} \cdots X_{N} \\
\end{aligned}$$

The term in brackets in the last line means "if $z_p = n_p$ then annihilate in mode $p$; otherwise, create in mode $p$ and attach a minus sign". The value stored by $z_{p-1}$ contains the information needed to determine whether a minus sign should be attached or not. However, now there is a string of Pauli $X$'s acting on modes $p+1, \ldots, N-1$ and hence using the parity transform also yields operators with high weight. These Pauli $X$'s perform the necessary update to $z_{p+1}, \ldots, z_{N-1}$ which is needed if the value of $n_{p}$ changes. In the worst case, the annihilation operator on the first mode will map to an operator which acts on all the qubits.

Since the parity transform does not offer any advantages over the JWT, OpenFermion does not include a standalone function to perform it. However, there is functionality for defining new transforms by specifying an encoder and decoder pair, also known as a binary code (in our examples the decoder is simply the inverse mapping), and the binary code which defines the parity transform is included in the library as an example. See [Lowering qubit requirements using binary codes](./binary_code_transforms_demo.ipynb) for a demonstration of this functionality and how it can be used to reduce the qubit resources required for certain applications.

Let's use this functionality to map our previously instantiated FermionOperators to QubitOperators using the parity transform with 10 total modes and check that the resulting operators satisfy the expected relations.

```python
# Set the number of modes in the system
n_modes = 10

# Define a function to perform the parity transform
def parity(fermion_operator, n_modes):
    return binary_code_transform(fermion_operator, parity_code(n_modes))

# Map FermionOperators to QubitOperators using the parity transform
annihilate_2_parity = parity(annihilate_2, n_modes)
create_2_parity = parity(create_2, n_modes)
annihilate_5_parity = parity(annihilate_5, n_modes)
create_5_parity = parity(create_5, n_modes)
num_2_parity = parity(num_2, n_modes)
num_5_parity = parity(num_5, n_modes)

# Check the canonical anticommutation relations
assert anticommutator(annihilate_5_parity, annihilate_2_parity) == zero
assert anticommutator(annihilate_5_parity, annihilate_5_parity) == zero
assert anticommutator(annihilate_5_parity, create_2_parity) == zero
assert anticommutator(annihilate_5_parity, create_5_parity) == identity

# Check that the occupation number operators commute
assert commutator(num_2_parity, num_5_parity) == zero

# Print some output
print("annihilate_2_parity = \n{}".format(annihilate_2_parity))
print('')
print("create_2_parity = \n{}".format(create_2_parity))
print('')
print("annihilate_5_parity = \n{}".format(annihilate_5_parity))
print('')
print("create_5_parity = \n{}".format(create_5_parity))
print('')
print("num_2_parity = \n{}".format(num_2_parity))
print('')
print("num_5_parity = \n{}".format(num_5_parity))
```

Now let's map one of the FermionOperators again but with the total number of modes set to 100.

```python
print(parity(annihilate_2, 100))
```

Note that with the JWT, it is not necessary to specify the total number of modes in the system because $\tilde{a}_p$ only acts on qubits $0, \ldots, p$ and not any higher ones.

### The Bravyi-Kitaev transform

The discussion above suggests that we can think of the action of a transformed annihilation operator $\tilde{a}_p$ on a computational basis vector $\lvert z \rangle$ as a 4-step classical algorithm:
1. Check if $n_p = 0$. If so, then output the zero vector. Otherwise,
2. Update the bit stored by $z_p$.
3. Update the rest of the bits $z_q$, $q \neq p$.
4. Multiply by the parity $\sum_{q=0}^{p-1} n_p$.

Under the JWT, Steps 1, 2, and 3 are represented by the operator $(\lvert{0}\rangle\langle{1}\rvert)_p$ and Step 4 is accomplished by the operator $Z_{0} \cdots Z_{p-1}$ (Step 3 actually requires no action).
Under the parity transform, Steps 1, 2, and 4 are represented by the operator
$(\lvert{0}\rangle\langle{1}\rvert)_p (\lvert{0}\rangle\langle{0}\rvert)_{p - 1} -
(\lvert{0}\rangle\langle{1}\rvert)_p (\lvert{1}\rangle\langle{1}\rvert)_{p - 1}$ and Step 3 is accomplished by the operator $X_{p+1} \cdots X_{N-1}$.

To obtain a simpler description of these and other transforms (with an aim at generalizing), it is better to put aside the ladder operators and work with an alternative set of $2N$ operators defined by
$$c_p = a_p + a_p^\dagger\,, \qquad d_p = -\mathrm{i} (a_p - a_p^\dagger)\,.$$
These operators are known as Majorana operators. Note that if we describe how Majorana operators should be transformed, then we also know how the annihilation operators should be transformed, since
$$a_p = \frac{1}{2} (c_p + \mathrm{i} d_p).$$

For simplicity, let's consider just the $c_p$; the $d_p$ are treated similarly. The action of $c_p$ on a fermionic basis vector is given by
$$c_p \lvert n_0, \ldots, n_{p-1}, n_p, n_{p+1}, \ldots, n_{N-1} \rangle =
(-1)^{\sum_{q=0}^{p-1} n_q} \lvert n_0, \ldots, n_{p-1}, 1 - n_p, n_{p+1}, \ldots, n_{N-1} \rangle$$

In words, $c_p$ flips the occupation of mode $p$ and multiplies by the ever-present parity factor. If we transform $c_p$ to a qubit operator $\tilde{c}_p$, we should be able to describe the action of $\tilde{c}_p$ on a computational basis vector $\lvert z \rangle$ with a 2-step classical algorithm:
1. Update the string $z$ to a new string $z'$.
2. Multiply by the parity $\sum_{q=0}^{p-1} n_q$.

Step 1 amounts to flipping some bits, so it will be performed by some Pauli $X$'s, and Step 2 will be performed by some Pauli $Z$'s. So $\tilde{c}_p$ should take the form
$$\tilde{c}_p = X_{U(p)} Z_{P(p - 1)},$$
where $U(j)$ is the set of bits that need to be updated upon flipping $n_j$, and $P(j)$ is a set of bits that stores the sum $\sum_{q=0}^{j} n_q$ (let's define $P(-1)$ to be the empty set). Let's see how this looks under the JWT and parity transforms.

```python
# Create a Majorana operator from our existing operators
c_5 = annihilate_5 + create_5

# Set the number of modes (required for the parity transform)
n_modes = 10

# Transform the Majorana operator to a QubitOperator in two different ways
c_5_jw = jordan_wigner(c_5)
c_5_parity = parity(c_5, n_modes)

# Print some output
print("c_5_jw = \n{}".format(c_5_jw))
print('')
print("c_5_parity = \n{}".format(c_5_parity))
```

For the JWT, $U(j) = \{j\}$ and $P(j) = \{0, \ldots, j\}$, whereas for the parity transform, $U(j) = \{j, \ldots, N-1\}$ and $P(j) = \{j\}$. The size of these sets can be as large as $N$, the total number of modes. These sets are determined by the encoding function $e$.

It is possible to pick a clever encoder with the property that these sets have size $O(\log N)$. The corresponding transform will map annihilation operators to qubit operators with weight $O(\log N)$, which is much smaller than the $\Omega(N)$ weight associated with the JWT and parity transforms. This fact was noticed by [Bravyi and Kitaev](https://arxiv.org/abs/quant-ph/0003137), and later [Havlíček and others](https://arxiv.org/abs/1701.07072) pointed out that the encoder which achieves this is implemented by a classical data structure called a Fenwick tree. The transforms described in these two papers actually correspond to different variants of the Fenwick tree data structure and give different results when the total number of modes is not a power of 2. OpenFermion implements the one from the first paper as `bravyi_kitaev` and the one from the second paper as `bravyi_kitaev_tree`. Generally, the first one (`bravyi_kitaev`) is preferred because it results in operators with lower weight and is faster to compute.

Let's transform our previously instantiated Majorana operator using the Bravyi-Kitaev transform.

```python
c_5_bk = bravyi_kitaev(c_5, n_modes)
print("c_5_bk = \n{}".format(c_5_bk))
```

The advantage of the Bravyi-Kitaev transform is not apparent in a system with so few modes. Let's look at a system with 100 modes.

```python
n_modes = 100

# Initialize some Majorana operators
c_17 = FermionOperator('[17] + [17^]')
c_50 = FermionOperator('[50] + [50^]')
c_73 = FermionOperator('[73] + [73^]')

# Map to QubitOperators
c_17_jw = jordan_wigner(c_17)
c_50_jw = jordan_wigner(c_50)
c_73_jw = jordan_wigner(c_73)
c_17_parity = parity(c_17, n_modes)
c_50_parity = parity(c_50, n_modes)
c_73_parity = parity(c_73, n_modes)
c_17_bk = bravyi_kitaev(c_17, n_modes)
c_50_bk = bravyi_kitaev(c_50, n_modes)
c_73_bk = bravyi_kitaev(c_73, n_modes)

# Print some output
print("Jordan-Wigner\n"
      "-------------")
print("c_17_jw = \n{}".format(c_17_jw))
print('')
print("c_50_jw = \n{}".format(c_50_jw))
print('')
print("c_73_jw = \n{}".format(c_73_jw))
print('')
print("Parity\n"
      "------")
print("c_17_parity = \n{}".format(c_17_parity))
print('')
print("c_50_parity = \n{}".format(c_50_parity))
print('')
print("c_73_parity = \n{}".format(c_73_parity))
print('')
print("Bravyi-Kitaev\n"
      "-------------")
print("c_17_bk = \n{}".format(c_17_bk))
print('')
print("c_50_bk = \n{}".format(c_50_bk))
print('')
print("c_73_bk = \n{}".format(c_73_bk))
```

Now let's go back to a system with 10 modes and check that the Bravyi-Kitaev transformed operators satisfy the expected relations.

```python
# Set the number of modes in the system
n_modes = 10

# Map FermionOperators to QubitOperators using the Bravyi-Kitaev transform
annihilate_2_bk = bravyi_kitaev(annihilate_2, n_modes)
create_2_bk = bravyi_kitaev(create_2, n_modes)
annihilate_5_bk = bravyi_kitaev(annihilate_5, n_modes)
create_5_bk = bravyi_kitaev(create_5, n_modes)
num_2_bk = bravyi_kitaev(num_2, n_modes)
num_5_bk = bravyi_kitaev(num_5, n_modes)

# Check the canonical anticommutation relations
assert anticommutator(annihilate_5_bk, annihilate_2_bk) == zero
assert anticommutator(annihilate_5_bk, annihilate_5_bk) == zero
assert anticommutator(annihilate_5_bk, create_2_bk) == zero
assert anticommutator(annihilate_5_bk, create_5_bk) == identity

# Check that the occupation number operators commute
assert commutator(num_2_bk, num_5_bk) == zero

# Print some output
print("annihilate_2_bk = \n{}".format(annihilate_2_bk))
print('')
print("create_2_bk = \n{}".format(create_2_bk))
print('')
print("annihilate_5_bk = \n{}".format(annihilate_5_bk))
print('')
print("create_5_bk = \n{}".format(create_5_bk))
print('')
print("num_2_bk = \n{}".format(num_2_bk))
print('')
print("num_5_bk = \n{}".format(num_5_bk))
```