最初に必要なライブラリを読み込みます。

```python
from sympy import *
from sympy.physics.quantum import *
from sympy.physics.quantum.qubit import Qubit, QubitBra, measure_all, measure_all_oneshot,measure_partial, matrix_to_qubit
from sympy.physics.quantum.gate import H,X,Y,Z,S,T,CPHASE,CNOT,SWAP,UGate,CGateS,gate_simp,OneQubitGate
from sympy.physics.quantum.gate import IdentityGate as _I
from sympy.physics.quantum.qft import *
from sympy.physics.quantum.matrixcache import matrix_cache
matrix_cache.cache_matrix('Tdg',Matrix([[1, 0], [0, exp(-I*pi/4)]]))
matrix_cache.cache_matrix('Sdg',Matrix([[1, 0], [0, -I]]))
matrix_cache.cache_matrix('V',(1/2)*Matrix([[1+I, 1-I], [1-I, 1+I]]))
matrix_cache.cache_matrix('Vdg',(1/2)*Matrix([[1-I, 1+I], [1+I, 1-I]]))

CZ = CPHASE

class Tdg(OneQubitGate): # T^{\dagger} 演算
    gate_name = u'Tdg'
    gate_name_latex = u'T^{\dagger}'
    def get_target_matrix(self, format='sympy'):
        return matrix_cache.get_matrix('Tdg', format)

class Sdg(OneQubitGate): # S^{\dagger} 演算
    gate_name = u'Sdg'
    gate_name_latex = u'S^{\dagger}'
    def get_target_matrix(self, format='sympy'):
        return matrix_cache.get_matrix('Sdg', format)

class V(OneQubitGate): # √X 演算
    gate_name = u'V'
    gate_name_latex = u'V'
    def get_target_matrix(self, format='sympy'):
        return matrix_cache.get_matrix('V', format)

class Vdg(OneQubitGate): # √X ^{\dagger}演算
    gate_name = u'Vdg'
    gate_name_latex = u'V^{\dagger}'
    def get_target_matrix(self, format='sympy'):
        return matrix_cache.get_matrix('Vdg', format)

def CV(c,t):  return CGateS((c),V(t))
def CVdg(c,t):  return CGateS((c),Vdg(t))

def CCX(c1,c2,t):  return CGateS((c1,c2),X(t))
def Toffoli(c1,c2,t):  return CGateS((c1,c2),X(t))
def CCZ(c1,c2,t): return (H(t)*CCX(c1,c2,t)*H(t)) # CCZ演算子を定義します。
def hadamard(s,n):
    h = H(s)
    for i in range(s+1,n+s): h = H(i)*h
    return h

def disp1Q(u): print(u); display(represent(u,nqubits=1)); CircuitPlot(u,nqubits=1)
def disp2Q(u): print(u); display(represent(u,nqubits=2)); CircuitPlot(u,nqubits=2)

from sympy.printing.dot import dotprint
init_printing()

%matplotlib inline
import matplotlib.pyplot as plt
from sympy.physics.quantum.circuitplot import CircuitPlot,labeller, Mz,CreateOneQubitGate
alpha, beta, psi, phi, theta, chi = Symbol(r'\alpha'), Symbol(r'\beta'), Symbol(r'\psi'), Symbol(r'\phi'), Symbol(r'\theta'), Symbol(r'\chi')
from qutip import *
import numpy as np
```

（副読本）
* ニールセン・チャン「量子コンピュータと量子通信」（ここでは N,C"QCQI" と書きます）
* arXivに公開されている論文

# 量子コンピュータの基本
## 2.1 量子ビットとは？
### 2.1.1 ブロッホ球とブラケット記法

```python
# ケット
Ket(psi) , alpha * Ket('0') + beta * Ket('1')
```

```python
# ブラ
Bra(psi), Dagger(alpha * Ket('0') + beta * Ket('1') ) , Dagger(alpha) * Bra('0') + Dagger(beta) * Bra('1')
```

```python
# <ψ|ψ> の計算
expand(Dagger(alpha * Ket('0') + beta * Ket('1') ) * (alpha * Ket('0') + beta * Ket('1') ))
```

$\alpha \alpha^{\dagger} = 1, \beta \beta^{\dagger} = 1,  \langle 0 \vert 0 \rangle = 1, \langle 1 \vert 1 \rangle = 1, \langle 1 \vert 0 \rangle = 0, \langle 0 \vert 1 \rangle = 0$

*keywords*

- グローバル位相
- ブロッホ球
- Z基底（計算基底）
- X基底（アダマール基底）
- Y基底（円基底、Circular basis）

```python
b = Bloch()
zero_state = [0,0,1]
b.add_vectors(zero_state)
one_state = [0,0,-1]
b.add_vectors(one_state)
plus_state = [1,0,0]
b.add_vectors(plus_state)
minus_state = [-1,0,0]
b.add_vectors(minus_state)
b.show()
```

### 2.1.2 量子ビットの不思議な性質

*keywords*
- 確率振幅
- ボルンの規則
- 射影測定
- デコーヒーレンス
- コヒーレント時間

$ T_1 $ : $\lvert 1 \rangle$が  $\lvert 0 \rangle$になってしまう時間
$ T_2 $：重ね合わせが壊れてしまう時間

## 2.2 量子ゲートで計算する
### 2.2.1 １量子ビットゲート

* Xゲート

```python
disp1Q(X(0))
```

```python
disp1Q(X(0)*Qubit('0'))
```

```python
disp1Q(X(0)*Qubit('1'))
```

* Yゲート

```python
disp1Q(Y(0))
```

* Zゲート

```python
disp1Q(Z(0))
```

```python
disp1Q(Z(0)*Qubit('0'))
```

```python
disp1Q(Z(0)*Qubit('1'))
```

* アダマールゲート（H）

```python
disp1Q(H(0))
```

* 位相ゲート（S）
Z軸周りの $ \pi / 2 $ 回転

```python
disp1Q(S(0))
```

* $\pi/8$ゲート（T）
Z軸周りの $ \pi / 4 $ 回転

```python
disp1Q(T(0))
```

```python
# TT
disp1Q(T(0) *_I(0)* T(0))  # CircuitPlot がうまく描けないため恒等変換（I）を挟む
```

```python
# SS
disp1Q(S(0)*_I(0) *S(0))  # CircuitPlot がうまく描けないため恒等変換（I）を挟む
```

### 2.2.2 ２量子ビットゲート

```python
a0, a1, b0, b1 = Symbol('a0'), Symbol('a1'), Symbol('b0'), Symbol('b1')
Matrix([[a0],[a1]])
```

```python
 Matrix([b0,b1])
```

```python
Matrix([[a0],[a1]]) .dot( Matrix([b0,b1]) )
```

```python
TensorProduct(Matrix([a0,a1]),( Matrix([b0,b1]) ))
```

```python
u11, u12, u21, u22 = Symbol('u11'), Symbol('u12'), Symbol('u21'), Symbol('u22')
v11, v12, v21, v22 = Symbol('v11'), Symbol('v12'), Symbol('v21'), Symbol('v22')
TensorProduct(Matrix([[u11, u12], [u21, u22]]), Matrix([[v11, v12], [v21, v22]]))
```

```python
represent(CNOT(1,0),nqubits=2)
```

```python
represent(SWAP(1,0), nqubits=2)
```

```python
sw=CNOT(1,0)*CNOT(0,1)*CNOT(1,0)
represent(sw,nqubits=2)
```

```python
CircuitPlot(sw, nqubits=2)
```

## 2.3 量子回路図を書いてみよう
### 2.3.1 量子回路図の書き方

```python
q=H(2)*CZ(1,2)*CNOT(0,1)*H(0)*H(2)
```

```python
CircuitPlot(q, nqubits=3)
```

```python
qst=represent(q*Qubit('000'),nqubits=3)
qst
```

```python
matrix_to_qubit(qst)
```

### 2.3.2 量子コンピュータ版NANDゲート（Toffoliゲート）

```python
represent(Toffoli(2,1,0),nqubits=3)
```

```python
CircuitPlot(Toffoli(2,1,0),nqubits=3)
```

（参考）

[Elementary gates for quantum computation(arXiv:quant-ph/9503016)](https://arxiv.org/abs/quant-ph/9503016) ←1995年に示されている
N,C"QCQI" 「4.3 制御演算」に説明あり

```python
tof=CV(2,0)*CNOT(2,1)*CVdg(1,0)*CNOT(2,1)*CV(1,0)
CircuitPlot(tof,nqubits=3)
```

```python
represent(tof, nqubits=3)
# 2(0.5−0.5𝑖)(0.5+0.5𝑖) = 1
# (0.5−0.5𝑖)^2+(0.5+0.5𝑖)^2 = 0
```

```python
def Toff(q0,q1,q2):
    return T(q0)*S(q1)*CNOT(q0,q1)*Tdg(q1)*CNOT(q0,q1)\
            *H(q2)*Tdg(q1)*T(q2)*CNOT(q0,q2)*Tdg(q2)*CNOT(q1,q2)\
            *T(q2)*CNOT(q0,q2)*Tdg(q2)*CNOT(q1,q2)*H(q2)

represent(Toff(2,1,0), nqubits=3)  # 行列表現を表示します
```

```python
CircuitPlot(Toff(2,1,0),nqubits=3)
```

### 2.3.3 量子コンピュータ版足し算回路

* 半加算器（half adder）
 1 桁の 2 進数を ２つ加算

```python
halfadder=CNOT(2,1)*Toffoli(2,1,0)
CircuitPlot(halfadder,nqubits=3)
```

* 全加算器（full adder）
  1 桁の 2 進数を ３ つ加算

```python
halfadder=CNOT(3,1)*Toffoli(3,1,0)*CNOT(2,1)*Toffoli(2,1,0)
CircuitPlot(halfadder,nqubits=4)
```

> 等価な算術演算ができるという意味では量子コンピュータは古典コンピュータの上位互換といえますが、そのご利益はそれほど自明ではありません。

### 2.3.4 量子コンピュータ版算術論理演算

"重ね合わせ状態を使って並列計算"  ⇔　ボルンの規則による、値の取り出し（測定）は 1/N の確率

入力として用いる重ね合わせ状態 $\displaystyle \lvert x \rangle = \frac{1}{\sqrt{N}} \sum_{i=1}^N \lvert x_{i} \rangle $ は、$ \lvert 0 \dots 0 \rangle $ に全ての量子ビットにアダマールをかけたもの

```python
superposition=hadamard(0,4)*Qubit('0'*4)
superposition
```

```python
transpose(represent(superposition,nqubits=4))
```

### 2.3.5 万能量子計算

(Qiita記事）[量子コンピューターがコンピューターである理由](https://qiita.com/kyamaz/items/9c99bbf66c45fca535e5)
(Qiita記事）[量子コンピューターの計算精度とSolovay-Kitaevの定理](https://qiita.com/kyamaz/items/6d2c2708cb3351e57be1)
N,C"QCQI" 「付録C Solovay-Kitaevの定理」

 https://www.mathstat.dal.ca/~selinger/newsynth/ ← Haskell based なサンプルがあります。（コマンド実行できます）

## 2.4 コピーとテレポーテーション
### 2.4.1 量子もつれ（エンタングルメント）とは？

積状態：
$\begin{equation}
\left.\begin{aligned}
\big( \alpha \lvert 0 \rangle
+ \beta \lvert 1 \rangle \big)
\otimes \big( \gamma \lvert 0 \rangle
+ \delta \lvert 1 \rangle \big)
& = \alpha \gamma \lvert 00 \rangle
+ \alpha \delta \lvert 01 \rangle
+ \beta \gamma \lvert 10 \rangle
+ \beta \delta \lvert 11 \rangle
\end{aligned} \right.
\end{equation}$

エンタングル状態：単純な積では表すことができない状態

例）Bell状態

$\begin{equation}
\left.\begin{aligned}
\lvert \Phi_{+} \rangle =
\frac{1}{\sqrt{2}} \lvert 00 \rangle
+ \frac{1}{\sqrt{2}} \lvert 11 \rangle
\end{aligned} \right.
\end{equation}$

$\begin{equation}
\left.\begin{aligned}
\lvert \Phi_{-} \rangle =
\frac{1}{\sqrt{2}} \lvert 00 \rangle
- \frac{1}{\sqrt{2}} \lvert 11 \rangle
\end{aligned} \right.
\end{equation}$

$\begin{equation}
\left.\begin{aligned}
\lvert \Psi_{+} \rangle =
\frac{1}{\sqrt{2}} \lvert 01 \rangle
+ \frac{1}{\sqrt{2}} \lvert 10 \rangle
\end{aligned} \right.
\end{equation}$

$\begin{equation}
\left.\begin{aligned}
\lvert \Psi_{-} \rangle =
\frac{1}{\sqrt{2}} \lvert 01 \rangle
- \frac{1}{\sqrt{2}} \lvert 10 \rangle
\end{aligned} \right.
\end{equation}$

計算基底でエンタングルしている状態$\lvert \Phi_{+} \rangle$の、アダマール基底を調べる

$\begin{equation}
\left.\begin{aligned}
\lvert \Phi_{+} \rangle
&= \frac{1}{\sqrt{2}} \lvert 00 \rangle
+ \frac{1}{\sqrt{2}} \lvert 11 \rangle \\
&= \frac{1}{2 \sqrt{2}} \left\{ ( \lvert +\rangle + \lvert -\rangle ) \otimes ( \lvert +\rangle + \lvert -\rangle )
+ ( \lvert +\rangle - \lvert -\rangle ) \otimes ( \lvert +\rangle - \lvert -\rangle )  \right\} \\
&= \frac{1}{2 \sqrt{2}} \left\{ \lvert ++ \rangle + \lvert +- \rangle + \lvert -+ \rangle + \lvert -- \rangle
+ \lvert ++ \rangle - \lvert +- \rangle - \lvert -+ \rangle + \lvert -- \rangle
\right\} \\
&= \frac{1}{\sqrt{2}} \lvert ++ \rangle
+ \frac{1}{\sqrt{2}} \lvert -- \rangle
\end{aligned} \right.
\end{equation}$

（参考）
[Efficient evaluation of quantum observables using entangled measurements(arXiv:1909.09119)](https://arxiv.org/abs/1909.09119)

「Entanglion」IBM Research考案には、次のようなBell状態の線型結合の状態がでてきます。

$\begin{equation}
\left.\begin{aligned}
\lvert \Omega_{0} \rangle
&= \frac{1}{\sqrt{2}}
\lvert \Phi_{+} \rangle
+ \frac{1}{\sqrt{2}}
\lvert \Psi_{-} \rangle \\
&=\frac{1}{2}(
  \lvert 00 \rangle
+ \lvert 01 \rangle
- \lvert 10 \rangle
+ \lvert 11 \rangle
)
\end{aligned} \right.
\end{equation}$

$\begin{equation}
\left.\begin{aligned}
\lvert \Omega_{1} \rangle
&= -\frac{1}{\sqrt{2}}
\lvert \Phi_{-} \rangle
+ \frac{1}{\sqrt{2}}
\lvert \Psi_{+} \rangle \\
&=\frac{1}{2}(
- \lvert 00 \rangle
+ \lvert 01 \rangle
+ \lvert 10 \rangle
+ \lvert 11 \rangle
)
\end{aligned} \right.
\end{equation}$

$\begin{equation}
\left.\begin{aligned}
\lvert \Omega_{2} \rangle
&= \frac{1}{\sqrt{2}}
\lvert \Phi_{-} \rangle
+ \frac{1}{\sqrt{2}}
\lvert \Psi_{+} \rangle \\
&=\frac{1}{2}(
  \lvert 00 \rangle
+ \lvert 01 \rangle
+ \lvert 10 \rangle
- \lvert 11 \rangle
)
\end{aligned} \right.
\end{equation}$

$\begin{equation}
\left.\begin{aligned}
\lvert \Omega_{3} \rangle
&= \frac{1}{\sqrt{2}}
\lvert \Phi_{+} \rangle
- \frac{1}{\sqrt{2}}
\lvert \Psi_{-} \rangle \\
&=\frac{1}{2}(
 \lvert 00 \rangle
- \lvert 01 \rangle
+ \lvert 10 \rangle
+ \lvert 11 \rangle
)
\end{aligned} \right.
\end{equation}$

### 2.4.2 量子データはコピーできない？

* 量子複製不可能定理（No-Cloning Theorem）

ある量子状態$A_{@x}$から, 同じ量子状態を２つ($A_{@x}$と$A_{@y}$)を作ることができない.
ある量子状態$A_{@x}$から, 同じ量子状態$A_{@y}$に移動することはできる. ただし, 元あった量子状態 $A_{@x}$ は状態を維持できない.

* ”光速を超えて情報を伝えることは不可能です”

* （補足）ベルの不等式の破れ

詳しくは, 清水明「新版 量子論の基礎」第8章
ベルの不等式の一種「CHSH不等式」における"不等式の破れ"の解説あり

**ベルの不等式**
```
適当な物理量の組のある相関Cについて、原因と結果が逆転したり
しないまともな理論の範囲内では、
−2 ≤ C ≤ 2
をどんな実在論も満たす。
```
しかし、量子論はその不等式を破る場合がある。$|C| = 2\sqrt{2}$ の例が示せる。

### 2.4.3 量子データを転送する

 * 量子テレポーテーション

```python
qtelep = Mz(2)*Mz(1)*H(2)*CNOT(2,1)*CNOT(1,0)*H(1)
CircuitPlot(qtelep,nqubits=3, labels=["0_{b}","0_{a}","\psi_{a}"])
```

$\begin{equation}
\left.\begin{aligned}
\Big[
\lvert \psi \rangle _{a}
\otimes
\lvert 0 \rangle _{a}
\Big]
\lvert 0 \rangle _{b}
\end{aligned} \right.
\end{equation}$

$\begin{equation}
\left.\begin{aligned}
\otimes H \cdots \Rightarrow
\Big[
\lvert \psi \rangle _{a}
\otimes
\frac{1}{\sqrt{2}} \left(
\lvert 0 \rangle _{a} +  \lvert 1 \rangle _{a}
\right)
\Big]
\lvert 0 \rangle _{b}
\end{aligned} \right.
\end{equation}$

$\begin{equation}
\left.\begin{aligned}
\otimes CNOT _{1} \cdots \Rightarrow
\lvert \psi \rangle _{a}
\otimes
\frac{1}{\sqrt{2}} \left(
\lvert 0 \rangle _{a} \lvert 0 \rangle _{b}  +  \lvert 1 \rangle _{a} \lvert 1 \rangle _{b}
\right)
\end{aligned} \right.
\end{equation}$

$\begin{equation}
\left.\begin{aligned}
\quad \quad \quad =
\left( \alpha \lvert 0 \rangle _{a}
+ \beta \lvert 1 \rangle _{a} \right)
\otimes
\frac{1}{\sqrt{2}} \left(
\lvert 0 \rangle _{a} \lvert 0 \rangle _{b}  +  \lvert 1 \rangle _{a} \lvert 1 \rangle _{b}
\right)
\end{aligned} \right.
\end{equation}$

$\begin{equation}
\left.\begin{aligned}
\otimes CNOT _{2} \cdots \Rightarrow
\frac{1}{\sqrt{2}} \left(
\alpha \lvert 0 \rangle _{a} \lvert 0 \rangle _{a} \lvert 0 \rangle _{b}
+ \alpha \lvert 0 \rangle _{a} \lvert 1 \rangle _{a} \lvert 1 \rangle _{b}
+
\beta \lvert 1 \rangle _{a} \lvert 1 \rangle _{a} \lvert 0 \rangle _{b}
+ \beta \lvert 1 \rangle _{a} \lvert 0 \rangle _{a} \lvert 1 \rangle _{b}
\right)
\end{aligned} \right.
\end{equation}$

$\begin{equation}
\left.\begin{aligned}
\otimes H \cdots \Rightarrow
(1/2) (
&+ \alpha \lvert 0 \rangle _{a} \lvert 0 \rangle _{a} \lvert 0 \rangle _{b}
+
\alpha \lvert 1 \rangle _{a} \lvert 0 \rangle _{a} \lvert 0 \rangle _{b} \\
& +
\alpha \lvert 0 \rangle _{a} \lvert 1 \rangle _{a} \lvert 1 \rangle _{b}
+
\alpha \lvert 1 \rangle _{a} \lvert 1 \rangle _{a} \lvert 1 \rangle _{b} \\
& +
\beta \lvert 0 \rangle _{a} \lvert 1 \rangle _{a} \lvert 0 \rangle _{b}
-
\beta \lvert 1 \rangle _{a} \lvert 1 \rangle _{a} \lvert 0 \rangle _{b} \\
& +
\beta \lvert 0 \rangle _{a} \lvert 0 \rangle _{a} \lvert 1 \rangle _{b}
-
\beta \lvert 1 \rangle _{a} \lvert 0 \rangle _{a} \lvert 1 \rangle _{b}
)
\end{aligned} \right.
\end{equation}$

$\begin{equation}
\left.\begin{aligned}
\quad \quad \quad =
(1/2) (
&+ \lvert 0 \rangle _{a} \lvert 0 \rangle _{a} ( \alpha \lvert 0 \rangle _{b} +\beta \lvert 1 \rangle _{b}) \\
& +
\lvert 0 \rangle _{a} \lvert 1 \rangle _{a} ( \alpha \lvert 1 \rangle _{b} + \beta \lvert 0 \rangle _{b}) \\
&+
\lvert 1 \rangle _{a} \lvert 0 \rangle _{a} ( \alpha \lvert 0 \rangle _{b} - \beta \lvert 1 \rangle _{b} )\\
& +
\lvert 1 \rangle _{a} \lvert 1 \rangle _{a} ( \alpha \lvert 1 \rangle _{b} - \beta \lvert 0 \rangle _{b}) )
\end{aligned} \right.
\end{equation}$

### 2.4.4 量子ゲート操作を転送する

* ゲートテレポーテーション
* 測定型量子計算

```python
gate_qtelep=T(0)*CNOT(1,0)*Tdg(0)*T(0)*CNOT(0,1)
represent(gate_qtelep,nqubits=2)
```

```python
CircuitPlot(gate_qtelep,nqubits=2)
```

```python
matrix_to_qubit(represent(gate_qtelep*H(0)*Qubit('00'),nqubits=2))
```

```python
matrix_to_qubit(represent(gate_qtelep*H(0)*Qubit('10'),nqubits=2))
```

CNOT（の制御側）と T の交換

```python
t_cnot=CNOT(0,1)*T(0)
represent(t_cnot,nqubits=2)
```

```python
cnot_t=T(0)*CNOT(0,1)
represent(t_cnot,nqubits=2)
```

$ TXT^{\dagger} $の計算

```python
TXTdg = T(0)*X(0)*Tdg(0)
represent(exp(I*pi/4)*TXTdg, nqubits=1)
```

```python
represent(S(0)*X(0), nqubits=1)
```

Controlled- SX

```python
def CSX(c,t):  return CNOT(c,t)*CGateS([c],S(t))
represent(CSX(1,0),nqubits=2)
```

```python
CircuitPlot(CSX(1,0),nqubits=2)
```

```python
gate_qtelep2 = CSX(1,0)*T(0)*CNOT(0,1)
represent(gate_qtelep2,nqubits=2)
```

```python
gate_qtelep3 = CSX(1,0)*CNOT(0,1)*T(0)
represent(gate_qtelep3,nqubits=2)
```

（参考）6.4.4 魔法状態（magic state）とは？

$ \displaystyle \lvert T _{L} \rangle = T \lvert + _{L} \rangle = e^{-i\frac{\pi}{8} Z} \frac{1}{\sqrt{2}} ( \lvert 0 _{L}\rangle + \lvert 1 _{L} \rangle )$

魔法状態蒸留（magic state distillation）… 十分にエラーの少ない $ \lvert T _ {L} \rangle $ を作り出す方法

１回の魔法状態上流に少なくとも、15量子ビットの補助量子ビットが必要。$l$回行うと$15^{l}$個の補助量子ビットが必要で、それだけかけて、精度の高い１論理 T ゲート（正確には「魔法状態」）が作り上げられる。

```python

```


```python
# 그래프, 수학 기능 추가
# Add graph and math features
import pylab as py
import numpy as np
import numpy.linalg as nl
# 기호 연산 기능 추가
# Add symbolic operation capability
import sympy as sy

```

# 파이썬에서의 선형대수 : 표준 기능과 사이파이 계열의 넘파이
Linear Algebra in Python: Standard Library and NumPy of SciPy Stack

(Karlijn Willems, SciPy Cheat Sheet: Linear Algebra in Python, DataCamp, 2017/02/07, https://www.datacamp.com/community/blog/python-scipy-cheat-sheet)

(Wikipedia contributors. Linear algebra. Wikipedia, The Free Encyclopedia. August 2, 2018, 17:17 UTC. Available at: https://en.wikipedia.org/w/index.php?title=Linear_algebra&oldid=853134963. Accessed August 11, 2018. )

**선형 대수**란 간단히 말하면 벡터와 행렬에 관한 수학의 한 분야이다.

In short, **Linear Algebra** is an area of mathematics about vectors and matrices.

파이썬 프로그래밍 언어의 기본 기능만으로도 선형 대수 문제를 해결하는 것이 가능은 하나, 보다 효율을 높이기 위해, 1990년대 이후, 여러 개발자들의 공헌으로 [**사이파이** 계열 확장 모듈](https://www.scipy.org/stackspec.html)을 개발하였다.

We can solve linear algebra with default features of python. However, to make it more efficient, since 1990's, a group of community developers contributed in developing [**SciPy** stack](https://www.scipy.org/stackspec.html).

## 표준 라이브러리
Standard Library

아래는 (3차원) 벡터 합을 파이썬 3.6 의 기본 기능만으로 구현한 예이다.

Following is an example of adding two (3D) vectors using only default features of python 3.6.

```python
def add_two_vectors(a, b):

    result = []

    for ai, bi in zip(a, b):
        print(f"zip : ai={ai}, bi={bi}")
        result.append(ai + bi)

    return result

```

```python
def add_two_vectors_index(a, b):

    result = [0.0] * len(a)

    for index in range(len(a)):
        print(f"index : a[{index}]={a[index]}, b[{index}]={b[index]}")
        result[index] = a[index] + b[index]

    return result

```

```python
def add_two_vectors_list_comprehension(a, b):
    return [(ai + bi) for ai, bi in zip(a, b)]

```

```python
x = [1, 2, 3]
y = [0.1, 0.2, 0.3]

print('result =', add_two_vectors(x, y))
print('result (index) =', add_two_vectors_index(x, y))
print('result (list comprehension) =', add_two_vectors_list_comprehension(x, y))

```

## 사이파이 계열의 넘파이
NumPy of SciPy Stack

### ` numpy.ndarray`

아래는 같은 연산을 사이파이 `scipy` 계열의 넘파이 `numpy`로 구현한 것이다.

Following implements the same operation in `numpy` of `scipy` stack.

```python
x = np.array([1, 2, 3])
y = np.array((0.1, 0.2, 0.3))

print(f'x = {x}')
print(f'y = {y}')

print('result =', x + y)
print('type(x) =', type(x))

```

행렬 연산에도 `numpy.ndarray` 를 사용할 수 있다.
We can use `numpy.ndarray` for matrix operations.

```python
A = np.array(
    [
        [1, 2],
        (3, 4),
    ]
)
B = A * 0.1
print('B=\n', B)
C = A + B
print('C=\n', C)

```

특히, **Python 3.5, Numpy 1.10 이상**에서는 **`@` 연산자**를 행렬 곱셈에 사용할 수 있다.

Especially for **Python 3.5 or higher and Numpy 1.10 or higher**, we can use **`@` operator** to multiply two matrices.

*내적*
*Inner product*

```python
theta_a_deg = 30
theta_a_rad = np.deg2rad(theta_a_deg)
theta_b_deg = 120
theta_b_rad = np.deg2rad(theta_b_deg)

a_row_array = np.array([np.cos(theta_a_rad), np.sin(theta_a_rad)])
b_row_array = np.array([np.cos(theta_b_rad), np.sin(theta_b_rad)])

```

```python
py.plot((0, a_row_array[0]), (0, a_row_array[1]), '-', label='a')
py.plot((0, b_row_array[0]), (0, b_row_array[1]), '-', label='b')
py.grid(True)
py.legend(loc=0)
py.axis('equal')
py.show()

```

```python
print('a dot b (array) =', np.dot(a_row_array, b_row_array))
print('a_row_array @ b_row_array =', a_row_array @ b_row_array)

```

*행렬 곱셈*
*Matrix multiplication*

```python
theta_a_deg = 30
theta_a_rad = np.deg2rad(theta_a_deg)
c = np.cos(theta_a_rad)
s = np.sin(theta_a_rad)
rotation_array = np.array(
    [
        [c, -s],
        [s, c],
    ]
)

x_unit_array = np.array([1, 0])

rotated_x_unit_array = np.dot(rotation_array, x_unit_array)
print('rotated x =\n', rotated_x_unit_array)
print('rotation_array, x_unit_array =\n', rotation_array @ x_unit_array)

```

```python
py.plot((0, x_unit_array[0]), (0, x_unit_array[1]), '-', label='x_unit')
py.plot((0, rotated_x_unit_array[0]), (0, rotated_x_unit_array[1]), '-', label='rotated_x_unit')
py.grid(True)
py.legend(loc=0)
py.axis('equal')
py.show()

```

### `numpy.matrix`

때에 따라 `numpy.matrix` 를 쓰는 경우가 있다.

Depending on the situations, we may use `numpy.matrix`.

*내적*
*Inner product*

```python
theta_a_deg = 30
theta_b_deg = 120
a_row_matrix = np.matrix([[np.cos(np.deg2rad(theta_a_deg)), np.sin(np.deg2rad(theta_a_deg))]])
b_row_matrix = np.matrix([[np.cos(np.deg2rad(theta_b_deg)), np.sin(np.deg2rad(theta_b_deg))]])
print('a dot b (matrix)=', a_row_matrix * b_row_matrix.T)

```

*행렬 곱셈*
*Matrix multiplication*

```python
theta_a_deg = 30
theta_a_rad = np.deg2rad(theta_a_deg)
c = np.cos(theta_a_rad)
s = np.sin(theta_a_rad)
rotation_matrix = np.matrix(
    [
        [c, -s],
        [s, c],
    ]
)

x_unit_matrix_column = np.matrix([1, 0]).T

rotated_x_unit_matrix = rotation_matrix * x_unit_matrix_column
print('rotated x =\n', rotated_x_unit_matrix)

```

도전과제 : `@` 연산자를 이용하여 다음 $3\times3$ 행렬과 $3\times1$ 열벡터의 곱셈을 계산하고 자신의 계산 결과와 비교하시오
Using the `@` operator, multiply following $3\times3$ matrix and $3\times1$ column vector and compare with your own calculation.

$$
A = \begin{bmatrix}
    6 & -2 & 1 \\
    -2 & 3 & 2 \\
    1 & 2 & 3
\end{bmatrix}
$$

$$
x = \begin{pmatrix}
    1 & 2 & 1
\end{pmatrix}^T
$$

$$
y=Ax
$$

## Final Bell
마지막 종

```python
# stackoverfow.com/a/24634221
import os
os.system("printf '\a'");

```

```python

```


```python
from sympy import symbols,solve
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
from IPython.core.display import HTML
import urllib.request
```

```python
HTML(urllib.request.urlopen('http://metrprof.xyz/metr4323.css').read().decode())
#HTML( open('metr4323.css').read() ) #or use this, if you have downloaded metr4233.css to your computer
```

# Symbolic Math with Python
v1.311, 26 February 2018, by Brian Fiedler
$\newcommand{\V}[1]{\vec{\boldsymbol{#1}}}$
$\newcommand{\I}[1]{\widehat{\boldsymbol{\mathrm{#1}}}}$
$\newcommand{\pd}[2]{\frac{\partial#1}{\partial#2}}$
$\newcommand{\pdt}[1]{\frac{\partial#1}{\partial t}}$
$\newcommand{\ddt}[1]{\frac{\D#1}{\D t}}$
$\newcommand{\D}{\mathrm{d}}$
$\newcommand{\Ii}{\I{\imath}}$
$\newcommand{\Ij}{\I{\jmath}}$
$\newcommand{\Ik}{\I{k}}$
$\newcommand{\del}{\boldsymbol{\nabla}}$
$\newcommand{\dt}{\cdot}$
$\newcommand{\x}{\times}$
$\newcommand{\dv}{\del\cdot}$
$\newcommand{\curl}{\del\times}$
$\newcommand{\lapl}{\nabla^2}$

Demonstrates using `sympy` to solve for the mysterious coefficients we see in the Adams-Bashforth schemes and the advection schemes.

## Simple examples of solving linear equations

`symbols`: The names on the left will be the Python names of a variable, the symbols on the right will be what is printed. It is a good idea to keep them the same...

```python
z = 7 # traditional python variable assignment
p = symbols('q') # a bad idea to label the symbol object other than 'p'

print("any suprises here?")
print(type(z),z)
print(type(p),p)
```

    any suprises here?
    <class 'int'> 7
    <class 'sympy.core.symbol.Symbol'> q

```python
# The "= 0" is assumed in the equation that we solve:
solve(p/2 -1)
```

    [2]

```python
# There was only one variable, so we didn't see the name. Show name of what we solved for:
solve(p/2 -1, dict=True)
```

    [{q: 2}]

### Two independent equations, two unknowns

```python
# Normally, you should make the python variable name the same as the printed name.
x,y = symbols('x,y')
```

Let's solve these for $x$ and $y$:
$$x-y+1=0$$
$$x+4y-5=0$$
In `solve`, the equations that we are solving don't need the "= 0".  That is assumed.
Because we are solving for two unknowns, we get the answer as a python dictionary. `dict=True` is the default.

```python
solve( [ x-y+1, x+4*y-5] , [x,y] )
```

    {y: 6/5, x: 1/5}

### Three dependent equations

A system of linear equations may have an infinite number of solutions if the equations are not independent.

```python
x,y,z = symbols('x,y,z')
```

```python
solve( [ x + 2*y + 3*z - 4, 5*x + 6*y +7*z -8 , 9*x + 10*y + 11*z - 12 ], [x,y,z] )
```

    {y: -2*z + 3, x: z - 2}

### Inconsistent equations

A system of linear equations may have no solution. For example, equations for two lines that do not intersect.

```python
x,y = symbols('x,y')
```

```python
solve( [ x-y+1, x-y+2] , [x,y] )
```

    []

##  Deriving third-order upwind advection

In `AdvectionPDE1d.ipynb`,  we found that the derivative $\pd{f}{x}$ used in an equation like:
$$
\pdt{f} = -u\pd{f}{x}
$$
could be estimated in a variety of ways.  Those we mentioned were "second-order centered", "first-order upwind" and
"third-order upwind".

Here we will derive the "third-order upwind" scheme for $\pd{f}{x}$. As for the claim of being "third-order"  we will note that the derivative is estimated from a third-order polynomial, fit to 4 discrete points of $f$. It is "upwind" because two points upwind of $\pd{f}{x}$ are used, and one point downwind.

We attempt to fit:
$$ f(x) = f(0) + a \frac{x}{\delta} +b \frac{x^2}{\delta^2}
+c \frac{x^3}{\delta^3} $$

If we can find $a$, $b$ and $c$ that fit the three neighboring points, then
$f'(0) = a/\delta$ may be suitable for the derivative we need in an advection scheme.

$$f(\delta) = f(0) +a +b + c $$

$$f(-\delta) = f(0) - a + b - c $$

$$f(-2\delta) = f(0) - 2a + 4b - 8c $$

```python
f0,fp1,fm1,fm2,a,b,c = symbols('f0,fp1,fm1,fm2,a,b,c')
# fm1 is "f at minus 1 delta", fp1 is "f at plus 1 delta", and so on
```

```python
# the variable names np1, nm1, nm2 are the names of "expression objects":
np1 = f0 + a + b + c - fp1
nm1 = f0 -a + b - c - fm1
nm2 = f0 -2*a + 4*b - 8*c -fm2
soln = solve([np1,nm1,nm2],[a,b,c]) # "expression objects" are set equal to zero to be "equations"
soln
```

    {b: -f0 + fm1/2 + fp1/2,
     c: -f0/2 + fm1/2 - fm2/6 + fp1/6,
     a: f0/2 - fm1 + fm2/6 + fp1/3}

So seeing the solution for $a$ above:
$$ f'(0) = \frac{a}{\delta} = \frac{1}{6\delta} \left[ f(-2\delta) -6f(-\delta) + 3 f(0) + 2 f(\delta) \right] $$

You should now be able to see where this python code for third-order upwind advection comes from:

`dbdx[2:-2] = (b[:-4]  - 6*b[1:-3] + 3*b[2:-2] + 2*b[3:-1])/(6*dx)`

#### Example of the "fit" provided by the polynomial

What is the fitted polynomial doing for us? Let's do an example with specific values of $f$ at the four points: an upside-down V or "spike".

```python
from collections import OrderedDict # if you want OrderedDict instead of dict
```

```python
fs = [fm2, fm1, f0, fp1] # list of our symbols
vals = [0,1,2,1] # the values of f showing the spike
spike = OrderedDict(zip(fs,vals))# associate symbols with specific values
#spike= dict(zip(fs,vals)) # this works too
print(spike)
```

    OrderedDict([(fm2, 0), (fm1, 1), (f0, 2), (fp1, 1)])

```python
# Now substitute in those specific values of fm2,fm1,f0,fp1 to
# get numbers for a,b,c
coefs={} # initialize empty dict
for key in soln:
    coefs[key] = soln[key].subs(spike) # subs means subsitute
print(coefs)
```

    {b: -1, c: -1/3, a: 1/3}

In this example $\delta=1$. For the spike, we find $a=\frac{1}{3}$.  So "third-order upwind" estimate is $f'(0)=\frac{1}{3}$

Let's use those coefficients, specific to this "spike" example, to plot the fitted function, and maybe see where this estimate comes from.

```python
xa = np.linspace(-2,1,31) # this is the range of x/delta for the plot
xa
```

    array([-2. , -1.9, -1.8, -1.7, -1.6, -1.5, -1.4, -1.3, -1.2, -1.1, -1. ,
           -0.9, -0.8, -0.7, -0.6, -0.5, -0.4, -0.3, -0.2, -0.1,  0. ,  0.1,
            0.2,  0.3,  0.4,  0.5,  0.6,  0.7,  0.8,  0.9,  1. ])

```python
# this is the fitted f(x)
f = spike[f0] + coefs[a]*xa + coefs[b]*xa**2 + coefs[c]*xa**3
f
```

    array([0, 0.0429999999999997, 0.104000000000000, 0.181000000000000,
           0.272000000000000, 0.375000000000000, 0.488000000000000,
           0.609000000000000, 0.736000000000000, 0.867000000000000,
           1.00000000000000, 1.13300000000000, 1.26400000000000,
           1.39100000000000, 1.51200000000000, 1.62500000000000,
           1.72800000000000, 1.81900000000000, 1.89600000000000,
           1.95700000000000, 2, 2.02300000000000, 2.02400000000000,
           2.00100000000000, 1.95200000000000, 1.87500000000000,
           1.76800000000000, 1.62900000000000, 1.45600000000000,
           1.24700000000000, 1.00000000000000], dtype=object)

```python
plt.plot(xa,f,'g-')
plt.plot([-2,-1,0,1],vals,'ro');
```

Well, so what?
You should be able to see by inspection of the above spike that a "second-order centered" scheme would produce $f'(0)=0$,
and the "first-order upwind" scheme produces $f'(0)=1$.  We haven't shown that the above third-order "fit" of $f'(0)=\frac{1}{3}$ is necesarily "better" than other alternatives when used in an advection scheme. In METR 4323, the proof about being "better" is shown by experiments.

## Adams-Bashforth time step

The universal forecast scheme is (trivially):
$$f(t+\delta) = f(t) + \int_t^{t+\delta} f'(s) ds = f(t) + \delta \frac{1}{\delta}\int_t^{t+\delta} f'(s) ds $$
On this side of the pond, the $s$ is called a *dummy variable* for $t$.
Needless to say, knowing $f'(t)$ in the future is problematic, because we don't know the
future.  The simplest scheme is to assume $f'(s)$ will be $f'(t)$. That is the Euler scheme.

It may be helpful to denote the average value of $f'(t)$ over the next time step as:
$$ \overline{f'(t)} = \frac{1}{\delta}\int_t^{t+\delta} f'(s) ds $$

So our universal forecast scheme is also denoted:

$$f(t+\delta) = f(t) + \delta \overline{f'(t)} $$

Let's make a better estimate of $f'(t)$ in the near future. Let's call the current time $t=0$.
We seek $a$ and $b$ in
$$ f'(t)=f'(0)+a\frac{t}{\delta}+ b\frac{t^2}{\delta^2}$$
where $a$ and $b$ are determined by the requirement for $f'(t)$ to also fit the
values of $f'(t)$ in the previous time steps:
$$ f'(-\delta) = f'(0) - a + b$$
$$ f'(-2\delta) = f'(0) - 2a + 4b$$

The average value of $f'(t)$ between $t=0$ and $t=\delta$ is thus anticpated to be:
$$\overline{f'(t)} =
\frac{1}{\delta}\int_0^\delta
\left( f'(0)+ a\frac{s}{\delta}+ b \frac{s^2}{\delta^2} \right)ds
 =\frac{1}{\delta}
 \left[ f'(0)s +
 \frac{1}{2} a\frac{s^2}{\delta}
 + \frac{1}{3} b \frac{s^3}{\delta^2}\right]_0^\delta
 =f'(0)+ \frac{1}{2} a + \frac{1}{3} b$$

We next use `sympy` to find $a$ and $b$ in terms of $f'(0)$,  $f'(-\delta)$ and  $f'(-2\delta)$.

```python
fp0,fpm1,fpm2,a,b = symbols('fp0,fpm1,fpm2,a,b')
```

```python
nm1 = fp0 -a + b - fpm1
nm2 = fp0 -2*a + 4*b - fpm2
ab =solve([nm1,nm2],(a,b))  # the solution
ab
```

    {b: fp0/2 - fpm1 + fpm2/2, a: 3*fp0/2 - 2*fpm1 + fpm2/2}

So here is $\overline{f'(t)}$ in terms of $f'(0)$, $f'(-\delta)$ and $f'(-2\delta)$:

```python
fp0+ab[a]/2+ab[b]/3
```

    23*fp0/12 - 4*fpm1/3 + 5*fpm2/12

You should see the similarity with our Python code for 3rd-order Adams-Bashforth:

`(23*dqdt[0]-16*dqdt[1]+5*dqdt[2])/12.`

# Fifth-order upwind advection

$$ f(x) = f(0) + a X + b X^2 + c X^3 + d X^4 + e X^5 $$
where $X \equiv x/\delta$.

We have values $f(-3\delta)$, $f(-2\delta)$, $f(-\delta)$,
$f(\delta)$ and $f(2\delta)$ to fit by finding the appropriate values for $a$, $b$, $c$, $d$ and $e$.

| $\LaTeX\qquad$ |`python` |
| --- | --- |
| $f(-3\delta)$ | f0 |
| $f(-2\delta)$ | f1 |
| $f(-1\delta)$ | f2 |
| $f(0)$ | f3 |
| $f(\delta)$ | f4 |
| $f(2\delta)$ | f5 |

```python
f0,f1,f2,f3,f4,f5,a,b,c,d,e = symbols('f0,f1,f2,f3,f4,f5,a,b,c,d,e')
```

```python
np2 = f3 + 2*a + 4*b + 8*c + 16*d + 32*e -f5
np1 = f3 + a + b + c + d + e - f4
nm1 = f3 -a + b - c + d - e - f2
nm2 = f3 -2*a + 4*b - 8*c + 16*d - 32*e - f1
nm3 = f3 -3*a + 9*b - 27*c + 81*d - 243*e - f0
solve([np2,np1,nm1,nm2,nm3],(a,b,c,d,e))
```

    {e: -f0/120 + f1/24 - f2/12 + f3/12 - f4/24 + f5/120,
     b: -f1/24 + 2*f2/3 - 5*f3/4 + 2*f4/3 - f5/24,
     c: f0/24 - 7*f1/24 + 7*f2/12 - 5*f3/12 + f4/24 + f5/24,
     a: -f0/30 + f1/4 - f2 + f3/3 + f4/2 - f5/20,
     d: f1/24 - f2/6 + f3/4 - f4/6 + f5/24}

$\frac{\partial b}{\partial x} = \frac{a}{\delta}$ can be used in an advection scheme. This is what python code might look like for $\frac{\partial b}{\partial x}$ in the 5th order upwind scheme:

`dbdx[3:-2] = (-2*b[:-5]  + 15*b[1:-4] - 60*b[2:-3] + 20*b[3:-2] + 30*b[4:-1] -3*b[5:0])/(60*dx)`

Note there are 3 points to the left, and 2 points to the right, of the point where we want the derivative to apply. This should be appropriate for flow from the left.

## Student Task 1: Fourth-order centered advection

This should be easy.  Let's just truncate the above 5th order analysis to 4th order.

$$ f(x) = f(0) + a X + b X^2 + c X^3 + d X^4 $$
where $X \equiv x/\delta$.

We have values $f(-2\delta)$, $f(-\delta)$,
$f(\delta)$ and $f(2\delta)$ to fit by finding the appropriate values for $a$, $b$, $c$ and $d$.

| $\LaTeX\qquad$|`python` |
| --- | --- |
| $f(-2\delta)$ | f1 |
| $f(-1\delta)$ | f2 |
| $f(0)$ | f3 |
| $f(\delta)$ | f4 |
| $f(2\delta)$ | f5 |

**STUDENTS:** finish the sympy stuff for the 4th order scheme:

# Student Task 2: Implement the 4th and 5th order advection schemes

Make a copy of your `AdvectionPDE1d.ipynb` into something like `MoreAdvection.ipynb`. Modify the new notebook to include options for `advord=4` and `advord=5`.  Do some experiments to make pretty plots comparing the 1 thru 5 schemes.  I suggest you use N=101 points.

```python

```


\begin{equation}
\omega = \Omega\, T_s
\end{equation}

\begin{equation}
\omega = \frac{2\pi k}{N},\; k=0, \dotsc, N-1
\end{equation}

\begin{equation}
X_d(\omega) = \frac{1}{T_s} \sum_{k=-\infty}^\infty X_c\biggl(\frac{\omega - 2\pi k}{T_s}\biggr)
\end{equation}

\begin{equation}
\text{rect}(t) =
\begin{cases}
1 & |t| \leq \frac{1}{2} \\
0 & \text{otherwise}
\end{cases}
\end{equation}

\begin{equation}
\text{sinc}(x) =
\begin{cases}
1 & x = 0 \\
\frac{\sin x}{x} & \text{otherwise}
\end{cases}
\end{equation}

\begin{equation}
\text{rect(t)} \overset{\mathfrak{F}}{\longleftrightarrow} \text{sinc}(\Omega/2)
\end{equation}

\begin{equation}
x(at) \overset{\mathfrak{F}}{\longleftrightarrow} \frac{1}{|a|} X_c\biggl(\frac{\Omega}{a}\biggr)
\end{equation}

```python
%matplotlib notebook
```

```python
import numpy as np
import scipy.fft
import scipy.special
import matplotlib.pyplot as plt

from pyinverse.axis import RegularAxis, FFTRegularAxis
```

```python
a = 3.4
N = 129
```

```python
axis_t = RegularAxis.linspace(-1, 1, N)
```

```python
def rect(t):
    y = np.zeros_like(t)
    y[np.abs(t) < 0.5] = 1
    y[np.abs(t) == 0.5] = 0.5
    return y
```

```python
x = rect(axis_t.centers * a)
```

```python
fig, ax = plt.subplots()
ax.plot(axis_t.centers, x, marker='.')
xmin, xmax = ax.get_xlim()
ax.plot([xmin, -1/(2*a)], [0, 0], c='C3')
ax.plot([-1/(2*a), -1/(2*a)], [0, 1], c='C3')
ax.plot([-1/(2*a), 1/(2*a)], [1, 1], c='C3')
ax.plot([1/(2*a), 1/(2*a)], [1, 0], c='C3')
ax.plot([1/(2*a), xmax], [0, 0], c='C3')
ax.set_xlabel(r'$t$ (s)')
ax.set_title(r'rect($t$)');
```

    <IPython.core.display.Javascript object>

```python
axis_Omega, x_Omega = axis_t.spectrum(x)
```

```python
assert np.allclose(np.max(np.abs(np.imag(x_Omega))), 0)
```

```python
x_Omega = np.real(x_Omega)
```

```python
axis_Omega2, x_Omega2 = axis_Omega.increasing(x_Omega)
```

```python
# note that np.sinc is sin(pi x) / (pi x), which is different than sinc at the beginning of this notebook
x_Omega_theory = np.sinc(axis_Omega.centers/(2*np.pi)/a)/np.abs(a)
x_Omega_theory2 = np.sinc(axis_Omega2.centers/(2*np.pi)/a)/np.abs(a)
```

```python
fig, ax = plt.subplots()
ax.plot(axis_Omega2.centers/(2*np.pi), x_Omega2, marker='+', label='grid.ft')
ax.plot(axis_Omega2.centers/(2*np.pi), x_Omega_theory2, marker='.', label='theory')
ax.legend()
ax.set_xlabel(r'$f$ (Hz)')
ax.set_title(r'Aliasing causes differences');
```

    <IPython.core.display.Javascript object>

```python
axis_ispectrum_theory, x_ispectrum_theory = axis_Omega.ispectrum(x_Omega_theory)
```

```python
np.max(np.abs(np.imag(x_ispectrum_theory)))
```

    0.0

```python
fig, ax = plt.subplots()
ax.plot(axis_t.centers, x, marker='.', label='truth')
ax.plot(axis_ispectrum_theory.centers, x_ispectrum_theory, marker='.', label='ift of theory', zorder=-10)
ax.plot([xmin, -1/(2*a)], [0, 0], c='C3')
ax.plot([-1/(2*a), -1/(2*a)], [0, 1], c='C3')
ax.plot([-1/(2*a), 1/(2*a)], [1, 1], c='C3')
ax.plot([1/(2*a), 1/(2*a)], [1, 0], c='C3')
ax.plot([1/(2*a), xmax], [0, 0], c='C3')
ax.set_xlabel('t (s)')
ax.legend();
```

    <IPython.core.display.Javascript object>

## Gibbs phenomena

The oscillations and overshoot/overshoot observed are examples of Gibbs phenomena.

What we have computed in `x_ift_theory` is the inverse DFT (meant to approximate the inverse FT and is approximate because of aliasing and finite sampling) of a frequency windowed sinc. Let us go through the full derivation:

To begin, `x_ft_theory` are samples of the FT of $x(t) = \text{rect}(at)$ from $-f_n$ to $f_n$ where $f_n$ is the Nyquist frequency. We then have

\begin{equation}
w(t) * x(t) = \int_{-\infty}^{\infty} x(\tau)\, y(t - \tau)\, d\tau \overset{\mathfrak{F}}{\longleftrightarrow}  W(f)\, X(f)
\end{equation}

where $W(f) = \text{rect}(T_s f)$ and $f_N = f_s / 2 = 1 / (2T_s)$. The inverse Fourier transform of the frequency window is given by

\begin{equation}
w(t) = \text{sinc}\biggl(\frac{t}{T_s}\biggr) \frac{1}{T_s}
\end{equation}

Thus, we have

\begin{align}
y(t) &= \int_{-\infty}^\infty \text{sinc}\biggl(\frac{\tau}{T_s}\biggr) \frac{1}{T_s}\, \text{rect}[a(t-\tau)]\, d\tau \\
&= \frac{1}{\pi} \int_{\frac{\pi(2 a t - 1)}{2 a T_s}}^{\frac{\pi(2 a t + 1)}{2 a T_s}} \biggl(\frac{\sin x}{x}\biggr)\, dx \\
&= \frac{1}{\pi}S_i\biggl[\frac{\pi(2 a t + 1)}{2 a T_s}\biggr] - \frac{1}{\pi}S_i\biggl[\frac{\pi(2 a t - 1)}{2 a T_s}\biggr]
\end{align}
where $S_i$ is the sine integral function implemented by, e.g., `scipy.special.sici`.

```python
axis_t8 = axis_t.oversample(8)
```

```python
x_theory = 1/np.pi*scipy.special.sici(np.pi*(2*a*axis_t8.centers + 1)/(2*a*axis_t.T))[0] - 1/np.pi*scipy.special.sici(np.pi*(2*a*axis_t8.centers - 1)/(2*a*axis_t.T))[0]
```

```python
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(axis_t8, rect(a*axis_t8.centers), label=r'rect$(t)$', c='C0')
ax.scatter(axis_ispectrum_theory.centers, x_ispectrum_theory, marker='.', label='ift of theory', c='C1', zorder=-10)
ax.plot(axis_t8, x_theory, label='Theory (inverse FT of bandlimited sinc)', c='C2')
ax.set_xlabel(r'$t$')
ax.legend(bbox_to_anchor=(1.04,0.5), loc="center left", borderaxespad=0)
fig.tight_layout()
```

    <IPython.core.display.Javascript object>


# Introduction to symbols

```python
import warnings
warnings.filterwarnings('ignore')

from lambeq import AtomicType, BobcatParser, TensorAnsatz
from discopy import Dim

# Define atomic types
N = AtomicType.NOUN
S = AtomicType.SENTENCE

# Parse a sentence
parser = BobcatParser(verbose='suppress')
diagram = parser.sentence2diagram('John walks in the park')

# Apply a tensor ansatz
ansatz = TensorAnsatz({N: Dim(4), S: Dim(2)})
tensor_diagram = ansatz(diagram)
tensor_diagram.draw(figsize=(12,5), fontsize=12)
```

```python
tensor_diagram.free_symbols
```

    {John__n, in__s.r@n.r.r@n.r@s@n.l, park__n, the__n@n.l, walks__n.r@s}

Each symbol is associated with a specific size, which is defined from the applied ansatz.

```python
[(s, s.size) for s in tensor_diagram.free_symbols]
```

    [(walks__n.r@s, 8),
     (the__n@n.l, 16),
     (John__n, 4),
     (in__s.r@n.r.r@n.r@s@n.l, 256),
     (park__n, 4)]

For example, you see that preposition "in" has been assigned 256 dimensions, which is derived by multiplying the dimensions of each individual wire ($2 \cdot 4 \cdot 4 \cdot 2 \cdot 4$), nouns are assigned 4 dimensions, and the determiner 16 dimensions.

## Circuit symbols

```python
from lambeq import IQPAnsatz

iqp_ansatz = IQPAnsatz({N: 1, S: 1}, n_layers=1)
circuit = iqp_ansatz(diagram)
circuit.draw(figsize=(12,8), fontsize=12)
```

```python
[(s, s.size) for s in circuit.free_symbols]
```

    [(in__s.r@n.r.r@n.r@s@n.l_3, 1),
     (walks__n.r@s_0, 1),
     (park__n_0, 1),
     (John__n_0, 1),
     (in__s.r@n.r.r@n.r@s@n.l_0, 1),
     (in__s.r@n.r.r@n.r@s@n.l_2, 1),
     (park__n_2, 1),
     (park__n_1, 1),
     (John__n_2, 1),
     (the__n@n.l_0, 1),
     (in__s.r@n.r.r@n.r@s@n.l_1, 1),
     (John__n_1, 1)]

## From symbols to tensors

```python
from sympy import default_sort_key

parameters = sorted(tensor_diagram.free_symbols, key=default_sort_key)
```

We will use `numpy` arrays for the tensors, initialised randomly:

```python
import numpy as np

tensors = [np.random.rand(p.size) for p in parameters]
print(tensors[0])
```

    [0.69545749 0.70027778 0.79763124 0.40774577]

```python
tensor_diagram_np = tensor_diagram.lambdify(*parameters)(*tensors)
print("Before lambdify:", tensor_diagram.boxes[0].data)
print("After lambdify:", tensor_diagram_np.boxes[0].data)
```

    Before lambdify: John__n
    After lambdify: [0.69545749 0.70027778 0.79763124 0.40774577]

```python
result = tensor_diagram_np.eval()
print(result)
```

    Tensor(dom=Dim(1), cod=Dim(2), array=[13.4525461 , 12.83309003])

```python
result.array
```

    array([13.4525461 , 12.83309003])


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

# Efectos del lazo cerrado

## Objetivos
- Determinar la estabilidad de sistemas de lazo abierto y lazo cerrado.
- Verificar el efecto de cerrar un lazo de control sobre sistemas de  tiempo continuo.

## Lazo cerrado

Tomando como base el bucle típico de control de la siguiente figura.

Puede deducirse que la función de transferencia desde la **referencia** $R(s)$ hasta la **controlada** $C(s)$.

Observe que el error se define como:
\begin{equation}
E(s) = R(s) - H(s) C(s)
\end{equation}
La señal **controlada** corresponde a la transformación que realiza el **controlador**, el **actuador** y la **planta** sobre el **error**.
\begin{align}
\color{blue}{C(s)} &= \left (G_c(s)G_a(s)G_p(s)  \right )  E(s) \\
\color{blue}{C(s)} &= \left (G_c(s)G_a(s)G_p(s) \right ) \left ( \color{blue}{\color{blue}{R(s)}} - H(s) \color{blue}{C(s)} \right ) \\
\color{blue}{C(s)} &= \left (G_c(s)G_a(s)G_p(s) \right ) \color{blue}{\color{blue}{R(s)}} - \left (G_c(s)G_a(s)G_p(s)H(s) \right )\color{blue}{C(s)} \\
\end{align}
\begin{equation}
\color{blue}{C(s)} +  \left (G_c(s)G_a(s)G_p(s)H(s) \right )\color{blue}{C(s)}  =  \left (G_c(s)G_a(s)G_p(s) \right ) \color{blue}{R(s)}
\end{equation}
\begin{equation}
\color{blue}{C(s)} \left ( 1 +  G_c(s)G_a(s)G_p(s)H(s)\right ) =   \left (G_c(s)G_a(s)G_p(s) \right ) \color{blue}{R(s)}
\end{equation}

Así, la función de transferencia de lazo cerrado es:

\begin{equation}
\frac{C(s)}{R(s)} = \frac{G_c(s)G_a(s)G_p(s)}{1 + G_c(s)G_a(s)G_p(s)H(s)}
\end{equation}

Para efectos prácticos se reune en un solo modelo a los sistemas **Actuador** y **Planta**, pues estos dos sistemas conforman el **Proceso** que se debe controlar. Así, la función de transferencia de lazo cerrado será:

\begin{equation}
\frac{C(s)}{R(s)} = \frac{G_c(s)G_p(s)}{1 + G_c(s)G_p(s)H(s)}
\end{equation}

Tenga en cuenta que $G_p(s)$ incorpora las dinámicas de **Actuador** y **Planta**.

El rol del **Sensor** requiere respuestas "rápidas" y "precisas".

El sistema **Controlador** debe ser diseñado para lograr comportamientos deseados en el sistema en lazo cerrado, es decir, se moldea la forma de $C(s)$ a partir de $R(s)$ ajustando $G_c(s)$ de forma apropiada.

## Lazo abierto vs Lazo cerrado

Las características más relevantes de la respuesta transitoria de un sistema está relacionada con la ubicación de sus polos.

De las ecuaciones anteriores, puede observarse que los polos se reubican en lazo cerrado de acuerdo con el controlador. Para este analisis considere los modelos lineales definidos como divisiones de polinomios.

\begin{equation}
\frac{C(s)}{R(s)} = \frac{G_c(s)G_p(s)}{1 + G_c(s)G_p(s)H(s)}
\end{equation}

\begin{equation}
\frac{C(s)}{R(s)} = \frac{\frac{N_c(s)}{D_c(s)}\frac{N_p(s)}{D_p(s)}}{1 + \frac{N_c(s)}{D_c(s)}\frac{N_p(s)}{D_p(s)}H(s)}
\end{equation}

Considere que el sensor es perfecto, es decir, $H(s)=1$.

La función de transferencia de lazo cerrado es:

\begin{equation}
\frac{C(s)}{R(s)} = \frac{\frac{N_c(s)N_p(s)}{D_c(s)D_p(s)}}{\frac{D_c(s)D_p(s) + N_c(s)N_p(s)}{D_c(s)D_p(s)}} = \frac{N_c(s)N_p(s)}{D_c(s)D_p(s) + N_c(s)N_p(s)}
\end{equation}

Mientras la función de transferencia de lazo abierto es:

\begin{equation}
\frac{C(s)}{R(s)} = \frac{N_c(s)N_p(s)}{D_c(s)D_p(s)}
\end{equation}

Observe que los numeradores se mantienen, esto indica que los ceros del sistema en lazo cerrado son los mismos que en lazo abierto.

Observe que los denominadores cambian, esto indica que los polos del sistema en lazo cerrado cambian respecto al lazo abierto.

**Ejemplo**

Suponga un proceso modelado por:

$$G_p(s) = \frac{2}{4s - 3}$$
$$G_p(s) = \frac{-2/3}{\frac{-4}{3}s + \frac{3}{3}}$$
$$G_p(s) = \frac{-2/3}{\frac{-4}{3}s + 1}$$

y una estrategia de contro definida por:

$$G_c(s) = k_c$$

- ¿El sistema $G_p$ es estable?
- ¿Qué efecto tiene realimentar el sistema con el controlador definido?

En análisis se realizará a partir de las raíces del sistema teniendo en cuenta que la función de transferencia de lazo cerrado es:

\begin{equation}
\frac{C(s)}{R(s)} =  \frac{N_c(s)N_p(s)}{D_c(s)D_p(s) + N_c(s)N_p(s)}
\end{equation}

$$G_{LC}(s) = \frac{2k_c}{4s - 3 + 2k_c}$$

Polo en
$$4s-3+2k_c=0$$
$$s=\frac{3-2k_c}{4}$$

```python
# Se define la función de transferencia del proceso
Gp = control.tf(2, [4,-3])
Gp
```

$$\frac{2}{4 s - 3}$$

```python
# Se hallan los polos del proceso
polos = Gp.pole()
polos
```

    array([0.75])

```python
# Se hallan los ceros del proceso
ceros = Gp.zero()
ceros
```

    array([], dtype=float64)

```python
# Se grafica el mapa de polos y ceros
control.pzmap(Gp)
plt.grid(True)
```

- El sistema no tiene ceros.
- El sistema tiene un polo en $s = 0.75$.
- La respuesta dinámica del sistema está dominada por $e^{0.75t}$.
- Este sistema es inestable.

```python
# Se grafica la respuesta al escalón
ts = np.linspace(0, 20, 1000)
_, y = control.step_response(Gp, ts)
plt.plot(ts, y)
plt.grid(True)
```

Se tomarán distintos escenarios para $G_c(s) = k_c$.

```python
Gc1 = 0.01
Gc2 = 0.1
Gc3 = 1.5
Gc4 = 3
Gc5 = 5
Gc6 = 1000
Gc7 = 1.501
```

```python
# Caso 1
G_LC1 = control.feedback(Gc1*Gp,1)
_, y1 = control.step_response(G_LC1, ts)

G_LC1
```

$$\frac{0.02}{4 s - 2.98}$$

```python
# Caso 2
G_LC2 = control.feedback(Gc2*Gp,1)
_, y2 = control.step_response(G_LC2, ts)
G_LC2
```

$$\frac{0.2}{4 s - 2.8}$$

```python
# Caso 3
G_LC3 = control.feedback(Gc3*Gp,1)
_, y3 = control.step_response(G_LC3, ts)
G_LC3
```

$$\frac{3}{4 s}$$

```python
# Caso 4
G_LC4 = control.feedback(Gc4*Gp,1)
_, y4 = control.step_response(G_LC4, ts)
G_LC4
```

$$\frac{6}{4 s + 3}$$

```python
# Caso 5
G_LC5 = control.feedback(Gc5*Gp,1)
_, y5 = control.step_response(G_LC5, ts)
G_LC5
```

$$\frac{10}{4 s + 7}$$

```python
# Caso 6
G_LC6 = control.feedback(Gc6*Gp,1)
_, y6 = control.step_response(G_LC6, ts)
G_LC6
```

$$\frac{2000}{4 s + 1997}$$

```python
# Caso 7
G_LC7 = control.feedback(Gc7*Gp,1)
_, y7 = control.step_response(G_LC7, ts)
G_LC7
```

$$\frac{3.002}{4 s + 0.002}$$

```python
# Se grafica el mapa de polos y ceros para todos los escenarios
control.pzmap(Gp)
control.pzmap(G_LC1)
control.pzmap(G_LC2)
control.pzmap(G_LC3)
control.pzmap(G_LC4)
control.pzmap(G_LC5)
control.pzmap(G_LC6)
control.pzmap(G_LC7)
plt.grid(True)
```

```python
# Se grafica la respuesta al escalón para todos los escenarios
plt.plot(ts, y,ts, y1,ts, y2,ts, y3,ts, y4,ts, y5,ts, y6)
plt.legend(['Proceso',
            'k=' + str(Gc1),
            'k=' + str(Gc2),
            'k=' + str(Gc3),
            'k=' + str(Gc4),
            'k=' + str(Gc5),
            'k=' + str(Gc6),
            'k=' + str(Gc7)
           ])
plt.grid(True)
```

```python
# Se grafica la respuesta al escalón para los escenarios inestables
plt.plot(ts, y,ts, y1,ts, y2,ts, y3)
plt.legend(['Proceso',
            'k=' + str(Gc1),
            'k=' + str(Gc2),
            'k=' + str(Gc3)])
plt.grid(True)
```

```python
# La respuesta al escalón para los escenarios estables y el integrador
plt.plot(ts, y3,ts, y4,ts, y5,ts,y6,ts,y7)
plt.legend(['k=' + str(Gc3),
            'k=' + str(Gc4),
            'k=' + str(Gc5),
            'k=' + str(Gc6),
           'k=' + str(Gc7)])
plt.grid(True)
```

```python
# La respuesta al escalón para los escenarios estables y el integrador
plt.plot(ts, y4,ts, y5,ts,y6)
plt.legend(['k=' + str(Gc4),
            'k=' + str(Gc5),
            'k=' + str(Gc6)])
plt.grid(True)
```

**Observaciones**

- El proceso es de primer orden inestable.
- El controlador de modelo $G_c(s) = k_c$ fue capaz de estabilizar al sistema a partir de un valor determinado de $k_c$
- Una vez estable, el efecto de aumentar $k_c$ se aprecia en la reducción del tiempo de estabilización.

**Preguntas**
- ¿Puede identificar qué efecto tiene aumentar $k_c$ sobre el valor final de la señal controlada?
- ¿Puede identificar qué efecto tiene aumentar $k_c$ sobre el valor final de la señal de error?


# Control de la dirección

Hasta ahora, hemos tratado el robot como un sistema plano, considerando que se mueve siempre en línea recta. Para ello, hemos condensado los dos motores en uno solo, haciendo lo mismo con las ruedas. Esto presenta varios problemas, siendo el más evidente que el robot no puede aprovechar que tiene dos motores independientes para moverse en cualquier dirección. Incluso aunque el objetivo fuese moverse en línea recta, será difícil conseguirlo: si se utiliza un voltaje común para los dos motores, cualquier pequeña diferencia entre ellos (rozamiento, resistencia del bobinado, etc.) hará que giren a velocidades ligeramente diferentes, lo que provocará desviaciones en la trayectoria.

La solución más simple, que nos evita recurrir a un modelo completo en 3D, es introducir una segunda capa de control para la velocidad de giro alrededor del eje vertical. Necesitaremos entonces dos cosas: poder medir dicha velocidad, y saber cómo modificarla sin que se caiga el robot.

## Estimación de la velocidad de giro

Si queremos medir la velocidad de giro $\dot\phi$, tendremos que expresarla en función de variables que podamos medir. Lo más evidente es ponerla en función de las velocidades de los centros de las ruedas:

$$
    \dot\phi = \frac{\dot x_r - \dot x_l}{d}
$$

donde $\dot x_r$ y $\dot x_l$ son las velocidades de las ruedas derecha e izquierda, respectivamente, y $d$ es la distancia entre centros. Las velocidades de las ruedas, a su vez, se obtienen a partir de las velocidades de giro de los motores $\omega_r$ y $\omega_l$ de la manera siguiente:

$$
    \dot x_r = \left(\omega_r + \dot\theta\right)r \qquad \dot x_l = \left(\omega_l + \dot\theta\right)r
$$

Combinando ambas expresiones, se puede escribir la velocidad de giro como:

$$
    \dot\phi = \frac{r}{d}\left(\omega_r - \omega_l\right)
$$

Esta expresión sólo depende de las velocidades de giro de los motores, que se miden directamente en los *encoders*.

## Actuación sobre la velocidad de giro

Para modificar $\dot\phi$ habrá que alterar las velocidades de los motores. Con esto hay que tener cuidado, ya que se podría desequilibrar el robot, así que habrá que asegurar que el par resultante sobre el chasis no se vea modificado. El par total sobre el chasis será la suma de los pares de cada uno de los motores:

$$
    \tau_t = \tau_r + \tau_l
$$

Nuestro controlador LQR en 2D nos proporciona un par común para los dos motores, que es el que permite que el robot se mueva a la velocidad adecuada sin caerse. Si alteramos los pares de los motores, pero con cuidado de asegurar que su resultante no cambie, podremos hacer que el robot gire sin perder el equilibrio. Para ello, podemos definir un par de corrección $\Delta\tau$ tal que:

$$
    \tau_r = \tau_t + \Delta\tau \qquad \tau_l = \tau_t - \Delta\tau
$$

En teoría, para aplicar este $\Delta\tau$ habría que calcular a qué incremento de voltaje corresponde para cada motor, para su estado actual de velocidad y aceleración. En la práctica, modificando directamente el voltaje no se introduce un error tan grande como para que el LQR no pueda compensarlo, así que lo más sencillo es simplemente actuar sobre los voltajes de los motores de la siguiente manera:

$$
    V_r = V + \Delta V \qquad V_l = V - \Delta V
$$

donde $V$ es el voltaje común obtenido en el LQR, y $\Delta V$ es un ajuste de voltaje que vendrá determinado por el controlador de giro.

## Diseño del controlador

Como el estado que queremos controlar en este caso es estable, no es necesario recurrir a métodos avanzados, basta con utilizar un simple PI. Su entrada será el error en $\dot\phi$, y devolverá como salida una corrección de voltaje $\Delta V$, resultado de aplicar las ganancias correspondientes $K_p$ y $K_i$ al error y su integral. El error en la velocidad de giro se define como la diferencia entre la velocidad de giro deseada, $\dot\phi_d$, y la que tenemos realmente, $\dot\phi$, así que el controlador se puede resumir de la siguiente manera:

$$
\begin{align}
    \varepsilon &= \dot\phi_d - \dot\phi \\
    \Delta V &= K_p \varepsilon + K_i\int \varepsilon \mathrm dt
\end{align}
$$

La aplicación práctica es muy simple: se mide la velocidad de giro a partir de los *encoders*, se calcula el error respecto a la velocidad deseada, y se va acumulando su integral. Luego, se calcula el $\Delta V$ usando las ganancias del PI y se aplica al voltaje medio obtenido por el LQR, sumándolo al motor derecho y restándolo al izquierdo.


# Motivation

A coach has a set of tools he uses to develop a training plan and continually tailor it to the ever-evolving needs of his client. The tools among coaches across different resistance training disciplines tend to overlap almost completely. If it is possible to construct an expressive and comprehensive unified parameterization of the concepts these tools deal with, then a powerful analytical model of training becomes available to us.

# Metrics

- `AI`: Absolute Intensity. Independent of exercise variation being used, this is an absolute measure of intensity of load. If expressed as a percentage, $\text{AI} = \frac{\text{Load : kg}}{\text{1RM : kg}}$. Otherwise, AI specifies the weight of a training load.
- `AV`: Absolute Volume. Generally this is an expression of total work performed. For instance, if a trainee performs a total of 35 repetitions with a load of 205 kilograms, the absolute volume is $35 \cdot 205 = 7175 \text{kg}$.
- `INOL`: Intensity \* Number Of Lifts. Popularized by Hristo Hristov, this metric is commonly used to gauge the systemic stress induced by a training prescription, as well as to compare the relative stimulus incurred from multiple *groups* of sets with varying loading schemes and of varying quantities of repetitions. The name of this metric is a misnomer, as demonstrated by the non-linear denominator in the formula: $\text{INOL} = \frac{\text{Sets Performed} \cdot \text{Repetitions Per Set}}{100 - \text{(% Relative Intensity)} \cdot 100}$.
- `Intensity`: We use the term Intensity to refer to loads via various metrics. The most common unit is a percentage of one-repetition maximum load used, but may also be given as a RIR or RPE rating.
- `IA`: Intensity Amplitude. In performing similar or accessory exercises to a competition movement, a trainee will often be able to perform more or fewer repetitions of the variation than he would of the competition movement. Therefore IA is used to calculate RI, as it gives an indication of various factors like systemic stress (transitively via INOL). $\text{IA} = \frac{\text{Competition Movement 1RM}}{\text{Variation Movement 1RM}}$.
- `IM`: Intensity Modifier. Used for deloads or progressive overloads, often varying per microcycle, IM is a way of linearly scaling training loads for a set of training prescriptions.
- `Maximum Repetitions`: The quantity of repetitions of a movement a trainee may perform in order to reach failure.
- `ORM` / `1RM`: One-Repetition Maximum. The maximum load that may be used such that a set of one repetition has a RIR rating of zero.
- `RI`: Relative Intensity. $\text{RI} = \frac{\text{AI} \cdot \text{IM}}{\text{IA}}$.
- `REQ`: Repetition-Endurance Quotient. This is used as a linear gauge of how close to failure a set is. The formula is $\text{REQ} = \frac{\text{Repetitions Performed}}{\text{Maximum Repetitions}}$. This metric may be used to predict technical quality of repetitions in a set.
- `R-INOL`: Relative INOL. This is a somewhat well-known metric for scaling INOL ratings across different types of movements, so that their systemic stress can be compared and used to evaluate overall training stimulus. I argue that this system is too subjective and imprecise, varying excessively in precision from one trainee to another. A more empirical procedural method is used to derive overall stimulus needed to achieve a desired adaptive effect.
- `RIR`: Repetitions In Reserve. After completing a set, RIR denotes the quantity of *additional* repetitions that would be required to reach failure. For instance, if one is able to complete 10 repetitions, but only performs 6, the set has a RIR rating of 4.
- `RIR falloff`: Given a training prescription with at least the following constraints: `_xB @ L | XX:XX` (quantity of sets is not required), RIR falloff is an integer-valued function (typically linear) with real-valued result ($\text{RIR falloff} : \mathbb{N} \mapsto \mathbb{R}$) expressing how much RIR will decrease (or, perhaps usefully in the context of circuit training, increase) per set performed.
- `RV`: Relative Volume. This is generally an expression of work performed in multiples of a 1RM load, given by the equation $\text{RV} = \text{RI} \cdot \text{Total Repetitions Performed}$. For instance, if a trainee performs a total of 35 repetitions at 82.5% of 1RM, this may be expressed as a relative volume of $35 \cdot 0.825 = 28.875$.
- `SV`: Set Volume. Simply put, this metric denotes sets total sets performed of some group of movements over a certain period of time.

```
from typing import Callable, List, Tuple, Union
```

```
import numpy as np
import pandas as pd

columns = ["Movement", "Sets", "Repetitions", "Load (% 1RM)"]

def add_row(df: pd.DataFrame, row: List[Union[str, int, float]]) -> None:
  return df.append(dict(zip(columns, row)), ignore_index=True)

df = pd.DataFrame(columns=columns)

rows = [
  ["Squat", 3, 5, 0.8],
  ["Bench Press", 1, 15, 0.65],
  ["Bench Press", 1, 12, 0.675],
  ["Bench Press", 3, 10, 0.7],
  ["Front Squat", 4, 4, 0.875],
]

for row in rows:
  global df
  df = add_row(df, row)

df["INOL"] = df["Sets"] * df["Repetitions"] / (1 - df[r"Load (% 1RM)"]) / 100
df
```

      Movement
      Sets
      Repetitions
      Load (% 1RM)
      INOL

      0
      Squat
      3
      5
      0.800
      0.75

      1
      Bench Press
      1
      15
      0.650
      0.428571

      2
      Bench Press
      1
      12
      0.675
      0.369231

      3
      Bench Press
      3
      10
      0.700
      1

      4
      Front Squat
      4
      4
      0.875
      1.28

```
df_mvmt = df.groupby("Movement")
```

```
print("Total INOL")

df_mvmt.apply(lambda mvmt: sum(mvmt["INOL"]))
```

    Total INOL

    Movement
    Bench Press    1.797802
    Front Squat    1.280000
    Squat          0.750000
    dtype: float64

```
print("Set Volume")

df_mvmt.apply(lambda mvmt: sum(mvmt["Sets"]))
```

    Set Volume

    Movement
    Bench Press    5
    Front Squat    4
    Squat          3
    dtype: int64

```
print("Total Repetitions")

df_mvmt.apply(lambda mvmt: np.dot(mvmt["Sets"], mvmt["Repetitions"]))
```

    Total Repetitions

    Movement
    Bench Press    57
    Front Squat    16
    Squat          15
    dtype: int64

# One-Rep Maximum Formulas

In the following equations, $r$ denotes maximum number of repetitions possible and $w$ denotes weight of training load.

### Epley

$\text{1RM} = w \left( 1 + \frac{r}{30} \right)$

### Brzycki

$\text{1RM} = w \cdot \frac{36}{37 - r}$

### McGlothin

$\text{1RM} = \frac{100w}{101.3 - 2.67123r}$

### Lombardi

$\text{1RM} = w\sqrt[10]{r} = wr^{0.1}$

### Mayhew

$\text{1RM} = \frac{100w}{52.2 + 41.9e^{-0.055r}}$

### O'Conner

$\text{1RM} = w \left( 1 + \frac{r}{40} \right)$

### Wathen

$\text{1RM} = \frac{100w}{48.8 + 53.8e^{-0.075r}}$

```
import math
import sympy
from typing import Union

class Formula:

  o, w, r = sympy.symbols("o w r")

  @classmethod
  def calculate(cls,
                solve_for: sympy.Symbol,
                orm: float=None,
                weight: float=None,
                reps: float=None):
    if solve_for == cls.o:
      return sympy.solve(cls.zero.subs(cls.w, weight).subs(cls.r, reps), solve_for)[0]
    elif solve_for == cls.w:
      return sympy.solve(cls.zero.subs(cls.o, orm).subs(cls.r, reps), solve_for)[0]
    elif solve_for == cls.r:
      return sympy.solve(cls.zero.subs(cls.o, orm).subs(cls.w, weight), solve_for)[0]

  @classmethod
  def orm(cls, weight: float, reps: int) -> float:
    return cls.calculate(Formula.o, weight=weight, reps=reps)

  @classmethod
  def max_reps(cls,
               orm: float,
               weight: float,
               round_down=True) -> Union[int, float]:
    result = cls.calculate(Formula.r, orm=orm, weight=weight)
    return math.floor(result) if round_down else result

  @classmethod
  def rep_max(cls, orm: float, reps: int) -> float:
    return cls.calculate(Formula.w, orm=orm, reps=reps)

class ORM:
  """
  Calculators for one-repetition maximum and other associated measures.

  - `Formula.orm` takes in a weight used and number of repetitions performed and
  returns the one-repetition maximum.
  - `Formula.max_reps` takes in a one-repetition maximum and a weight to be used
  and returns the number of repetitions one may complete in a single set. Note
  that the return value is a float. The floor of this value must be taken to get
  a physically meaningful quantity.
  - `Formula.rep_max` takes in a one-repetition maximum and number of
  repetitions to be completed in a set and returns the maximum weight one may
  use.
  """

  class Epley(Formula):

    zero = Formula.w * (1 + Formula.r / 30) - Formula.o

  class Brzycki(Formula):

    zero = Formula.w * 36 / (37 - Formula.r) - Formula.o

  class McGlothin(Formula):

    zero = 100 * Formula.w / (101.3 - 2.67123 * Formula.r) - Formula.o

  class Lombardi(Formula):

    zero = Formula.w * (Formula.r ** 0.1) - Formula.o

  class Mayhew(Formula):

    zero = 100 * Formula.w / (52.2 + 41.9 * (math.e ** (-0.055 * Formula.r))) - Formula.o

  class OConner(Formula):

    zero = Formula.w * (1 + Formula.r / 40) - Formula.o

  class Wathen(Formula):

    zero = 100 * Formula.w / (48.8 + 53.8 * (math.e ** (-0.075 * Formula.r))) - Formula.o

```

```
from matplotlib import pyplot as plt

rep_upper_limit = 20

rep_range = range(1, rep_upper_limit + 1)

fig = plt.figure(facecolor="w")
plt.title("Response Profile of Popular 1RM Formulas")
plt.ylabel("Load (% 1RM)")
plt.xlabel("Repetitions performed")
plt.xticks(range(1, rep_upper_limit, 2))
plt.grid()

for formula in [var for var in vars(ORM) if not var.startswith("__")]:
  plt.plot(rep_range,
           [getattr(ORM, formula).rep_max(100, reps) for reps in rep_range],
           label=formula)

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

Given that the actual maximal % 1RM performed at 1 rep is going to be exactly 100%, we can rule out Mayhew's formula as probably less accurate than the others. And because McGlothin and Brzycki are so similar, and Brzycki produces the correct repetition maximum load at 1 rep, we can use Brzycki virtually any time McGlothin would work. We are left with the following formulas:

- Epley,
- O'Conner,
- Brzycki,
- Lombardi, and
- Wathen.

Anecdotally, Wathen seems to produce the most accurate predictions, with Lombardi finding some utility in isolation/single-joint movements.

***But how can we do better?***

Obviously the response profile is going to vary with each trainee and each movement performed.

### TODO: Explain the following (data acquisition methods, rationale for Lagrange polynomial interpolation attempt, explanation of reciprocal function being similar to Brzycki, Wathen, etc.), clean up names (`f_val` is pretty undescriptive).

```
f_val = {
    1: 100,
    2: 91.5,
    3: 92,
    5: 85,
    7: 86,
    10: 75,
}
```

```
from scipy.interpolate import lagrange

reps, intensities = zip(*[(r, f_val[r]) for r in f_val])
```

```
poly = lagrange(reps, intensities)
xs = range(1, 15)
ys = [poly(x) for x in xs]

fig = plt.figure(facecolor="w")
plt.title("Load Capacity Per Repetition Estimation by Lagrange Polynomial Interpolation")
plt.ylabel("Load (% 1RM)")
plt.xlabel("Repetitions performed")
plt.xticks(range(1, rep_upper_limit, 2))
plt.grid()

plt.scatter(reps, intensities) # Plot real data points.
plt.plot(xs, ys) # Plot interpolated curve.
plt.show()

print(f"1RM: {poly(1)}")
```

This is pretty terrible, as the extrapolation to the right of the data set is obviously impossible. Let's try fitting a reciprocal function to the dataset, using the function $f(r) = \frac{a}{r - b} + c$.

```
import numpy as np
from scipy.optimize import curve_fit
from random import randint
from sympy import symbols

def reciprocal_curve(x, a, b, c):
  # TODO: Prune f_val to make it prefer data points with lower reps (n-duplication).
  return abs(a) / (x + abs(b)) + abs(c)

initial_guesses = [0, 0, 0]
while True:
  try:
    popt, pcov = curve_fit(reciprocal_curve, reps, intensities, p0=initial_guesses)
  except RuntimeError:
    initial_guesses = [guess + randint(-1, 1) for guess in initial_guesses]
  else:
    break

curve = lambda x: reciprocal_curve(x, *popt)

fig = plt.figure(facecolor="w")
plt.title("Load Capacity Per Repetition Estimation by Reciprocal Fitting")
plt.ylabel("Load (% 1RM)")
plt.xlabel("Repetitions performed")
plt.xticks(range(1, rep_upper_limit, 2))
plt.grid()

plt.scatter(reps, intensities)
plt.plot(xs, curve(xs))
plt.show()

x = symbols("repetitions")

print(f"max_load(repetitions) = {curve(x)}")
```

That's a great result!

### TODO: Plug in real values from each other 1RM formula into the reciprocal optimizer, compare performance to them as $r$ changes.

### TODO: Explore elliptic curves as an alternative to reciprocal fitting.

### TODO: Add HNFM (Hanley Normalized Fatigue Metric) vs. INOL response curves and explanation of which is better than the other.

# Quantifying Systemic Fatigue

### TODO: Add introduction to this section.

## Intensity $\cdot$ Number of Lifts (INOL)

$\text{INOL}(\text{reps}, \text{intensity}) = \sum_{n=1}^N \frac{\text{reps}_n}{100 - \text{intensity}}$

## Hanley Normalized Fatigue Metric (HNFM)

$\text{HNFM}(\text{reps}, \text{intensity}) = \sum_{n=1}^N \text{reps}_n \cdot \left( \frac{100}{100 - \text{intensity}_n} \right)^2$

```
def INOL(reps: int, intensity: float) -> float:
  # Only calculates INOL for one set. Simply sum multiple results to get overall
  # INOL across sets.
  try:
    return reps / (100 - intensity)
  except ZeroDivisionError:
    return float("inf")

def HNFM(reps: int, intensity: float) -> float:
  # Only calculates HNFM for one set. Simply sum multiple results to get overall
  # HNFM across sets.
  try:
    return reps * ((100 / (100 - intensity)) ** 2)
  except ZeroDivisionError:
    return float("inf")

INOL_x = np.linspace(0, 20, 100)
INOL_y = np.linspace(55, 95, 100)
INOL_z = np.array([INOL(i, j) for i in INOL_x for j in INOL_y])
INOL_X, INOL_Y = np.meshgrid(INOL_x, INOL_y)
INOL_Z = INOL_z.reshape(100, 100)
fig, ax = plt.subplots(constrained_layout=True)
plt.title("INOL Response Profile")
plt.xlabel("Repetitions Performed")
plt.ylabel("Intensity (% 1RM)")
plt.grid()
CS = ax.contourf(INOL_X, INOL_Y, INOL_Z, levels=25)
fig.colorbar(CS)

HNFM_x = np.linspace(0, 20, 100)
HNFM_y = np.linspace(55, 95, 100)
HNFM_z = np.array([(lambda h : min([h, 1000]))(HNFM(i, j)) for i in HNFM_x for j in HNFM_y])
HNFM_X, HNFM_Y = np.meshgrid(HNFM_x, HNFM_y)
HNFM_Z = HNFM_z.reshape(100, 100)
fig, ax = plt.subplots(constrained_layout=True)
plt.title("HNFM Response Profile")
plt.xlabel("Repetitions Performed")
plt.ylabel("Intensity (% 1RM)")
plt.grid()
CS = ax.contourf(HNFM_X, HNFM_Y, HNFM_Z, levels=25)
fig.colorbar(CS)

plt.show()
```

# Prilepin's Chart of Training Volume

```
import pandas as pd

prilepin = pd.DataFrame({
    "Percent": [(55, 65), (70, 80), (80, 90), (90, 95)],
    "Reps/set": [(3, 6), (3, 6), (2, 4), (1, 2)],
    "Optimal": [24, 18, 15, 4],
    "Total Range": [(18, 30), (12, 24), (10, 20), (10,)]
})

prilepin
```

      Percent
      Reps/set
      Optimal
      Total Range

      0
      (55, 65)
      (3, 6)
      24
      (18, 30)

      1
      (70, 80)
      (3, 6)
      18
      (12, 24)

      2
      (80, 90)
      (2, 4)
      15
      (10, 20)

      3
      (90, 95)
      (1, 2)
      4
      (10,)

```
prilepin["INOL (Optimal)"] = tuple(map((lambda data: [round(data[0] / load, 2) for
                                                      load in
                                                      data[1]]),
                                       zip(prilepin["Optimal"],
                                           prilepin["Percent"])))

prilepin["INOL (Range)"] = tuple(map(lambda data: [[round(load / reps, 2) for load in data[1]] for reps in data[0]], zip(prilepin["Total Range"], prilepin["Percent"])))

prilepin
```

      Percent
      Reps/set
      Optimal
      Total Range
      INOL (Optimal)
      INOL (Range)

      0
      (55, 65)
      (3, 6)
      24
      (18, 30)
      (0.44, 0.37)
      [[3.06, 3.61], [1.83, 2.17]]

      1
      (70, 80)
      (3, 6)
      18
      (12, 24)
      (0.26, 0.23)
      [[5.83, 6.67], [2.92, 3.33]]

      2
      (80, 90)
      (2, 4)
      15
      (10, 20)
      (0.19, 0.17)
      [[8.0, 9.0], [4.0, 4.5]]

      3
      (90, 95)
      (1, 2)
      4
      (10,)
      (0.04, 0.04)
      [[9.0, 9.5]]

```

```


# 概率论和信息论笔记

## 常用概率分布

### Bernoulli分布

Bernoulli分布是单个二值随机变量的分布。它由单个参数$\phi\in[0,1]$控制，$\phi$给出了随机变量等于1的概率。它有如下一些性质：

\begin{align}
P(\text{x}=1)&=\phi \\
P(\text{x}=0)&=1-\phi \\
P(\text{x}=x)&=\phi^{x}(1-\phi)^{1-\phi} \\
\mathbb{E}_{\text{x}}[\text{x}]&=\phi \\
\text{Var}_{\text{x}}(\text{x})&=\phi(1-\phi)
\end{align}

### Multinoulli分布

Multinoulli分布或者**范畴分布**是指**在具有k个不同状态的单个离散型随机变量上的分布，其中k是一个有限值**。

Multinoulli分布通常用来表示对象分类的分布。我们通常不需要计算Multinoulli分布的期望和方差。

Bernoulli分布和Multinoulli分布走狗用来描述它们领域内的任何分布，因为它们领域很简单：**它们可以对那些能够将所有状态进行枚举的离散型随机变量进行建模**。

### 高斯分布或者正态分布

实数上最常用的分布就是**正太分布(normal distribution)**，也称为**高斯分布Gaussian distribution**。

公式如下：

$$\mathcal{N}(x;\mu,\sigma^2)=\sqrt\frac{1}{2\pi\sigma^2}\text{exp}(-\frac{1}{2\sigma^2}(x-\mu)^2)$$

高斯分布如下图所示：

我们用$\beta^{-1}$替代$\sigma^2$，表示**精度(precision)**，那么有：

$$\mathcal{N}(x;\mu,\beta^{-1})=\sqrt\frac{\beta}{2\pi}\text{exp}(-\frac{1}{2}\beta(x-\mu)^2)$$

当我们由于缺乏关于某个实数上分布的先验知识而不知道该选择怎样的形式时，正态分布是默认的比较好的选择，其中有两个原因。
* 我们想要建模的很多分布的真实情况是比较接近正态分布的，**中心极限定理（central limit theorem）**说明很多独立随机变量的和近似服从正态分布
* 在具有相同方差的所有可能的概率分布中， 正态分布在实数上具有最大的不确定性，因此，我们可以认为正态分布是对模型加入的先验知识量最少的分布

### 指数分布和Laplace分布

在深度学习中，我们经常会需要一个在 `x = 0` 点处取得**边界点 (sharp point)** 的分布。为了实现这一目的，我们可以使用**指数分布（exponential distribution）**：

$$p(x;\lambda)=\lambda\mathbb{1}_{x>=0} exp(-\lambda x)$$

其中，**指示函数(indicator function)**$\mathbb{1}_{x>=0}$使得$x$取负数的时候值为零。

一个联系紧密的概率分布是**Laplace 分布（Laplace distribution）**，它允许我们在任意一点 µ 处设置概率质量的峰值:

$$Laplace(x;\mu,\gamma)=\frac{1}{2\gamma}exp(-\frac{\vert{x-\mu}\vert}{\gamma})$$

## 常用函数的有用性质

### sigmoid函数

sigmoid函数常用来产生Bernoulli分布的参数$\phi$，因为它的范围是(0,1)，处在$\phi$的有效值范围。它的公式如下：

$$\sigma(x)=\frac{1}{1+e^{-x}}$$

### softplus函数

softplus 函数可以用来产生正态分布的 β 和 σ 参数，因为它的范围是 (0, $\infty$)。它的公式如下：

$$\zeta(x)=\log(1+e^x)$$

它的名称来源是因为它是另一个函数的“软化”，这个函数是：

$$x^+=max(0,x)$$

以下是一些很有用的性质：

\begin{align}
\sigma(x)&=\frac{1}{1+e^{-x}}=\frac{e^x}{e^x+e^0} \\
\frac{d}{d_x}\sigma(x)&=\sigma(x)(1-\sigma(x)) \\
1-\sigma(x)&=\sigma(-x) \\
\log(\sigma(x))&=-\zeta(-x) \\
\frac{d}{d_x}\zeta(x)&=\sigma(x) \\
\forall{x} \in (0,1),\sigma(x)^{-1}&=\log(\frac{x}{1-x}) \\
\forall{x}>0,\zeta(x)^{-1}&=\log(exp(x)-1) \\
\zeta(x)&=\int_{-\infty}^{+\infty}\sigma(y)d_y \\
\zeta(x)-\zeta(-x)&=x
\end{align}

函数$\sigma(x)^{-1}$在统计学中称为**分对数(logits)**，但是这个函数在机器学习里面很少用到。

## 贝叶斯法则

我们经常会需要在已知$P(y\vert x)$时计算$P(x\vert y)$。幸运的是，如果还知道$P(x)$，我们可以用**贝叶斯规则（Bayes’ rule）**来实现这一目的：

$$P(x\vert y)=\frac{P(x)P(y\vert x)}{P(y)}$$

其中,

$$P(y)=\sum_xP(y\vert x)P(x)$$

## 信息论

定义一个事件$\text{x}=x$的**自信息量(self-information)**：

$$I(x)=-\log P(x)$$

其中$\log$以e为底，$I(x)$的单位是**奈特(nats)**，$\log$以2为底，$I(x)$的单位是**比特(bit)或者香农(shannons)**。默认使用e为底。

自信息只处理单个的输出。我们可以用**香农熵（Shannon entropy）**来对整个概率分布中的不确定性总量进行量化：

$$H(x)=\mathbb{E}_{x\sim P}[I(x)]=-\mathbb{E}_{x\sim P}[\log P(x)]$$

也记做$H(P)$。

换言之，**一个分布的香农熵是指遵循这个分布的事件所产生的期望信息总量**。

如果我们对于同一个随机变量$x$有两个单独的概率分布$P(x)$ 和$Q(x)$，我们可以使用**KL 散度（Kullback-Leibler (KL) divergence）**来衡量这两个分布的差异：

$$D_{KL}(P\parallel Q)=\mathbb{E}_{x\sim P}[\log\frac{P(x)}{Q(x)}]=\mathbb{E}_{x\sim P}[\log P(x)-\log Q(x)]$$

一个和 KL 散度密切联系的量是**交叉熵（cross-entropy）**$H(P;Q) = H(P) +D_{KL}(P\parallel Q)$，它和KL散度很像但是缺少左边一项：

$$H(P,Q)=-\mathbb{E}_{x\sim P}\log Q(x)$$


Here we simulate an NMR experiment to illustrate simple quantum gates with two qubits. We start with an initial state, described by the density matrix in the logical (rotating) frame, apply some time evolution operators and than measure the magnetization to obtain a sample to which we can apply a Fourier transformation.
The molecule that we simulate is TCE, and we use the two carbons, with a Larmor frequency of 125 MHz and a chemical shift of 900 Hz. The coupling is 100 Hz (values taken from http://arXiv.org/abs/quant-ph/0207172v1). We set $\hbar = 1$.

```python
import numpy as np
import scipy.linalg
import matplotlib.pyplot as plt
# Conversion factor between frequencies in Hz and frequencies in rad/s
conv = 2*np.pi
# Larmor frequencies
w0 = 125000000*conv
w1 = (125000000+900)*conv
w12 = (100/(2*np.pi))*conv
```

First we need a function that initializes the density matrix. We simplify things a bit and start with a pseudo-pure state
$$
\rho = \frac{1}{4} (1-\epsilon) + \epsilon |0 \rangle \langle 0 |
$$

```python
####################################
# Initialize the density matrix    #
####################################
def initPseudoPureState(e=0.5):
    r = np.diag(np.ones(4)*(1-e)*0.25)
    r[0][0] += e
    return r
```

Next we need some standard time evolution operators. We start with the free evolution. The following function returns the Hamiltonian under free evolution in the logical frame. The chemical term is therefore zero, and the only term is the coupling term
$$
\frac{2 \pi}{\hbar} J_{12} I^1_z I^2_z = \pi \frac{\hbar}{2} J_{12} \sigma_z^1 \sigma_z^2
$$

```python
####################################
# Free evolution hamiltonian       #
# w12 - J-coupling                 #
####################################
def freeEvolutionHamiltonian():
    # The tensor product of the two sigma z Pauli matrices is of course
    # course diagonal with eigenvalues (+1, -1, -1, +1). We simply
    # multiply this by the constant
    return w12*0.5* np.pi * np.diag([1, -1, -1, +1])
```

The following function will, for a given Hamiltonian, calculate the time evolution for a time t, given by
$$
U(t) = \exp (- \frac{i}{\hbar} Ht)
$$

```python
###################################
# Time evolution operator         #
###################################
def timeEvolutionOperator(H, t):
    return scipy.linalg.expm(-1j*H*t)

```

Next, we need a function that simulates the evolution of the density matrix (again in the logical frame) under the time evolution operator U. This is simply given by conjugation with the time evolution operator

```python
###################################
# Evolve density matrix           #
###################################
def evolveDensityMatrix(r, U):
    return np.matmul(np.matmul(U, r), np.matrix(U).H)

def applyHamiltonian(r, H, t):
    return  evolveDensityMatrix(r, timeEvolutionOperator(H,t))
```

We will now implement a few standard one- and two-qubit operators. We start with rotations. A rotation on the Bloch sphere by an angle $\Theta$ around the x-axis is described by
$$
R_x(\Theta) = e^{-\frac{i}{\hbar}\Theta I_z} = e^{-\frac{1}{2} i \Theta \sigma_z}
$$
and similar for the y-axis. So we need the Pauli matrices
\begin{align}
\sigma_x &= \begin{pmatrix} 0 & 1 \\ 1 & 0 \end{pmatrix} \\
\sigma_y &= \begin{pmatrix} 0 & -i \\ i & 0 \end{pmatrix} \\
\sigma_z &= \begin{pmatrix} 1 & 0 \\ 0 & -1 \end{pmatrix}
\end{align}

```python
sx = np.array([[0, 1], [1, 0]])
sy = np.array([[0, -1j], [1j, 0]])
sz = np.array([[1, 0], [0, -1]])

#################################################
# Rotation around the x-axis                    #
#################################################
def rotX(theta, i):
    if (i == 0):
        return np.kron(scipy.linalg.expm(-0.5*1j*theta*sx), np.diag(np.ones(2)))
    return np.kron(np.diag(np.ones(2)), scipy.linalg.expm(-0.5*1j*theta*sx))

#################################################
# Rotation around the y-axis                    #
#################################################
def rotY(theta, i):
    if (i == 0):
        return np.kron(scipy.linalg.expm(-0.5*1j*theta*sy), np.diag(np.ones(2)))
    return np.kron(np.diag(np.ones(2)), scipy.linalg.expm(-0.5*1j*theta*sy))

#################################################
# Rotation around the z-axis                    #
#################################################
def rotZ(theta, i):
    if (i == 0):
        return np.kron(scipy.linalg.expm(-0.5*1j*theta*sz), np.diag(np.ones(2)))
    return np.kron(np.diag(np.ones(2)), scipy.linalg.expm(-0.5*1j*theta*sz))

```

Now we turn to measurements. First, we need to be able to extract the magnetization in the logical frame from a density matrix. We can do this by multiplying the density matrix with the components of the angular momentum and taking the trace.

```python
#################################################
# Determine magnetization                       #
#################################################
def getMagnetization(r):
    mx = np.zeros(2)
    my = np.zeros(2)
    Ix1 = 0.5* np.kron(sx, np.diag(np.ones(2)))
    Iy1 = 0.5* np.kron(sy, np.diag(np.ones(2)))
    Ix2 = 0.5* np.kron(np.diag(np.ones(2)), sx)
    Iy2 = 0.5* np.kron(np.diag(np.ones(2)), sy)
    mx[0] = np.real(np.trace(np.matmul(r, Ix1)))
    mx[1] = np.real(np.trace(np.matmul(r, Ix2)))
    my[0] = np.real(np.trace(np.matmul(r, Iy1)))
    my[1] = np.real(np.trace(np.matmul(r, Iy2)))
    return mx, my
```

Let us now try to understand the signal that a magnetization causes in a coil along the x-axis. We can calculate the signal induced in the coil (after pre-processing in a quadrature detection circuit) expressed in terms of the magnetization $\widetilde{m}$ in the logical frame as
$$
s(t) = \sum_i \omega_i (\widetilde{m}_x(t) + i \widetilde{m}_y(t)) e^{i \Omega^i t}
$$
where
$$
\Omega^i = \omega_{ref} - \omega_i
$$

```python
##########################################
# Calculate the signal                   #
##########################################
def signal(wref, mx, my, t):
    s = (mx[0] + 1j*my[0]) * np.exp(1j*(wref-w0)*t)
    s += (mx[1] + 1j*my[1]) * np.exp(1j*(wref-w1)*t)
    return s

#########################################
# Take a sample                         #
# dt - sample period                    #
# T - total duration of sampling        #
# wref - reference frequency            #
# r- initial density matrix             #
# tau - decay factor                    #
# J - set this to one to simulate       #
#     coupling                          #
#########################################
def sample(r, dt, T, wref, tau, J = 0):
    # Take samples
    y = []
    t = np.arange(0, T, dt)
    decay = 1
    decay_step = np.exp(-tau*dt)
    # Calculate the time evolution operator once
    # for the time dt
    H = J*freeEvolutionHamiltonian()
    U = timeEvolutionOperator(H, dt)
    for _t in t:
        # Get magnetization
        mx, my = getMagnetization(r)
        mx = decay * mx
        my = decay * my
        s = signal(wref, mx, my, _t)
        y.append(s)
        # Update decay factor
        decay = decay * decay_step
        # and apply time evolution
        r = evolveDensityMatrix(r, U)
    return y
```

We will use the FFT routine provided by numpy to do the Fourier transform. Let us try to understand how the output of the FFT is related to the spectrum. The vector that we feed into the FFT has components
$$
s_k = s(dt \cdot k)
$$
The complex FFT takes the vector $s_k$ and calculates a vector $S_k$ given by
$$
S_k = \sum_{m=0}^{N-1} s_m \exp\left\{-2\pi i{mk \over N}\right\} \qquad k = 0,\ldots,N-1.
$$
Up to a factor of $2 \pi$, the inverse is given by
$$
s_k = \sum_{m=0}^{N-1} S_m \exp\left\{2\pi i{mk \over N}\right\}
$$
If S has a very sharp peak at one index $k$, then
$$
s_j = \exp\left\{2\pi i{kj \over N}\right\}
$$
i.e. expressed in terms of the signal $s$, we have a signal which is approximately described by
$$
s(dt \cdot j) = \exp\left\{2\pi i{kj \over N}\right\}
$$
or
$$
s(t) = s(\frac{t}{dt} dt) = \exp\left\{2\pi i{k t \over dt N}\right\}
$$
which corresponds to a frequency
$$
\Omega =  { 2\pi  k \over T}
$$
where $T = N dt$ is the total duration of the measurement. Thus we need to scale the x-axis by the factor
$$
{ 2\pi  \over T}
$$
when we plot the results of the FFT. Note that negative frequencies will appear in the components close to $N$ and cannot easily be distinguished from positive frequencies due to the periodicity. Therefore we should choose the reference frequency greater than the involved Larmor precession frequencies.

```python
##########################################
# Calculate and plot the FFT of a signal #
# wref - reference frequency             #
# fmin - minimimum frequency to look at  #
# T - duration of sampling               #
# Inputs are specified in rad s**(-1)    #
##########################################

def showFFT(y, wref, fmin, T):
    f = np.fft.fft(y)
    fig = plt.figure(figsize=(10,5))
    ax = fig.add_subplot(1,1,1)
    N = len(f)
    X = (wref - 2*np.pi/T*np.arange(N))
    # We zoom into the low frequency area. We plot the area between fmin and the reference
    # frequency
    kmin = int(T/(2*np.pi)*(wref-fmin))
    ax.plot(1/conv*X[:kmin], np.real(f)[:kmin])
    ax.set_ylabel("Amplitude",fontsize=20)
    ax.set_xlabel("$\omega - \omega_0$ (Hz)", fontsize=20)
    ax.set_yticks([])
    plt.show()
```

We are now ready to run our first experiment. We start with an initial pseudo-pure state. This state will be diagonal and commute with the Hamiltonian, i.e. it is a stationary state. Next we apply a rotation around the y-axis by the angle $\pi/2$ to both spins. We then take a sample. We expect to see two peaks, located at the Larmor frequencies $\omega_0$ and $\omega_1 = \omega_0 + \delta$ with the chemical shift $\delta$ of roughly 900 Hz.

```python
r = initPseudoPureState()
r = evolveDensityMatrix(r, rotY(np.pi*0.5, 0))
r = evolveDensityMatrix(r, rotY(np.pi*0.5, 1))
mx, my = getMagnetization(r)
print("Magnetization in x-direction:", mx)
print("Magnetization in y-direction:" , my)

# Reference frequency. To make sure that our frequencies are positive, we have to choose a reference
# frequency that is greater than w0 and w1
wref = w0 + 1200*conv
# Sampling interval. The resolution is in the order of 1 / dt
dt = 0.0001
# Overall sampling time
T = 4
# Decay factor
tau = 0.8
y = sample(r, dt, T, wref, tau)
showFFT(y, wref, -2000*conv + wref, T)
```

Now let us investigate the impact of a spin flip. We repeat the experiment above, but this time, we first apply a rotation around the x-axis by an angle $\pi$, i.e. we apply $\sigma_x$ on the first qubit.

```python
r = initPseudoPureState()
r = evolveDensityMatrix(r, rotX(np.pi, 0))
r = evolveDensityMatrix(r, rotY(np.pi*0.5, 0))
r = evolveDensityMatrix(r, rotY(np.pi*0.5, 1))
mx, my = getMagnetization(r)
print("Magnetization in x-direction:", mx)
print("Magnetization in y-direction:" , my)
```

    Magnetization in x-direction: [-0.25  0.25]
    Magnetization in y-direction: [ 0.  0.]

As expected, this has flipped the magnetization in the x-direction for the first qubit. Let us now repeat the sampling and the FFT to see how this affects the spectrum.

```python
y = sample(r, dt, T, wref, tau)
showFFT(y, wref, -2000*conv + wref, T)
```

We can clearly see that the amplitude of the first qubit has been reversed, as expected. Next, let us try to simulate the impact of coupling. We repeat our second experiment, but apply coupling during the measurement.

```python
r = initPseudoPureState()
r = evolveDensityMatrix(r, rotY(np.pi*0.5, 0))
r = evolveDensityMatrix(r, rotY(np.pi*0.5, 1))
mx, my = getMagnetization(r)
print("Magnetization in x-direction:", mx)
print("Magnetization in y-direction:" , my)
wref = w0 + 1200*conv
y = sample(r, dt, T, wref, tau, J = 1)
showFFT(y, wref, -2000*conv + wref, T)
```

We find that the coupling introduces a splitting - each peak is split into two peaks, with a distance given by the coupling frequency.

Next, let us turn to initial states that are thermal states, i.e. given by the Boltzmann distribution. Ignoring the small coupling terms and the chemical shift, i.e. applying the usual approximation for homonuclear systems, the initial density matrix is given by
$$
\rho = \frac{1}{4} + \frac{1}{4} \frac{\beta}{\hbar} I_z = \frac{1}{4} + \frac{1}{8} \beta \sigma_z
$$
where $\beta$ is in the order of $10^{-4}$ at room temperature and $\sigma_z$ is the z-component of the total angular momentum, i.e. the sum $\sigma_z^1 + \sigma_z^2$.

```python
################################################
# Density matrix for a thermal state           #
################################################
def thermalState(beta):
    r = np.diag(np.ones(4))*0.25
    r = r + 0.125 * beta * (np.kron(sz, np.diag(np.ones(2))) + np.kron(np.diag(np.ones(2)), sz))
    return r
```

```python
r = thermalState(0.0001)
print(r)
r = evolveDensityMatrix(r, rotY(np.pi*0.5, 0))
r = evolveDensityMatrix(r, rotY(np.pi*0.5, 1))
mx, my = getMagnetization(r)
print("Magnetization in x-direction:", mx)
print("Magnetization in y-direction:" , my)
wref = w0 + 1200*conv
y = sample(r, dt, T, wref, tau, J = 0)
showFFT(y, wref, -2000*conv + wref, T)
```

```python
# Plot the signal as well
fig = plt.figure(figsize=(10,5))
ax = fig.add_subplot(1,1,1)
time_axis = np.arange(0, T, dt)
ax.plot(time_axis[:500], np.real(y)[:500])
ax.set_ylabel("Amplitude",fontsize=20)
ax.set_xlabel("Time", fontsize=20)
ax.set_yticks([])
plt.show()
```

We can repeat this experiment, but this time, we flip only the spin of the first nucleus. We therefore expect a pure (damped) oscillation and one peak in the Fourier transformed spectrum.

```python
r = thermalState(0.0001)
r = evolveDensityMatrix(r, rotY(np.pi*0.5, 0))
mx, my = getMagnetization(r)
wref = w0 + 1200*conv
y = sample(r, dt, T, wref, tau, J = 0)
showFFT(y, wref, -2000*conv + wref, T)
```

```python
fig = plt.figure(figsize=(15,5))
ax = fig.add_subplot(1,1,1)
time_axis = np.arange(0, T, dt)
ax.plot(time_axis[:1000], np.real(y)[:1000])
ax.set_ylabel("Amplitude",fontsize=20)
ax.set_xlabel("Time", fontsize=20)
ax.set_yticks([])
plt.show()
```

```python

```