```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
```

# Game Description

**Game info**:

- two players: Small-Blind (SB) and Big-Blind (BB)
- both SB and BB have a initial stack of $V_{SB}$ and $V_{BB}$

**Game mechanics**:
1. A hand is dealt to SB and BB. Hand chosen based on $U(0,1)$
2. SB pays 0.5
3. BB pays 1.0
4. SB decided to either fold or jam (go all-in)
5. If SB jams, BB decides to either call or fold
6. The better hand wins. The lower, the better.
     - There is a certain probability that the worse hand wins

# Solution in simplified case:

## SetUp
- $V_{SB} = V_{BB} = 2$
- $p_{worseHandWins} = \frac{1}{3}$

- $y$: hand that SB has
- $x$: hand that BB has
- $y^*$ is the treshold for the SB for making jam/fold decision
    - if $y$ is < $y^*$: jam, otherwise fold
- $x^*$ is the treshold for the BB to call/fold
    - it makes sense to always call as BB if the bets of SB are so small compared to pot
    - $x^*=1$ in this setup

## Question
- given a hand $y$, what is the expected value of the game for the SB
    - assume that the BB uses $x^*=1$

## Solution
There are two actions that BB can take, and each of those have their own expectation.

Terminology:
- $p_w$ probability of winning the pot
- $p_l$ probability of losing the pot
- $PnL_w$ PnL that is received for winning wrt the stack before the game
    - $PnL_w = V_w - V_0$
    - this is the difference in stack after winning the pot ($V_w$) compared to
      before the game ($V_0$)
- $p_{rev}$ probability of reversing who is winning
    - normally, the lower hand wins, but with $p_{rev}$ the higher one wins

### Expected value for folding
- $<\text{SB},\text{Fold}|y> = p_w * PnL_w + p_l * PnL_l$
- $<\text{SB},\text{Fold}|y> = 0.0 + 1.0 * (V_l - V_0) = 1.0 * (1.5 - 2.0)$
    - if SB folds, they just lose the SB of $0.5$
- $<\text{SB},\text{Fold}|y> = -0.5$

### Expected value for jamming
- $<\text{SB},\text{Jam}|y> = p_w|y * PnL_w + p_l|y * PnL_l$
    - $p_w|y = p_{yBetter}*(1-p_{rev}) + p_{yWorse}*p_{rev}$
    - $PnL_w = V_w - V_0 = S = 4-2=2$
    - $p_l|y = p_{yBetter}*p_{rev} + p_{yWorse}*(1-p_{rev})$
    - $PnL_l = V_l - V_0 = -S = 0-2=-2$

- compute the probability that a given $y$ is better than $x \sim U(0,1)$
    - $p_{yBetter}$:
      $y$ is better than $x$ if $x \epsilon [y,1]$, the probability of that is $1-y$
    - $p_{yWorse}$
      $y$ is worse than $x$ if $x \epsilon [0,y]$, the probability of that is $y$

- $<\text{SB},\text{Jam}|y> = ((1-y)*(1-p_{rev}) + y*p_{rev}) * S + \\
                ((1-y)*p_{rev} + (1-y)*(1-p_{rev}) * (-S)$

- rearranging this equation gives:
\begin{equation}
<\text{SB},\text{Jam}|y> = 2S \Big( 2yp_{rev} - y - p_{rev} + \frac{1}{2} \Big)
\end{equation}

- Solution for $S=2, p_{rev}=\frac{1}{3}$ (used in implementation examples)
    - $<\text{SB},\text{Jam}|y> = -\frac{4}{3}y + \frac{2}{3}$

### What is the value of $Y^*$
- jam whenever jamming has higher EV than folding
- folding always has EV of $-\frac{1}{2}$
- setting the EV of folding and jamming equal results in:
    - $y^* = \frac{3}{4} + \frac{1}{2}$

# Solution in generic case:

# Implementation

```python
class PlayerSB:
    def __init__(self, hand, jam_treshold):
        self.hand = hand
        self.jam_treshold = jam_treshold # jam if hand < treshold

    def make_decision(self):
        if self.hand <= self.jam_treshold:
            return "JAM"
        else:
            return "FOLD"

class PlayerBB:
    def __init__(self, hand, call_treshold):
        self.hand = hand
        self.call_treshold = call_treshold # call if hand < treshold

    def make_decision(self):
        if self.hand <= self.call_treshold:
            return "CALL"
        else:
            return "FOLD"
```

```python
class Game_JamFold:
    def __init__(self, V_SB, V_BB, smallBlind, bigBlind, prob_winWithWorseHand, player_SB, player_BB):
        self.V_SB = V_SB
        self.V_BB = V_BB

        self.SB = smallBlind
        self.BB = bigBlind

        self.prob_winWithWorseHand = prob_winWithWorseHand

        self.player_SB = player_SB
        self.player_BB = player_BB

    def play(self):
        pot = 0.0

        # step 1: take blinds
        V_SB = self.V_SB
        V_BB = self.V_BB

        V_SB -= self.SB
        pot += self.SB

        V_BB -= self.BB
        pot += self.BB

        # step 2: SB-player needs to decide what to do ["JAM", "FOLD"]
        action_SB = player_SB.make_decision()
        callAmount = None

        if action_SB == "FOLD":
            V_BB += pot
            pot = 0
            return {"winner": "BB", "V_SB": V_SB, "V_BB": V_BB}
        else:
            # first make the call to BB
            pot += self.BB - self.SB
            V_SB -= (self.BB - self.SB)

            # then jam all-in with rest
            callAmount = V_SB
            pot += callAmount
            V_SB -= callAmount

        # step 3: BB-player needs to decide what to do ["CALL", "FOLD"]
        action_BB = player_BB.make_decision()

        if action_BB == "FOLD":
            V_SB += pot
            pot = 0
            return {"winner": "SB", "V_SB": V_SB, "V_BB": V_BB}
        else:
            V_BB -= callAmount
            pot += callAmount

        # showdown: determine winning hand
        if player_SB.hand < player_BB.hand:
            winner = "SB"
        else:
            winner = "BB"

        # account for "luck" --> the better hand might still lose
        reverseResult = np.random.uniform(0.0, 1.0, 1)
        if reverseResult <= self.prob_winWithWorseHand:
            winner = "BB" if winner == "SB" else "SB"

        # allocate pot to winner
        if winner == "SB":
            V_SB += pot
        else:
            V_BB += pot

        return {"winner": winner, "V_SB": V_SB, "V_BB": V_BB}
```

```python
SB_hand, BB_hand = np.random.uniform(low=0.0, high=1.0, size=2)
print("X: {:.5f}, Y: {:.5f}".format(SB_hand, BB_hand))
```

    X: 0.42157, Y: 0.70463

```python
V_SB, V_BB = 2,2
player_SB = PlayerSB(hand=0.2, jam_treshold=0.5)
player_BB = PlayerBB(hand=0.7, call_treshold=1.0) # call everything
```

```python
game = Game_JamFold(V_SB=V_SB,
                    V_BB=V_BB,
                    smallBlind=0.5,
                    bigBlind=1.0,
                    prob_winWithWorseHand=1.0/3.0,
                    player_SB=player_SB,
                    player_BB=player_BB)
game.play()
```

## Iterate through SB and BB hands

```python
V_SB, V_BB = 2,2

df_results = pd.DataFrame(columns=[
    "i_hand","i_replay", "SB_hand", "BB_hand", "winner", "V_SB", "V_BB"])
SB_hand = 1.0/3.0

n_hands = 50
n_replay = 10
handSelection = "grid" # "random"

for SB_hand in np.concatenate((np.linspace(0, 0.5, 21), np.linspace(0.55, 1, 5))):
    for i in range(n_hands):
        if handSelection == "random":
            _, BB_hand = np.random.uniform(low=0.0, high=1.0, size=2)
        elif handSelection == "grid":
            minHand, maxHand = 0.0, 1.0
            BB_hand = minHand + i * (maxHand-minHand)/(n_hands-1)
        else:
            raise ValueError("Unknown value for \"handSelection\"")

        player_SB = PlayerSB(hand=SB_hand, jam_treshold=0.5)
        player_BB = PlayerBB(hand=BB_hand, call_treshold=1.0) # call everything

        game = Game_JamFold(V_SB=V_SB,
                            V_BB=V_BB,
                            smallBlind=0.5,
                            bigBlind=1.0,
                            prob_winWithWorseHand=1.0/3.0,
                            player_SB=player_SB,
                            player_BB=player_BB)

        for j in  range(n_replay):
            results = game.play()
            df_results.loc[df_results.shape[0]] = {"i_hand": i,
                                                   "i_replay": j,
                                                   "SB_hand": SB_hand,
                                                   "BB_hand": BB_hand,
                                                   "winner": results["winner"],
                                                   "V_SB": results["V_SB"],
                                                   "V_BB": results["V_BB"]}

    df_results["PnL_SB"] = df_results["V_SB"] - V_SB
    df_results["PnL_BB"] = df_results["V_BB"] - V_BB
```

```python
df_results["PnL_SB"].mean()
```

    0.108

```python
df_results["winner"].value_counts()
```

    SB    1982
    BB    1018
    Name: winner, dtype: int64

```python
df_results["BB_hand"].hist()
```

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

for SB_hand, df in df_results.groupby("SB_hand"):
    EV_SB = df["PnL_SB"].mean()

    ax.scatter(SB_hand, EV_SB, color="steelblue")

ax.axhline(0, color="red", alpha=0.5)
ax.axvline(0.5, color="red", alpha=0.5)

# add theoretical line to this
arr_x = np.linspace(0, 1.0, 100)
arr_jam = -4/3*arr_x + 2/3
ax.plot(arr_x, arr_jam, color="forestgreen")

arr_x = np.linspace(0.5, 1, 100)
arr_fold = arr_x*0-0.5
ax.plot(arr_x, arr_fold, color="forestgreen")

ax.set_xlabel("SB hand")
ax.set_ylabel("<SB>")

ax.set_ylim(-1.0, 1.0)
ax.grid()
```

```python
np.concatenate((np.linspace(0, 0.5, 11), np.linspace(0.55, 1, 5)))
```

    array([0.    , 0.05  , 0.1   , 0.15  , 0.2   , 0.25  , 0.3   , 0.35  ,
           0.4   , 0.45  , 0.5   , 0.55  , 0.6625, 0.775 , 0.8875, 1.    ])


### by nick brønn

### Jaynes-Cummings Hamiltonian

For qubits, we can describe the dynamics of the qubit with the **Pauli Matrices**

$$
\sigma^x = \begin{pmatrix} 0 & 1 \\ 1 & 0 \end{pmatrix} \qquad
\sigma^y = \begin{pmatrix} 0 & -i \\ i & 0 \end{pmatrix} \qquad
\sigma^z = \begin{pmatrix} 1 & 0 \\ 0 & -1 \end{pmatrix} \qquad
$$

The Pauli matrices let us define raising and lowering operators

$$ \sigma^+ = \frac{1}{2}( \sigma^x - i\sigma^y) \qquad {\rm and}
\qquad \sigma^- = \frac{1}{2}( \sigma^x + i\sigma^y)$$

They raise and lower qubit states

$$ \sigma^+ |0\rangle = |1\rangle \qquad \sigma^+ |1\rangle = 0
\qquad {\rm and} \qquad
\sigma^-|1\rangle = |0\rangle \qquad \sigma^-|0\rangle = 0$$

The interaction between qubit and resonator is descibed by the **Jaynes-Cummings Hamiltonian**

$$
H_{\rm JC}^{\rm (RWA)}/\hbar = \underbrace{\omega_r a^\dagger a}_\text{resonator} - \overbrace{\frac{1}{2} \omega_q \sigma_z}^\text{qubit} + \underbrace{g(a^\dagger \sigma^- + a \sigma^+)}_\text{dipole interaction}.
$$

In order to understand it, we must *block-diagonalize* it using the *Schrieffer-Wolff transformation*.

## The Schrieffer-Wolff Transformation

You have a diagonalized Hamiltonian plus a perturbation
$$
H \quad = \quad \underbrace{\begin{pmatrix}
\Box &  &  &  &  &  &  \\
 & \Box &  &  &  &  &  \\
 &  & \Box &  &  &  &  \\
 &  &  & \Box &  &  &  \\
 &  &  &  & \Box &  &  \\
 &  &  &  &  & \Box &  \\
 &  &  &  &  &  & \Box \end{pmatrix}}_\text{diagonal} \quad + \quad
\underbrace{\begin{pmatrix}
\times & \times & \times & \times & \cdot & \cdot & \cdot \\
\times & \times & \times & \times & \cdot & \cdot & \cdot \\
\times & \times & \times & \times & \cdot & \cdot & \cdot \\
\times & \times & \times & \times & \cdot & \cdot & \cdot \\
\cdot & \cdot & \cdot & \cdot & \times & \times & \times \\
\cdot & \cdot & \cdot & \cdot & \times & \times & \times \\
\cdot & \cdot & \cdot & \cdot & \times & \times & \times \end{pmatrix}}_\text{perturbation}
$$

Split the perturbation into *block diagonal* and *block off-diagonal* parts so that $H = H_0 + H_1 + H_2$
$$
H \quad = \quad \underbrace{\begin{pmatrix}
\Box &  &  &  &  &  &  \\
 & \Box &  &  &  &  &  \\
 &  & \Box &  &  &  &  \\
 &  &  & \Box &  &  &  \\
 &  &  &  & \Box &  &  \\
 &  &  &  &  & \Box &  \\
 &  &  &  &  &  & \Box \end{pmatrix}}_\text{diagonal}
\quad + \quad
\underbrace{\begin{pmatrix}
\times & \times & \times & \times &  &  &  \\
\times & \times & \times & \times &  &  &  \\
\times & \times & \times & \times &  &  &  \\
\times & \times & \times & \times &  &  &  \\
 &  &  &  & \times & \times & \times \\
 &  &  &  & \times & \times & \times \\
 &  &  &  & \times & \times & \times \end{pmatrix}}_\text{block diagonal}
\quad + \quad
\underbrace{\begin{pmatrix}
 &  &  &  & \cdot & \cdot & \cdot \\
 &  &  &  & \cdot & \cdot & \cdot \\
 &  &  &  & \cdot & \cdot & \cdot \\
 &  &  &  & \cdot & \cdot & \cdot \\
\cdot & \cdot & \cdot & \cdot &  &  &  \\
\cdot & \cdot & \cdot & \cdot &  &  &  \\
\cdot & \cdot & \cdot & \cdot &  &  &  \end{pmatrix}}_\text{block non-diagonal}
$$

## The Schrieffer-Wolff Transformation

We seek an anti-hermitian, block off-diagonal operator $S$ such that

$$
H_{\rm eff} = e^{iS} H e^{-iS} = \sum_{m=0}^\infty \frac{1}{m!} \left[H, S\right]^{(m)} \qquad {\rm with} \qquad \left[H,S\right]^{(m)} = \left[\left[H,S\right]^{(m-1)},S\right] \quad {\rm and} \quad \left[H,S\right]^{(0)} = H
$$

is the generalized commutator

Taking $H_1+H_2$ as a perturbation on the diagonal $H_0$ and $S$ as a Taylor expansion

$$ H = H_0 + \lambda(H_1 + H_2) \qquad S = \sum_{m=1}^\infty \lambda^m S^{(m)} $$

to keep track of the order $\lambda$

Then

$$
H_{\rm eff} = H_0 + \lambda (H_1+H_2) + \left[H_0 + \lambda(H_1+H_2), \lambda S^{(1)}\right]
  + \frac{1}{2} \left[ \left[ H_0 + \lambda(H_1+H_2), \lambda S^{(1)}\right], \lambda S^{(1)}\right]
  + \left[H_0 + \lambda(H_1+H_2), \lambda^2 S^{(2)}\right] + \ldots \\
  \approx H_0 + \lambda \left( H_1 + H_2 + \left[H_0, S^{(1)}\right] \right)
  + \lambda^2 \left( \left[H_1+H_2, S^{(1)}\right] + \frac{1}{2} \left[ \left[H_0, S^{(1)}\right], S^{(1)}\right] + \left[H_0, S^{(2)}\right]\right)
$$

up to second order in $\lambda$

## The Schrieffer-Wolff Transformation

The transformation eliminates the block off-diagonal part of $H_{\rm eff}$,

$$
H_{\rm eff}^{\rm off-diag} = \sum_{m=0}^\infty \frac{1}{(2m+1)!} [H_0 + H_1, S]^{(2m+1)} + \sum_{m=0}^\infty \frac{1}{(2m)!} [H_2, S]^{(2m)} \equiv 0,
$$

to each order of $\lambda$

Expanding

$$
H_{\rm eff}^{\rm off-diag} = \left[ H_0 + \lambda H_1, \lambda S^{(1)} \right]+\lambda H_2 + \left[H_0 + \lambda H_1, \lambda^2 S^{(2)}\right]
  + \frac{1}{3!} \left[ H_0+\lambda H_1, \lambda S^{(1)}\right]^{(3)} + \frac{1}{2!} \left[ \lambda H_2, \lambda S^{(1)}\right]^{(2)} \\
  = \lambda \left( \left[ H_0, S^{(1)} \right] + H_2 \right) + \lambda^2 \left( \left[H_1, S^{(1)} \right] + \left[H_0, S^{(2)}\right]\right) + \ldots
$$

Going to second order yields

$$
\left[ H_0, S^{(1)} \right] = -H_2 \qquad \left[ H_0, S^{(2)} \right] = -\left[H_1, S^{(1)}\right]
$$

## The Schrieffer-Wolff Transformation
$$
H_{\rm eff} = H_0 + \lambda \left( H_1 + H_2 + \left[H_0, S^{(1)}\right] \right)
  + \lambda^2 \left( \left[H_1+H_2, S^{(1)}\right] + \frac{1}{2} \left[ \left[H_0, S^{(1)},\right] S^{(1)}\right] + \left[H_0, S^{(2)}\right]\right)
$$

Substituting the equations needed to cancel the block off-diagonal terms and letting $\lambda \to 1$

$$
H_{\rm eff} = H_0 + H_1 + \frac{1}{2} \left[ H_2, S^{(1)} \right]
$$

Now we only need to find $S^{(1)}$ for a second-order correction!

The recent manuscript [A Systematic Method for Schrieffer-Wolff Transformation and Its Generalizations](http://www.arxiv.org/abs/2004.06534) systematically provides the ansatz by calculating

$$\eta = [H_0, H_2] $$

and set the coefficients such that

$$ [H_0, \eta] = H_2$$

which means $\eta$ is the same as $S^{(1)}$ by uniqueness

## S-W Tranformation of the J-C Hamiltonain
For ease of tedious calculations, we will use the Python package [`sympy`](http://www.sympy.org) for symbolic mathematics.

```python
# import SymPy and define symbols
import sympy as sp
sp.init_printing(use_unicode=True)
wr = sp.Symbol('\omega_r') # resonator frequency
wq = sp.Symbol('\omega_q') # qubit frequency
g = sp.Symbol('g', real=True) # vacuum Rabi coupling
Delta = sp.Symbol('Delta', real=True) # wr - wq; defined later
```

```python
# import operator relations and define them
from sympy.physics.quantum.boson import BosonOp
a = BosonOp('a') # resonator photon annihilation operator
from sympy.physics.quantum import pauli, Dagger, Commutator
from sympy.physics.quantum.operatorordering import normal_ordered_form
```

## S-W Tranformation of the J-C Hamiltonian

```python
# Pauli matrices
sx = pauli.SigmaX()
sy = pauli.SigmaY()
sz = pauli.SigmaZ()

# qubit raising and lowering operators
splus = pauli.SigmaPlus()
sminus = pauli.SigmaMinus()
```

```python
# define J-C Hamiltonian in terms of diagonal and non-block diagonal terms
H0 = wr*Dagger(a)*a - (1/2)*wq*sz;
H1 = 0
H2 = g*(Dagger(a)*sminus + a*splus);
HJC = H0 + H1 + H2; HJC # print
```

## S-W Tranformation of the J-C Hamiltonian

```python
# using the above method for finding the ansatz
eta = Commutator(H0, H2); eta
```

We will need to used the methods `doit()`, `expand`, `normal_ordered_form`, and `qsimplify_pauli`

```python
pauli.qsimplify_pauli(normal_ordered_form(eta.doit().expand()))
```

## S-W Tranformation of the J-C Hamiltonian

Now take $A$ and $B$ as the coefficients of $a^\dagger \sigma_-$ and $a\sigma_+$, respectively. Then the commutator

```python
A = sp.Symbol('A')
B = sp.Symbol('B')
eta = A * Dagger(a) * sminus - B * a * splus;
pauli.qsimplify_pauli(normal_ordered_form(Commutator(H0, eta).doit().expand()))
```

This expression should be equal to $H_2$

```python
H2
```

## S-W Tranformation of the J-C Hamiltonian

Therefore set $A = B = g/\Delta$ where $\Delta = \omega_r - \omega_q$ is the frequency detuning between the resonator and qubit. Then $S^{(1)}$ is determined as

```python
S1 = eta.subs(A, g/Delta)
S1 = S1.subs(B, g/Delta); S1.factor()
```

Then we can calculate the effective second order correction to $H_0$

```python
Heff = H0 + H1 + 0.5*pauli.qsimplify_pauli(normal_ordered_form(Commutator(H2, S1).doit().expand())).simplify(); Heff
```

This is typically written as
$$
H_{\rm eff} = \left(\omega_r + \frac{g^2}{\Delta}\sigma_z\right)a^\dagger a  - \frac{1}{2}\left(\omega_q -\frac{g^2}{\Delta}\right) \sigma_z
$$
which shows a state-dependent shift by $\chi \equiv g^2/\Delta$ of the resonator frequency called the *ac Stark shift* and a shift in qubit frequency due to quantum vacuum fluctuations called the *Lamb shift*.

## Qiskit Pulse - Spectroscopy

### Import Necessary Libraries

```python
from qiskit.tools.jupyter import *
from qiskit import IBMQ
IBMQ.load_account()
provider = IBMQ.get_provider(hub='ibm-q', group='open', project='main')
backend = provider.get_backend('ibmq_armonk')
```

    ibmqfactory.load_account:WARNING:2020-07-27 14:44:14,221: Credentials are already in use. The existing account in the session will be replaced.

### Verify Backend is Pulse-enabled

```python
backend_config = backend.configuration()
assert backend_config.open_pulse, "Backend doesn't support Pulse"

dt = backend_config.dt
print(f"Sampling time: {dt*1e9} ns")
backend_defaults = backend.defaults()
```

    Sampling time: 0.2222222222222222 ns

## Qiskit Pulse - Spectroscopy

```python
import numpy as np

# unit conversion factors -> all backend properties returned in SI (Hz, sec, etc)
GHz = 1.0e9 # Gigahertz
MHz = 1.0e6 # Megahertz
kHz = 1.0e3
us = 1.0e-6 # Microseconds
ns = 1.0e-9 # Nanoseconds

# We will find the qubit frequency for the following qubit.
qubit = 0

# The sweep will be centered around the estimated qubit frequency.
center_frequency_Hz = backend_defaults.qubit_freq_est[qubit]        # The default frequency is given in Hz
                                                                    # warning: this will change in a future release
print(f"Qubit {qubit} has an estimated frequency of {center_frequency_Hz / GHz} GHz.")
```

    Qubit 0 has an estimated frequency of 4.9744440960448815 GHz.

## Qiskit Pulse - Spectroscopy

```python
# scale factor to remove factors of 10 from the data
scale_factor = 1e-14
# We will sweep 40 MHz around the estimated frequency
frequency_span_Hz = 40 * MHz
# in steps of 1 MHz.
frequency_step_Hz = 1 * MHz
# We will sweep 20 MHz above and 20 MHz below the estimated frequency
frequency_min = center_frequency_Hz - frequency_span_Hz / 2
frequency_max = center_frequency_Hz + frequency_span_Hz / 2
# Construct an np array of the frequencies for our experiment
frequencies_GHz = np.arange(frequency_min / GHz,
                            frequency_max / GHz,
                            frequency_step_Hz / GHz)

print(f"The sweep will go from {frequency_min / GHz} GHz to {frequency_max / GHz} GHz \
in steps of {frequency_step_Hz / MHz} MHz.")
```

    The sweep will go from 4.954444096044882 GHz to 4.994444096044882 GHz in steps of 1.0 MHz.

## Qiskit Pulse - Spectroscopy

```python
from qiskit import pulse            # This is where we access all of our Pulse features!

inst_sched_map = backend_defaults.instruction_schedule_map
measure = inst_sched_map.get('measure', qubits=[qubit])
x_pulse = inst_sched_map.get('x', qubits=[qubit])

### Collect the necessary channels
drive_chan = pulse.DriveChannel(qubit)
meas_chan = pulse.MeasureChannel(qubit)
acq_chan = pulse.AcquireChannel(qubit)
```

```python
# Create the base schedule
# Start with drive pulse acting on the drive channel
schedule = pulse.Schedule(name='Frequency sweep')
schedule += x_pulse
# The left shift `<<` is special syntax meaning to shift the start time of the schedule by some duration
schedule += measure << schedule.duration

# Create the frequency settings for the sweep (MUST BE IN HZ)
frequencies_Hz = frequencies_GHz*GHz
schedule_frequencies = [{drive_chan: freq} for freq in frequencies_Hz]
```

## Qiskit Pulse - Spectroscopy

```python
schedule.draw(label=True, scaling=0.8)
```

## Qiskit Pulse - Spectroscopy

```python
from qiskit import assemble
frequency_sweep_program = assemble(schedule,
                                   backend=backend,
                                   meas_level=1,
                                   meas_return='avg',
                                   shots=1024,
                                   schedule_los=schedule_frequencies)
```

```python
# RUN the job on a real device
#job = backend.run(rabi_experiment_program)
#print(job.job_id())
#from qiskit.tools.monitor import job_monitor
#job_monitor(job)

# OR retreive result from previous run
job = backend.retrieve_job('5ef3b081fbc24b001275b03b')
```

## Qiskit Pulse - Spectroscopy

```python
frequency_sweep_results = job.result()
```

```python
import matplotlib.pyplot as plt
plt.style.use('dark_background')

sweep_values = []
for i in range(len(frequency_sweep_results.results)):
    # Get the results from the ith experiment
    res = frequency_sweep_results.get_memory(i)*scale_factor
    # Get the results for `qubit` from this experiment
    sweep_values.append(res[qubit])

plt.scatter(frequencies_GHz, np.real(sweep_values), color='white') # plot real part of sweep values
plt.xlim([min(frequencies_GHz), max(frequencies_GHz)])
plt.xlabel("Frequency [GHz]")
plt.ylabel("Measured signal [a.u.]")
plt.show()
```

## Qiskit Pulse - Spectroscopy

```python
from scipy.optimize import curve_fit

def fit_function(x_values, y_values, function, init_params):
    fitparams, conv = curve_fit(function, x_values, y_values, init_params)
    y_fit = function(x_values, *fitparams)

    return fitparams, y_fit
```

```python
fit_params, y_fit = fit_function(frequencies_GHz,
                                 np.real(sweep_values),
                                 lambda x, A, q_freq, B, C: (A / np.pi) * (B / ((x - q_freq)**2 + B**2)) + C,
                                 [5, 4.975, 1, 3] # initial parameters for curve_fit
                                )
```

## Qiskit Pulse - Spectroscopy

$$
H_{\rm eff} = \left(\omega_r + \frac{g^2}{\Delta}\sigma_z\right)a^\dagger a  - \frac{1}{2}\left(\omega_q -\frac{g^2}{\Delta}\right) \sigma_z
$$

```python
plt.scatter(frequencies_GHz, np.real(sweep_values), color='white')
plt.plot(frequencies_GHz, y_fit, color='red')
plt.xlim([min(frequencies_GHz), max(frequencies_GHz)])

plt.xlabel("Frequency [GHz]")
plt.ylabel("Measured Signal [a.u.]")
plt.show()
```

## Qiskit Pulse - Dispersive Shift

```python
 # Create the schedules for 0 and 1
schedule_0 = pulse.Schedule(name='0')
schedule_0 += measure

schedule_1 = pulse.Schedule(name='1')
schedule_1 += x_pulse
schedule_1 += measure << schedule_1.duration
```

## Qiskit Pulse - Dispersive Shift

```python
schedule_0.draw()
```

## Qiskit Pulse - Dispersive Shift

```python
schedule_1.draw()
```

## Qiskit Pulse - Dispersive Shift

```python
frequency_span_Hz = 320 * kHz
frequency_step_Hz = 8 * kHz

center_frequency_Hz = backend_defaults.meas_freq_est[qubit]
print(f"Qubit {qubit} has an estimated readout frequency of {center_frequency_Hz / GHz} GHz.")
frequency_min = center_frequency_Hz - frequency_span_Hz / 2
frequency_max = center_frequency_Hz + frequency_span_Hz / 2
frequencies_GHz = np.arange(frequency_min / GHz,
                            frequency_max / GHz,
                            frequency_step_Hz / GHz)

print(f"The sweep will go from {frequency_min / GHz} GHz to {frequency_max / GHz} GHz\
      in steps of {frequency_step_Hz / MHz} MHz.")
```

    Qubit 0 has an estimated readout frequency of 6.993427855 GHz.
    The sweep will go from 6.993267855 GHz to 6.993587855 GHz      in steps of 0.008 MHz.

## Qiskit Pulse - Dispersive Shift

```python
num_shots_per_frequency = 2048

frequencies_Hz = frequencies_GHz*GHz
schedule_los = [{meas_chan: freq} for freq in frequencies_Hz]

cavity_sweep_0 = assemble(schedule_0,
                               backend=backend,
                               meas_level=1,
                               meas_return='avg',
                               shots=num_shots_per_frequency,
                               schedule_los=schedule_los)

cavity_sweep_1 = assemble(schedule_1,
                               backend=backend,
                               meas_level=1,
                               meas_return='avg',
                               shots=num_shots_per_frequency,
                               schedule_los=schedule_los)
```

```python
# RUN the job on a real device
#job_0 = backend.run(cavity_sweep_0)
#job_monitor(job_0)
#job_0.error_message()

#job_1 = backend.run(cavity_sweep_1)
#job_monitor(job_1)
#job_1.error_message()

# OR retreive result from previous run
job_0 = backend.retrieve_job('5efa5b447c0d6800137fff1c')
job_1 = backend.retrieve_job('5efa6b2720eee10013be46b4')

cavity_sweep_0_results = job_0.result()
cavity_sweep_1_results = job_1.result()
```

## Qiskit Pulse - Dispersive Shift

```python
scale_factor = 1e-14

sweep_values_0 = []
for i in range(len(cavity_sweep_0_results.results)):
    res_0 = cavity_sweep_0_results.get_memory(i)*scale_factor
    sweep_values_0.append(res_0[qubit])

sweep_values_1 = []
for i in range(len(cavity_sweep_1_results.results)):
    res_1 = cavity_sweep_1_results.get_memory(i)*scale_factor
    sweep_values_1.append(res_1[qubit])

plotx = frequencies_Hz/kHz
ploty_0 = np.abs(sweep_values_0)
ploty_1 = np.abs(sweep_values_1)
```

## Qiskit Pulse - Dispersive Shift

$$
H_{\rm eff} = \left(\omega_r + \frac{g^2}{\Delta}\sigma_z\right)a^\dagger a  - \frac{1}{2}\left(\omega_q -\frac{g^2}{\Delta}\right) \sigma_z
$$

```python
plt.plot(plotx, ploty_0, color='blue', marker='.') # plot real part of sweep values
plt.plot(plotx, ploty_1, color='red', marker='.') # plot real part of sweep values
plt.legend([r'$\vert0\rangle$', r'$\vert1\rangle$'])

plt.grid()
plt.xlabel("Frequency [kHz]")
plt.ylabel("Measured signal [a.u.]")
plt.yscale('log')
plt.show()
```

# <center> On to Exercise 1... </center>


# Discrete Fourier Transform (DFT)

The Fourier transform is one of the most important tools for a wide range of applications in engineering and computer science. In this notebook, we have a look at a discrete variant of the Fourier transform known as Discrete Fourier Transform (DFT). Furthermore, we introduce and implement the fast Fourier transform (FFT), which is an efficient algorithm for computing the DFT. This notebook closely follows the [FMP Notebook on the Discrete Fourier Transform](https://www.audiolabs-erlangen.de/resources/MIR/FMP/C2/C2_ComplexNumbers.html).

Inner Product

Definition of DFT

DFT Matrix

Fast Fourier Transform (FFT)

Exercise 1: Interpretation of Frequency Indices

Exercise 2: Missing Time Localization

Exercise 3: Chirp Signal

Exercise 4: Inverse DFT

## Inner Product

In this notebook, we consider [discrete-time (DT) signals](PCP_Signal.html) of finite length $N\in\mathbb{N}$, which we represent as vector $x=(x(0),x(1),...,x(N-1))^\top\in\mathbb{R}^N$ with samples $x(n)\in\mathbb{R}^N$ for $n\in[0:N-1]$. Note that we start indexing with the index $0$. A general concept for comparing two vectors (or signals) is the **inner product**. Given two vectors $x, y \in \mathbb{R}^N$, the inner product between $x$ and $y$ is defined as follows:

$$
\langle x | y \rangle := \sum_{n=0}^{N-1} x(n) y(n).
$$

The absolute value of the inner product may be interpreted as a measure of similarity between $x$ and $y$: if $x$ and $y$ point to the same direction (i.e., $x$ and $y$ are similar), the inner product $|\langle x | y \rangle|$ is large. If $x$ and $y$ are orthogonal (i.e., $x$ and $y$ are dissimilar), the inner product $|\langle x | y \rangle|$ is zero.

One can extend this concept to **complex-valued** vectors $x,y\in\mathrm{C}^N$, where the inner product is defined as

$$
\langle x | y \rangle := \sum_{n=0}^{N-1} x(n) \overline{y(n)}.
$$

In the case of real-valued signals, the complex conjugate does not play any role and the definition of the complex-valued inner product reduces to the real-valued one. In the following code cell, we give some examples.

Note:
One can use the NumPy function np.vdot to compute the inner product. However, opposed to the mathematical convention to conjugate the second argument, this function applies complex conjugation on the first argument. Therefore, for computing $\langle x | y \rangle$ as defined above, one has to call np.vdot(y, x).

In the following, we generate and visualize three signals $x_1$, $x_2$, $x_3$. Then, we compute and discuss different inner products using the signals.

```python
import numpy as np
from matplotlib import pyplot as plt
import LibPCP.signal
%matplotlib inline

Fs = 64
dur = 1
x1, t = LibPCP.signal.generate_example_signal(Fs=Fs, dur=dur)
x2, t = LibPCP.signal.generate_sinusoid(dur=dur, Fs=Fs, amp=1, freq=2, phase=0.3)
x3, t = LibPCP.signal.generate_sinusoid(dur=dur, Fs=Fs, amp=1, freq=6, phase=0.1)

def plot_inner_product(ax, t, x, y, color_x='k', color_y='r', label_x='x', label_y='y'):
    ax.plot(t, x, color=color_x, linewidth=1.0, linestyle='-', label=label_x)
    ax.plot(t, y, color=color_y, linewidth=1.0, linestyle='-', label=label_y)
    ax.set_xlim([0, t[-1]])
    ax.set_ylim([-1.5, 1.5])
    ax.set_xlabel('Time (seconds)')
    ax.set_ylabel('Amplitude')
    sim = np.vdot(y, x)
    ax.set_title(r'$\langle$ %s $|$ %s $\rangle = %.1f$' % (label_x, label_y, sim))
    ax.legend(loc='upper right')

plt.figure(figsize=(8, 5))
ax = plt.subplot(2, 2, 1)
plot_inner_product(ax, t, x1, x1, color_x='k', color_y='k', label_x='$x_1$', label_y='$x_1$')
ax = plt.subplot(2, 2, 2)
plot_inner_product(ax, t, x1, x2, color_x='k', color_y='r', label_x='$x_1$', label_y='$x_2$')
ax = plt.subplot(2, 2, 3)
plot_inner_product(ax, t, x1, x3, color_x='k', color_y='b', label_x='$x_1$', label_y='$x_3$')
ax = plt.subplot(2, 2, 4)
plot_inner_product(ax, t, x2, x3, color_x='r', color_y='b', label_x='$x_2$', label_y='$x_3$')
plt.tight_layout()
```

In the above example, one can make the following observations:

* The signal $x_1$ is similar to itself, leading to a large value of $\langle x_1 | x_1 \rangle=40.0$.
* The overall course of the signal $x_1$ strongly correlates with the sinusoid $x_2$, which is reflected by a relatively large value of $\langle x_1 | x_2 \rangle=29.9$.
* There are some finer oscillations of $x_1$ that are captured by $x_3$, leading to a still noticeable values of  $\langle x_1 | x_3 \rangle=14.7$.
* The two sinusoids $x_1$ and $x_3$ are not correlated at all, which is revealed by the value of $\langle x_2 | x_3 \rangle=0$.

In other words, the above comparison reveals that the signal $x_1$ has a strong signal component of $2~\mathrm {Hz}$ (frequency of $x_2$) and $6~\mathrm {Hz}$ (frequency of $x_3$). Measuring correlations between an arbitrary signal and sinusoids of different frequencies is exactly the idea of performing a Fourier (or spectral) analysis.

## Definition of DFT

Let $x\in \mathbb{C}^N$ be a vector of length $N\in\mathbb{N}$. The **discrete Fourier transform** (DFT) of $x$ is defined by:

$$ X(k) := \sum_{n=0}^{N-1} x(n) \exp(-2 \pi i k n / N) $$

for $k \in [0:N-1]$. The vector $X\in\mathbb{C}^N$ can be interpreted as frequency representation of the time-domain signal $x$.   To obtain a geometric interpretation of the DFT, we define the vector $\mathbf{e}_k \in\mathbb{C}^N$ with real part $\mathbf{c}_k=\mathrm{Re}(\mathbf{e}_k)$ and imaginary part $\mathbf{s}_k=\mathrm{Im}(\mathbf{e}_k)$ by

$$\mathbf{e}_k(n) :=  \exp(2 \pi i k n / N) = \cos(2 \pi i k n / N) + i \sin(2 \pi i k n / N)
= \mathbf{c}_k(n) + i \mathbf{s}_k(n)$$

for each $k \in [0:N-1]$.

This vector can be regarded as a [sampled version](PCP_signal.html) of the [exponential function](PCP_exp.html) of frequency $k/N$. Using inner products, the DFT can be expressed as

$$ X(k) = \sum_{n=0}^{N-1} x(n) \overline{\mathbf{e}_k} = \langle x | \mathbf{e}_k \rangle,$$

thus measuring the similarity between the signal $x$ and the sampled exponential functions $\mathbf{e}_k$. The absolute value $|X(k)|$ indicates the degree of similarity between the signal $x$ and $\mathbf{e}_k$. In the case that $x\in \mathbb{R}^N$ is a real-valued vector (which is typically the case for audio signals), we obtain:

$$
X(k) = \langle x |\mathrm{Re}(\mathbf{e}_k) \rangle - i\langle x | \mathrm{Im}(\mathbf{u}_k) \rangle
= \langle x |\mathbf{c}_k \rangle - i\langle x | \mathbf{s}_k \rangle
$$

The following plot shows an example signal $x$ compared with functions $\overline{\mathbf{e}_k}$ for various frequency parameters $k$. The real and imaginary part of $\overline{\mathbf{e}_k}$ are shown in  red and  blue, respectively.

```python
def plot_signal_e_k(ax, x, k, show_e=True, show_opt=False):
    """Plot signal and k-th DFT sinusoid

    Notebook: PCP_dft.ipynb

    Args:
        ax: Figure axis
        x: Signal
        k: Index of DFT
        show_e: Shows cosine and sine
        show_opt: Shows cosine with optimal phase
    """
    N = len(x)
    time_index = np.arange(N)
    plt.plot(time_index, x, 'k', marker='.', markersize='10', linewidth=2.0, label='$x$')
    plt.xlabel('Time (samples)')
    e_k = np.exp(2 * np.pi * 1j * k * time_index / N)
    c_k = np.real(e_k)
    s_k = np.imag(e_k)
    X_k = np.vdot(e_k, x)

    plt.title(r'k = %d: Re($X(k)$) = %0.2f, Im($X(k)$) = %0.2f, $|X(k)|$=%0.2f' %
              (k, X_k.real, X_k.imag, np.abs(X_k)))
    if show_e is True:
        plt.plot(time_index, c_k, 'r', marker='.', markersize='5',
                 linewidth=1.0, linestyle=':', label='$\mathrm{Re}(\overline{\mathbf{u}}_k)$')
        plt.plot(time_index, s_k, 'b', marker='.', markersize='5',
                 linewidth=1.0, linestyle=':', label='$\mathrm{Im}(\overline{\mathbf{u}}_k)$')
    if show_opt is True:
        phase_k = - np.angle(X_k) / (2 * np.pi)
        cos_k_opt = np.cos(2 * np.pi * (k * time_index / N - phase_k))
        d_k = np.sum(x * cos_k_opt)
        plt.plot(time_index, cos_k_opt, 'g', marker='.', markersize='5',
                 linewidth=1.0, linestyle=':', label='$\cos_{k, opt}$')
    plt.grid()
    plt.legend(loc='lower right')

N = 64
x, t = LibPCP.signal.generate_example_signal(Fs=N, dur=1)

plt.figure(figsize=(8, 15))
for k in range(1, 8):
    ax = plt.subplot(7, 1, k)
    plot_signal_e_k(ax, x, k=k)

plt.tight_layout()
```

## DFT Phase

At first sight, the DFT may be a bit confusing: Why is a real-valued signal $x$ compared with a complex-valued sinusoid $\mathbf{u}_k$? What does the resulting complex-valued Fourier coefficient

$$
c_k:= X(k) := \langle x |\mathrm{Re}(\mathbf{u}_k) \rangle - i\langle x | \mathrm{Im}(\mathbf{u}_k) \rangle.
$$

encode? To understand this, we represent the complex number $c_k$ in form of its [polar representation](PCP_complex.html#polar)

$$
c_k = |c_k| \cdot \mathrm{exp}(i \gamma_k),
$$

where $\gamma_k$ is the [angle](PCP_complex.html) (given in radians).  Furthermore, let $\mathbf{cos}_{k,\varphi}:[0:N-1]\to\mathbb{R}$ be a sampled sinusoid with frequency parameter $k$ and phase $\varphi\in[0,1)$, defined by

$$
   \mathbf{cos}_{k,\varphi}(n) = \mathrm{cos}\big( 2\pi (kn/N - \varphi) \big)
$$

for $n\in[0,N-1]$. Defining $\varphi_k := - \frac{\gamma_k}{2 \pi}$, one obtains the following remarkable property of the Fourier coefficient $c_k$:

\begin{eqnarray}
|c_k| &=& \mathrm{max}_{\varphi\in[0,1)} \langle x | \mathbf{cos}_{k,\varphi} \rangle,\\
\varphi_k  &=& \mathrm{argmax}_{\varphi\in[0,1)} \langle x | \mathbf{cos}_{k,\varphi} \rangle.
\end{eqnarray}

In other words, the phase $\varphi_k$ maximizes the correlation between $x$ and all possible sinusoids $\mathbf{cos}_{k,\varphi}$ with $\varphi\in[0,1)$. Furthermore, the magnitude $|c_k|$ yields this maximal value. Thus, computing a single correlation between $x$ and the complex-valued function $\mathbf{u}_k$ (which real part coincides with $\mathbf{cos}_{k,0}$ and its imaginary part with $\mathbf{cos}_{k,0.25}$) solves an optimization problem. In the following code cell, we demonstrate this optimality property, where the $\mathbf{cos}_{k,\varphi}$ with optimal phase $\varphi=\varphi_k$ is shown in green.

```python
plt.figure(figsize=(8, 15))
for k in range(1, 8):
    ax = plt.subplot(7, 1, k)
    plot_signal_e_k(ax, x, k=k, show_e=False, show_opt=True)

plt.tight_layout()
```

## DFT Matrix

Being a linear operator $\mathbb{C}^N \to \mathbb{C}^N$, the DFT can be expressed by some $N\times N$-matrix. This leads to the famous DFT matrix $\mathrm{DFT}_N \in \mathbb{C}^{N\times N}$ matrix, which is given by

$$\mathrm{DFT}_N(n, k) = \mathrm{exp}(-2 \pi i k n / N)$$

for $n\in[0:N-1]$ and $k\in[0:N-1]$. Let $\rho_N:=\exp(2 \pi i / N)$ be the primitive $N^\mathrm{th}$ [root of unity](PCP_exp.html#roots). Then

$$\sigma_N:= \overline{\rho_N} = \mathrm{exp}(-2 \pi i / N)$$

also defines a primitive $N^\mathrm{th}$ [root of unity](PCP_exp.html#roots). From the [properties of exponential functions](PCP_exp.html), one obtains that

$$ \sigma_N^{kn} = \mathrm{exp}(-2 \pi i / N)^{kn} = \mathrm{exp}(-2 \pi i k n / N)$$

From this, one obtains:

$$
\mathrm{DFT}_N =
\begin{pmatrix}
    1 & 1 & 1 & \dots  & 1 \\
    1 & \sigma_N & \sigma_N^2 & \dots  & \sigma_N^{N-1} \\
    1 & \sigma_N^2 & \sigma_N^4 & \dots  & \sigma_N^{2(N-1)} \\
    \vdots & \vdots & \vdots & \ddots & \vdots \\
    1 & \sigma_N^{N-1} & \sigma_N^{2(N-1)} & \dots  & \sigma_N^{(N-1)(N-1)} \\
\end{pmatrix}
$$

In the following visualization, the real and imaginary part of $\mathrm{DFT}_N$ are shown, where the values are encoded by suitable colors. Note that the $k^\mathrm{th}$ row of $\mathrm{DFT}_N$ corresponds to the vector $\mathbf{e}_k$ as defined above.

```python
def generate_matrix_dft(N, K):
    """Generate a DFT (discete Fourier transfrom) matrix

    Notebook: PCP_dft.ipynb

    Args:
        N: Number of samples
        K: Number of frequency bins

    Returns:
        dft: The DFT matrix
    """
    dft = np.zeros((K, N), dtype=np.complex128)
    time_index = np.arange(N)
    for k in range(K):
        dft[k, :] = np.exp(-2j * np.pi * k * time_index / N)
    return dft

N = 32
dft_matrix = generate_matrix_dft(N, N)

plt.figure(figsize=(10, 4))

plt.subplot(1, 2, 1)
plt.title('$\mathrm{Re}(\mathrm{DFT}_N)$')
plt.imshow(np.real(dft_matrix), origin='lower', cmap='seismic', aspect='equal')
plt.xlabel('Time (sample, index $n$)')
plt.ylabel('Frequency (index $k$)')
plt.colorbar()

plt.subplot(1, 2, 2)
plt.title('$\mathrm{Im}(\mathrm{DFT}_N)$')
plt.imshow(np.imag(dft_matrix), origin='lower', cmap='seismic', aspect='equal')
plt.xlabel('Time (samples, index $n$)')
plt.ylabel('Frequency (index $k$)')
plt.colorbar()
plt.tight_layout()
```

We now write a function that computes the discrete Fourier transform $X = \mathrm{DFT}_N \cdot x$ of a signal $x\in\mathbb{C}^N$. We apply the function from above sampled at $N=64$ time points. The peaks of the the magnitude Fourier transform $|X|$ correspond to the main frequency components the signal is composed of. Note that the magnitude Fourier transform is symmetrical around the center. Why? For the interpretation of the time and frequency axis, see also Exercise 1: Interpretation of Frequency Indices

```python
def dft(x):
    """Compute the discete Fourier transfrom (DFT)

    Notebook: PCP_dft.ipynb

    Args:
        x: Signal to be transformed

    Returns:
        X: Fourier transform of x
    """
    x = x.astype(np.complex128)
    N = len(x)
    dft_mat = generate_matrix_dft(N, N)
    return np.dot(dft_mat, x)

N = 64
x, t = LibPCP.signal.generate_example_signal(Fs=N, dur=1)
X = dft(x)

def plot_signal_dft(t, x, X, ax_sec=False, ax_Hz=False, freq_half=False, figsize=(10, 2)):
    """Plotting function for signals and its magnitude DFT

    Notebook: PCP_dft.ipynb

    Args:
        t: Time axis (given in seconds)
        x: Signal
        X: DFT
        ax_sec: Plots time axis in seconds
        ax_Hz: Plots frequency axis in Hertz
        freq_half: Plots only low half of frequency coefficients
        figsize: Size of figure
    """
    N = len(x)
    if freq_half is True:
        K = N // 2
        X = X[:K]
    else:
        K = N

    plt.figure(figsize=figsize)
    ax = plt.subplot(1, 2, 1)
    ax.set_title('$x$ with $N=%d$' % N)
    if ax_sec is True:
        ax.plot(t, x, 'k', marker='.', markersize='3', linewidth=0.5)
        ax.set_xlabel('Time (seconds)')
    else:
        ax.plot(x, 'k', marker='.', markersize='3', linewidth=0.5)
        ax.set_xlabel('Time (samples)')
    ax.grid()

    ax = plt.subplot(1, 2, 2)
    ax.set_title('$|X|$')
    if ax_Hz is True:
        Fs = 1 / (t[1] - t[0])
        ax_freq = Fs * np.arange(K) / N
        ax.plot(ax_freq, np.abs(X), 'k', marker='.', markersize='3', linewidth=0.5)
        ax.set_xlabel('Frequency (Hz)')

    else:
        ax.plot(np.abs(X), 'k', marker='.', markersize='3', linewidth=0.5)
        ax.set_xlabel('Frequency (index)')
    ax.grid()
    plt.tight_layout()
    plt.show()

plot_signal_dft(t, x, X)
plot_signal_dft(t, x, X, ax_sec=True, ax_Hz=True)
plot_signal_dft(t, x, X, ax_sec=True, ax_Hz=True, freq_half=True)
```

## Fast Fourier Transform (FFT)

Next, we discuss the famous fast Fourier transform (FFT), which is a fast algorithm to compute the DFT. The FFT algorithm was originally found by Gauss in about 1805 and  then rediscovered by Cooley and Tukey in 1965. The FFT algorithm is based on the observation that applying a DFT of even size $N=2M$ can be expressed in terms of  applying two DFTs of half the size $M$. It exploits the fact that there are algebraic relations between the entries $\sigma_N^{kn} = \mathrm{exp}(-2 \pi i / N)^{kn}$ of DFT matrices. In particular, one has

$$\sigma_M = \sigma_N^2$$

In the FFT algorithm, one computes the DFT of the even-indexed and the uneven-indexed entries of $x$:

\begin{align}
(A(0), \dots, A(N/2-1)) &= \mathrm{DFT}_{N/2} \cdot (x(0), x(2), x(4), \dots, x(N-2))\\
(B(0), \dots, B(N/2-1)) &= \mathrm{DFT}_{N/2} \cdot (x(1), x(3), x(5), \dots, x(N-1))
\end{align}

With these two DFTs of size $N/2$, one can compute the full DFT of size $N$ via:

\begin{eqnarray}
C(k) &=& \sigma_N^k \cdot B(k)\\
X(k) &=& A(k) + C(k)\\
X(N/2 + k) &=& A(k) - C(k)\\
\end{eqnarray}

for $k \in [0: N/2 - 1]$. The numbers $\sigma_N^k$ are also called *twiddle factors*. If $N$ is a power of two, this idea can be applied recursively until one reaches the computation of $\mathrm{DFT}_{1}$ (the case $N=1$), which is simply multiplication by one (i.e. just returning the signal of length $N=1$). For further details, we refer to Section 2.4.3 of [Müller, FMP, Springer 2015]) (see also Table 2.1).

In the following code, we provide a function `fft` that implements the FFT algorithm. We test the function  `fft` by comparing its output with the one when applying the `dft` on a test signal `x`. For the comparison of result matrices, we use the NumPy functions [`np.array_equal`](https://numpy.org/doc/stable/reference/generated/numpy.array_equal.html) and [`np.allclose`](https://numpy.org/doc/stable/reference/generated/numpy.allclose.html#numpy.allclose).

```python
def fft(x):
    """Compute the fast Fourier transform (FFT)

    Notebook: PCP_dft.ipynb

    Args:
        x: Signal to be transformed

    Returns:
        X: Fourier transform of x
    """
    x = x.astype(np.complex128)
    N = len(x)
    log2N = np.log2(N)
    assert log2N == int(log2N), 'N must be a power of two!'
    X = np.zeros(N, dtype=np.complex128)

    if N == 1:
        return x
    else:
        this_range = np.arange(N)
        A = fft(x[this_range % 2 == 0])
        B = fft(x[this_range % 2 == 1])
        range_twiddle_k = np.arange(N // 2)
        sigma = np.exp(-2j * np.pi * range_twiddle_k / N)
        C = sigma * B
        X[:N//2] = A + C
        X[N//2:] = A - C
        return X

N = 64
x, t = LibPCP.signal.generate_example_signal(Fs=N, dur=1)

X_via_dft = dft(x)
X_via_fft = fft(x)
X_via_fft_numpy = np.fft.fft(x)

is_equal = np.array_equal(X_via_dft, X_via_fft)
is_equal_tol = np.allclose(X_via_dft, X_via_fft)
is_equal_tol_np = np.allclose(X_via_dft, X_via_fft_numpy)

print('Equality test for dft(x) and fft(x) using np.array_equal:    ', is_equal)
print('Equality test for dft(x) and fft(x) using np.allclose:       ', is_equal_tol)
print('Equality test for dft(x) and np.fft.fft(x) using np.allclose:', is_equal_tol_np)
```

Note: The test shows that our dft and fft implementations do not yield the same result (due to numerical issues). However, the results are numerically very close, which is verified by the test using np.allclose.

The FFT reduces the overall number of operations from the order of $N^2$ (needed when computing the usual matrix–vector product $\mathrm{DFT}_N \cdot x$) to the order of $N\log_2N$. The savings are enormous. For example, using $N=2^{10}=1024$, the FFT requires roughly $N\log_2N=10240$ instead of $N^2=1048576$ operations in the naive approach. Using the module `timeit`, which provides a simple way to time small bits of Python code, the following code compares the running time when using the naive approach and the FFT. Furthermore, we compare the running time with the highly optimized NumPy implementation np.fft.fft.

```python
import timeit

rep = 3
for N in [256, 512, 1024, 2048, 4096]:
    time_index = np.arange(N)
    x = np.sin(2 * np.pi *  time_index / N )
    t_DFT = 1000 * timeit.timeit(lambda: dft(x), number=rep)/rep
    t_FFT = timeit.timeit(lambda: fft(x), number=rep*5)/(rep*5)
    t_FFT_np = timeit.timeit(lambda: np.fft.fft(x), number=rep*100)/(rep*100)
    print('Runtime (ms) for N = %4d : DFT %10.2f,  FFT %.5f,  FFT_np %.8f'%(N, t_DFT, t_FFT, t_FFT_np))
```

## Exercises and Results

```python
import LibPCP.dft
show_result = True
```

Exercise 1: Interpretation of Frequency Indices

Given a dimension $N\in\mathbb{N}$, the $\mathrm{DFT}_N$ transform a vector $x\in\mathbb{C}^N$ into another vector $X\in\mathbb{C}^N$. Assuming that $x$ represents a time-domain signal sampled with a sampling rate $F_\mathrm{s}$, one can associate the index $n\in[0:N-1]$ of the sample $x(n)$ with the physical time point $t = n/F_\mathrm{s}$ given in seconds. In case of the vector $X$, the index $k\in[0:N-1]$ of the coefficient $X(k)$ can be associated to a physical frequency value

$$
  \omega=\frac{k \cdot F_\mathrm{s}}{N}.
$$

Furthermore, using a real-valued signal $x\in\mathbb{R}^N$, the upper part of $X\in\mathbb{C}^N$ becomes redundant, and it suffices to consider the first $K$ coefficients with $K=N/2$.

Find explanations why these properties apply.

Find out how the function plot_signal_dft uses these properties to convert and visualize the time and frequency axes.

Using the signal x, t = LibPCP.signal.generate_example_signal(Fs=64, dur=2), plot the signal and its magnitude Fourier transform once using axes given in indices and once using axes given in physical units (seconds, Hertz). Discuss the results.

Do the same for the signal x, t = LibPCP.signal.generate_example_signal(Fs=32, dur=2). What is going wrong and why?

```python
#<solution>
# Your Solution
#</solution>
```

```python
LibPCP.dft.exercise_freq_index(show_result=show_result)
```

Exercise 2: Missing Time Localization

The Fourier transform yields frequency information that is averaged over the entire time axis. However, the information on when these frequencies occur is hidden in the transform. To demonstrate this phenomenon, construct the following two different signals defined on a common time axis $[0, T]$ with $T$ given in seconds (e.g., $T=6~\mathrm{sec}$).

A superposition of two sinusoids $f_1+f_2$ defined over the entire time interval $[0, T]$, where the first sinusoid $f_1$ has a frequency $\omega_1=1~\mathrm{Hz}$ and an amplitude of $1$, while the second sinusoid $f_2$ has a frequency $\omega_2=5~\mathrm{Hz}$ and an amplitude of $0.5$.

A concatenation of two sinusoids, where $f_1$ (specified as before) is now defined only on the subinterval $[0, T/2]$, and $f_2$ is defined on the subinterval $[T/2, T]$.

Sample the interval $[0,T]$ to obtain $N$ samples (use np.linspace), with $N\in\mathbb{N}$ being power of two (e.g., $N=256$). Define DT-signals of the superposition and the concatenation and compute the DFT for each of the signals. Plot the signals as well as the resulting magnitude Fourier transforms and discuss the result.

```python
#<solution>
# Your Solution
#</solution>
```

```python
LibPCP.dft.exercise_missing_time(show_result=show_result)
```

Exercise 3: Chirp Signal

The function $f(t)=\sin\left(\pi t^2\right)$ defines a chirp signal (also called sweep signal), in which the frequency increases with time. The instantaneous frequency $\omega_t$ of the chirp signal at time $t$ is the derivate of the sinusoid's argument divided by $2\pi$, thus $\omega_t = t$.

Let $[t_0,t_1]$ be a time interval (given in seconds) with $0\leq t_0<t_1$ and $N\in\mathbb{N}$ be power of two. Implement a function generate_chirp that outputs a sampled chirp signal x over the interval $[t_0,t_1]$ with $N$ samples (use np.linspace).

Compute the DFT of x for various input parameters $t_0$, $t_1$, and $N$. Plot the chirp signal as well as the resulting magnitude Fourier transform. Discuss the result.

```python
#<solution>
# Your Solution
#</solution>
```

```python
LibPCP.dft.exercise_chirp(show_result=show_result)
```

Exercise 4: Inverse DFT

The discrete Fourier transform given by the matrix $\mathrm{DFT}_N \in \mathbb{C}^{N\times N}$ is an invertible operation, given by the inverse DFT matrix $\mathrm{DFT}_N^{-1}$.

There is an explicit relation between $\mathrm{DFT}_N$ and its inverse $\mathrm{DFT}_N^{-1}$. Which one?

Write a function generate_matrix_dft_inv that explicitly generates $\mathrm{DFT}_N^{-1}$.

Check your function by computing $\mathrm{DFT}_N \cdot \mathrm{DFT}_N^{-1}$ and $\mathrm{DFT}_N^{-1} \cdot \mathrm{DFT}_N$ (using np.matmul) and comparing these products with the identity matrix (using np.eye and np.allclose).

Furthermore, compute the inverse DFT by using np.linalg.inv. Compare the result with your function using np.allclose.

Similar to fft, implement a fast inverse Fourier transform fft_inv

```python
#<solution>
# Your Solution
#</solution>
```

```python
LibPCP.dft.exercise_inverse(show_result=show_result)
```


# Bubonic Plage - SIR Model

### Grade 11 Social Studies

We are interested in modelling a bubonic plague outbreak. We part from the assumption that the total population can be subdivided into a set of classes, each of which depends on the state of the infection. The [**SIR Model**](https://en.wikipedia.org/wiki/Compartmental_models_in_epidemiology) is the simplest one and, as its name suggests, it divides the population into three classes.

**Outcomes:**
* Examine and visualize concepts and examples related to the bubonic plague.
* Examine the timeline/map of the Black Death.
* Visualize mathematical model that shows the recovery, infection, and removal rates.

## The SIR Outbreak Model

### Population Parameters

In this model, the total population is divided into three groups:

* Susceptible: individuals that can become infected but haven't been yet
* Infected: individuals that are infected
* Removed: individuals that are either dead or recovered

We are looking at the changes, over the course of an outbreak, of the numbers of these individuals, represented by $S$, $I$, and $R$. In other words we want to understand how, as time passes, the number of individuals in each group changes.

Having a realistic model might be useful for predicting the long-term outcome of an outbreak and informing public health interventions.

If we can predict that the number of removed people will stay low and the number of infected people will quickly go down to zero, then there is no need to intervene and we can let the outbreak end by itself while only providing medical attention to the infected people.

Conversely if we predict a large increase of the numbers of infected and removed individuals, then the outbreak needs a quick intervention before it results in a large number of casualties. In a plague outbreak this intervention would, for example, be to make sure there is no contact between infected and susceptible people.

We now describe the SIR (Susceptible, Infected, Removed) mathematical model of an outbreak over time (for example every week). We write $S_t, I_t, R_t$ to denote the number of susceptible, infected, and removed individuals at time point $t$. $t=1$ is the first recorded time point, $t=2$ is the second and so on. We call *time unit* the time elapsed between two time points, for example a day or a week.

In this model, we assume that the **total population is constant** (so births and deaths are ignored) for the duration of the model simulation. We represent the total population size by $N$, and so at any time point $t$ we have $$N=S_t + I_t + R_t$$

### Modelling the disease progression

We assume that transmission requires contact between an infected individual and a susceptible individual. We also assume that the disease takes a constant amount of time to progress within an infected individual until they are removed (die or recover). We need to define these two processes (infection and removal) and model how they impact the transition from the time $t = (S_t,I_t,R_t)$ to the next state $t + 1 = (S_{t+1},I_{t+1},R_{t+1})$.

The occurrences of new infections of is modelled using a parameter $\beta$, that gives the proportion of contacts between susceptible people and infected people, during one time unit, that results in infection. Then we can describe the number of newly infected people as $\dfrac{\beta S_t I_t}{N}$, where the term $S_t I_t$ represents the set of all possible contacts between susceptible and infected individuals. We discuss this term later.

The occurrence of removals of infected people is modelled using a parameter denoted by $\gamma$. It is defined to be proportion of infected individuals that die or recover between two time points. If we are given that the duration of an infection is $T$ (i.e. how many time points it takes for an individual between infection and removal), then $\gamma = \dfrac{1}{T}$.

Taking into account the rate of contact $\beta$ and rate of removal $\gamma$, then each group population changes within one unit of time as follows

$$
\begin{align}
S_{t+1} &= S_t -  \dfrac{{\beta} S_t I_t}{N}\\
I_{t+1} &= I_t +  \dfrac{{\beta} S_t I_t}{N} - \gamma I_t \\
R_{t+1} &= R_t + \gamma I_t\\
N&=S_t + I_t + R_t
\end{align}
$$

These equations form the SIR model. They allow, from knowing the parameters of the model ($\beta$ and $\gamma$) and the current state ($S_t,I_t,R_t$) of a population to predict the next states of the population for later time points. Such models are critical in our days for monitoring and controlling infectious diseases outbreaks.

##### Technical remarks.
First, note that the SIR model does not enforce that the values $S_t,I_t,R_t$ at a given time point are integers. As $\beta$ and $\gamma$ are actually floating numbers, these values are actually most of the time not integers. This is fine as the SIR model is an approximate model and aims mostly at predicting the general dynamics of an outbreak, not the precise values for the number of susceptible, infected, and removed individuals.

Next, one can ask how to find the values of the parameters $\beta$ and $\gamma$ that are necessary to have a full SIR model.

As discussed above, the parameter $\gamma$ is relatively easy to find from knowing how the disease progress in a patient, as it is mostly the inverse of the average time a patient is sick.

The parameter $\beta$ is less easy to obtain. Reading the equations we can see that during a time point, out of the $S_t$ susceptible individuals, the number that get infected is $(\dfrac{{\beta}}{N}) S_t I_t$. As mentioned above, the product $S_t I_t$ can be interpreted as the set of all possible contacts between the $S_t$ susceptible individuals and the $I_t$ infected individuals and is often a large number, much larger than $S_t$ and in the order of $N^2$. The division by $N$ aims to lower this number, mostly to normalize it by the total population, to make sure it is in order of $N$ and not quadratic in $N$. So in order for the number of newly infected individuals during a time unit to be reasonable, $\beta$ is generally a  small number between $0$ and $1$. But formally, if we pick a value for $\beta$ that is too large, then the SIR model will predict value for $S_t$ that can be negative, which is inconsistent with the modelled phenomenon. So choosing the value of $\beta$ is the crucial step in modelling an outbreak.

```python
# This function takes as input a vector y holding all initial values,
#    t: the number of time points (e.g. days)
#    beta: proportion of contacts that result in infections
#    gamma: proportion of infected that are removed
#    S1,I1,R1 = initial population sizes

def discrete_SIR(S1,I1,R1,t,beta,gamma):
    # Empy arrays for each class
    S = [] # susceptible population
    I = [] # infected population
    R = [] # removed population
    N = S1+I1+R1 # the total population

    # Append initial values
    S.append(S1)
    I.append(I1)
    R.append(R1)

    # apply SIR model: iterate over the total number of days - 1
    for i in range(t-1):
        S_next = S[i] - (beta/N)*((S[i]*I[i]))
        S.append(S_next)

        I_next = I[i] + (beta/N)*((S[i]*I[i])) - gamma*I[i]
        I.append(I_next)

        R_next = R[i] + gamma * I[i]
        R.append(R_next)

    # return arrays S,I,R whose entries are various values for susceptible, infected, removed
    return((S,I,R))
```

##  Modelling an outbreak related to the Great Plague of London

The last major epidemic of the bubonic plague in England occurred between 1665 and 1666 ([click here for further reading](https://www.britannica.com/event/Great-Plague-of-London)). This epidemic did not kill as many people as the Black Death (1347 - 1351), however it is remembered as the "Great Plague of London" as it was the last widespread outbreak that affected England.

"City records indicate that some 68,596 people died during the epidemic, though the actual number of deaths is suspected to have exceeded 100,000 out of a total population estimated at 460,000. " [Great Plague of London"; Encyclopædia Britannica; Encyclopædia Britannica, inc.; September 08, 2016](https://www.britannica.com/event/Great-Plague-of-London)

When the bubonic plague outbreak hit London, people started to leave the city and go to the countryside, hoping to avoid the disease. But as can be expected, some of these people were already infected when they left London, and so carried the disease to start other outbreaks in some nearby villages. This happened in the village of Eyam.

When Eyam authorities realized a plague outbreak had started, they took the difficult decision to close the village in order to avoid to spread the disease further. So nobody was allowed to enter or leave the village and people stayed there hoping the outbreak would end by itself without too many casualties; note that from a mathematical point of view, that implies that the assumption that the sum of the numbers of susceptible, infected and removed individuals, the population, is constant.

Also the village authorities recorded regularly the number of infected and dead people; these data are described in the table below, for the period from June 19 1665 to October 19 1665, with data being recorded every 2 weeks. Obviously these data are imperfect (some people did not declare they were sick by fear of being ostracized, some people died too fast for the plague to be diagnosed, etc.), but nevertheless, they provide us with interesting data to see if the SIR model is an appropriate model for such a plague outbreak.

| Date  |Day Number |Susceptible | Infected |
|-------||-------------|----------|
|June 19 1665|0|254|7|
|July 3 1665|14|235|14|
|July 19 1665|28|201|22|
|Aug 3 1665|42|153|29|
|Aug 19 1665|56|121| 21|
|Sept 3 1665|70|108|8|
|Sept 19 1665|84|121|21|
|Oct 3 1665| 98|NA | NA|
|Oct 19 1665|112| 83 | 0|

The average time an infected individual remains infected by the bubonic plague is 11 days.

With the information above, we will be able to get the parameters of the SIR model for this outbreak and observe if indeed what this model predicts generates results corresponding to what happened in reality.

### Question 1:

Assuming that on June 19 no individuals had died, i.e. no one was in the Removed class, what is the value of $N$, i.e. the number of individuals in the total population?

### Question 2:

We know that the average time an individual remained infected is 11 days. What is the rate of removal ($\gamma$)?

### Question 3:

We are now trying something more difficult but more interesting. We introduced a mathematical model for outbreaks, but nothing so far shows that this SIR model is appropriate to model an outbreak such as the Eyam plague outbreak. We want to answer this question now.

From questions 1 and 2 above we know the values of $N$ and $\gamma$ (check your answers at the bottom of this notebook). From the data table we also know $S_1,I_1,R_1$, the state of the population at the start of the outbreak. So if we want to apply the SIR model we need to find  a value for $\beta$ the parameter, the number susceptible people becoming infected during a time unit. We consider here that a time unit is 1 day; the Eyam outbreak spanned 112 days, so 112 time units, even if data were only recorded every 2 weeks.

A standard scientific approach for the problem of finding $\beta$ is to try various values and see if there is one that leads to predicted values for $S_n,I_n,R_n$ that match the observed data. In order to evaluate this match, we focus on the number of infected people, the most important element of an outbreak.

The code below allows you to do this: you can choose a value of $\beta$, click on the "Run interact" button and it will show on the same graph a set of 8 blue dots (the observed number of infected people from the data table) and a set of 112 red dots, corresponding to the predicted number of infected individuals for the chosen value of $\beta$.

While there are several mathematical ways to define what would be the *best fit*, here we are not getting into this and you are just asked to try to find a value of $\beta$ that generated blue dots being approximately on the graph defined by the red dots. Pay particular attention to the first four blue dots.

Note that in this case $0 < \beta < 1$.

##### Warning:
The SIR model is a very simple approximation of the dynamics of a true outbreak, so don't expect to find a value of $\beta$ that generates a graph that contains exactly all observed data points (blue dots).

In particular note that the data from September 3 and 19 seem to be somewhat of an anomaly as we observe a sharp decrease in the number of infected followed by a surge. This could be due to many reasons, for example poor statistics recording (we are considering a group of people under heavy stress likely more motivated by trying to stay alive than to record accurate vital statistics).

So here we are interested in finding a parameter $\beta$ that captures the general dynamics (increase followed by a post-peak decrease) of the outbreak. You can expect to find a reasonable value for $\beta$ but be aware that many values, especially too high, will result in a very poor match between observed data and model predictions.

```python
from ipywidgets import interact_manual, interact,widgets
import matplotlib.pyplot as plt

# set style
s = {'description_width': 'initial'}

# Set interact manual box widget for beta
@interact(answer=widgets.FloatText(value=0.50, description='Enter beta ',
    disabled=False, style=s, step=0.01
))

# define function to find the appropriate value of beta
# this function takes as input a floating value and outputs a plot with the best fit curve
def find_beta(answer):

    # set initial values for SIR model
    S1,I1,R1 = 254,7,0

    # Use original data on Number of infected from table in the notebook
    ori_data = [7,14,22,29,21,8,21,0]

    # use days, time data was provided biweekly, we transform to days here
    ori_days = [1,14,28,42,56,70,84,112]

    # set number of days as the second to last entry on the ori_days array
    n = ori_days[len(ori_days)-1]-ori_days[0]+1

    # get beta from answer - to be sure transform to float
    beta = float(answer)

    # Gamma was obtained from disease
    gamma = 1/11

    # Compute SIR values using our discrete_SIR function
    (S,I,R) = discrete_SIR(S1,I1,R1,n,beta,gamma)

    # Figure
    #fig,ax = plt.subplot(figsize=(10,10))
    fig = plt.figure(facecolor='w',figsize=(17,5))
    ax  = fig.add_subplot(111,facecolor = '#ffffff')
    # Scatter plot of original number of infected in the course of 112 days

    plt.scatter(ori_days,ori_data,c="blue", label="Original Data")

    # Scatter plot of infected obtained from SIR mode, in the course of 112 days
    plt.scatter(range(n),I,c="red",label="SIR Model Predictions")

    # Make the plot pretty
    plt.xlabel('Time (days)')
    plt.ylabel('Infected Individuals')
    plt.title('Real Data vs Model')
    #legend = ax.legend()
    plt.show()

```

    interactive(children=(FloatText(value=0.5, description='Enter beta ', step=0.01, style=DescriptionStyle(descri…

## Simulating a Disease Outbreak

To conclude we will use the widgets below to simulate a disease outbreak using the SIR model.
You can choose the values of all the elements of the model (sizes of the compartments of the population at the beginning of the outbreak, parameters $\gamma$ and $\beta$, and duration in time units (days) of the outbreak. The default parameters are the ones from the Eyam plague outbreak.

The result is a series of three graphs that shows how the three components of the population change during the outbreak. It allows you to see the impact of changes in the parameters $\gamma$ and $\beta$, such as increasing $\beta$ (making the outbreak progress faster) or reducing $\gamma$ (decreasing the removal rate).

You can use this interactive tool to try to fit the SIR model to match the observed data.

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

# This function takes as input initial values of susceptible, infected and removed, number of days, beta and gamma
# it plots the SIR model with the above conditions
def plot_SIR(S1,I1,R1,n,beta,gamma):

    # Initialize figure
    fig = plt.figure(facecolor='w',figsize=(17,5))
    ax  = fig.add_subplot(111,facecolor = '#ffffff')

    # Compute SIR values for our initial data and parameters
    (S_f,I_f,R_f) = discrete_SIR(S1,I1,R1,n,beta,gamma)

    # Set x axis
    x = [i for i in range(n)]

    # Scatter plot of evolution of susceptible over the course of x days
    plt.scatter(x,S_f,c= 'b',label='Susceptible')

    # Scatter plot of evolution of infected over the course of x days
    plt.scatter(x,I_f,c='r',label='Infected')

    # Scatter plot of evolution of removed over the course of x days
    plt.scatter(x,R_f,c='g',label='Removed')

    # Make the plot pretty
    plt.xlabel('Time (days)')
    plt.ylabel('Number of individuals')
    plt.title('Simulation of a Disease Outbreak Using the SIR Model')
    legend = ax.legend()
    plt.show()

    # Print messages to aid student understand and interpret what is happening in the plot
    print("SIMULATION DATA\n")
    print("Beta: " + str(beta))
    print("Gamma: " + str(gamma))
    print("\n")
    print("Initial Conditions:")
    print("Total number of Susceptible: "  + str(ceil(S_f[0])))
    print("Total number of Infected: "  + str(ceil(I_f[0])))
    print("Total number of Removed: "  + str(ceil(R_f[0])))
    print("\n")
    print("After " + str(n) + " days:")
    print("Total number of Susceptible: "  + str(ceil(S_f[n-1])))
    print("Total number of Infected: "  + str(ceil(I_f[n-1])) )
    print("Total number of Removed: "  + str(ceil(R_f[n-1])))

# Tweaking initial Values
from ipywidgets import widgets, interact, interact_manual

# Set function above so that the user can set all parameters and manually start simulation
s = {'description_width': 'initial'}
interact(plot_SIR,
        S1 = widgets.IntSlider(value=254, min=200, max=1000, step=1, style=s, description="Susceptible Initial",
                               disabled=False, orientation='horizontal', readout=True),
        I1 = widgets.IntSlider(value=7, min=0, max=500, step=1, style=s, description="Infected Initial",
                               disabled=False, orientation='horizontal', readout=True),
        R1 = widgets.IntSlider(value=0, min=0, max=500, step=1, style=s, description="Removed Initial",
                               disabled=False, orientation='horizontal', readout=True),
        n = widgets.IntSlider(value=112, min=0, max=500, step=1, style=s, description="Time (days)",
                              disabled=False, orientation='horizontal', readout=True),
        beta = widgets.FloatText(value=1.50, description=r'$ \beta$ parameter',
                                 disabled=False, style = s, step=0.01),
        gamma = widgets.FloatText(value=1.50, description= r'$ \gamma$ parameter',
                                  disabled=False, style=s, step=0.01)
        );
```

    interactive(children=(IntSlider(value=254, description='Susceptible Initial', max=1000, min=200, style=SliderS…

### Answer 1
Since we are assuming the population is constant, and since $S_1 = 254, I_1 = 7, R_1 = 0$, then  $S_1 + I_1 + R_1 = 254 + 7 + 0  = 261$.

### Answer 2
We know that, on average, an individual will remain infected for approximately 11 days. This means that one individual moves to the removed class for every 11 days, and the rate of removal is $\gamma = \frac{1}{11} = 0.0909...$.

### Answer 3
The best value is approximately $\beta = 0.14909440503418078$.

Conclusion

In this notebook we learned about the SIR discrete model to model an outbreak. We learned that this model is one of the simplest ones and that it separates the total population $N$ (a constant) into three categories: Infected, Susceptible and Removed. We learned about rates of infection and removal and how this affects the number of individuals in each class.

We also ran a basic but realistic simulation of a bubonic plague outbreak of the Great Plague of London that took place in the village Eyam in 1665 and learned about the devastating effect this had on the population.

[](https://github.com/callysto/curriculum-notebooks/blob/master/LICENSE.md)


# Two Dimensional Fractal Integration

To generalize the concepts given in the previous section for FIFs to the FISs, consider that $\Omega = \{\tilde{P}_j = (\tilde{x}_j, \tilde{y}_j), \; j = 1, 2, 3\}$ is a triangular domain in the plane. Let $P = \{P_i = (x_i, y_i), \; i = 1, \ldots, N\}$ be given points in the plane scattered over $\Omega$ containing the points $\tilde{P}_j, \; j = 1, 2, 3$. A triangulation $T(\Omega)$ of $\Omega$ over the points $P$ is given with the set,
$$
\begin{equation}
T(\Omega) = \{ \Omega^i = \{P_j^i\} : P_j^i \in P, \; j = 1, 2, 3, \;  i  = 1, \ldots, K \}
\end{equation}
$$
Note that $T(\Omega)$ consists of non-degenerate triangles such that $\Omega^i \cap \Omega^j = \emptyset, \; i \neq j$ and $\Omega = \cup_{i  = 1}^K \Omega^i$.

Consider that each $P_i = (x_i, y_i) \in P$ is associated with $z_i \in \mathbb{R}$. An interpolation function--also called interpolant, corresponding to the data set $\{(x_i, y_i, z_i), \; i = 1, \ldots, N\}$  is a continuous function $f: \Omega \mapsto \mathbb{R}$ such that $f(x_i, y_i) = z_i$. The answer to the problem of constructing the interpolation function $f$ is in two folds:

1. Construction of an IFS $\mathcal{I} = \{\Omega \times \mathbb{R}; w_i; \; i = 1, \ldots, K \}$ whose attractor is the graph$G$ of the function $f$ satisfying the interpolation points, i.e.$f(x_i, y_i) = z_i, \; i = 1, \ldots, N$.

2. Construction of a contraction mapping $M: \mathcal{F} \mapsto\mathcal{F}$ where $\mathcal{F} =\{ \tilde{f} \in \mathcal{C}(\Omega) : \tilde{f}(\tilde{x}_j, \tilde{y}_j) = \tilde{z}_j \}$ such that the fixed point $f$ of the mapping $M$ satisfies the interpolation points, i.e. $f(x_i, y_i) = z_i, \; i = 1, \ldots, N$

### Construction of the IFS $\mathcal{I}$

Addressing to the first step, we put forward the following IFS $I = \{ \Omega \times \mathbb{R}; w_i, \; i = 1, \ldots, K \}$ with affine transformations $w_i$,
$$
\begin{aligned}
     w_i(x, y, z) &= (L_i(x, y), F_i(x, y, z))\\
    &=\begin{bmatrix}
    \alpha_1^i & \alpha_2^i & 0 \\
    \alpha_3^i & \alpha_4^i & 0 \\
    \alpha_5^i & \alpha_6^i & \alpha_7^i
    \end{bmatrix}
    \begin{bmatrix}
    x \\ y \\ z
    \end{bmatrix} + \begin{bmatrix}
    \beta_1^i \\ \beta_2^i \\ \beta_3^i
    \end{bmatrix}
     \; i = 1, \ldots, K,
    \end{aligned}
$$
where $L_i : \Omega \mapsto \Omega^i$
$$
L_i(x, y) = \begin{bmatrix}
    \alpha_1^i & \alpha_2^i \\ \alpha_3^i & \alpha_4^i
    \end{bmatrix} \begin{bmatrix}
    x \\ y
    \end{bmatrix} + \begin{bmatrix}
    \beta_1^i \\ \beta_2^i
    \end{bmatrix}
$$
are contraction mappings for the $z$ axis satisfying the boundary conditions,
$$
F_i(\tilde{x}_j, \tilde{y}_j, \tilde{z}_j) = z_j^i, \; j = 1, 2, 3.
$$
where $\alpha_7^i$ are arbitrary contractivity factors satisfying $|\alpha_7^i| < 1, \; i = 1, \ldots, K$, also called as vertical scaling factors.

Given the vertical scaling factors $\alpha_7^i, \; i = 1, \ldots, K$, the coefficients $\alpha_k^i, \; k = 1 , \ldots, 6, \; i = 1, \ldots, K$ can be found using the boundary conditions in (\ref{eq: plane boundary conditions}) and (\ref{eq: z values boundary conditons}) which results in following system of equations,
$$
\begin{aligned}
\alpha_1^i \tilde{x}_j + \alpha_2^i \tilde{y}_j + \beta_1^i &= x_j^i \\
\alpha_3^i \tilde{x}_j + \alpha_4^i \tilde{y}_j + \beta_2^i &= y_j^i \\
\alpha_5^i \tilde{x}_j + \alpha_6^i \tilde{y}_j + \beta_3^i &= z_j^i - \alpha_7^i \tilde{z}_j
\end{aligned}
$$
for $i = 1, \ldots, K, \; j = 1, 2, 3$. This system can be rewritten in block diagonal matrix equation system,
$$
\begin{bmatrix}
P & 0 & \ldots & 0 \\
0 & P & \ldots & 0 \\
\vdots & \vdots & \ddots & \vdots \\
0 & 0 & \ldots & P \\
\end{bmatrix}
\begin{bmatrix}
r_1 \\ r_2 \\ r_3
\end{bmatrix} = \begin{bmatrix}
c_1 \\ c_2 \\ c_3
\end{bmatrix}
$$
where
$$
\begin{aligned}
P &=
\begin{bmatrix}
\tilde{x}_1 & \tilde{y}_1 & 1 \\
\tilde{x}_2 & \tilde{y}_2 & 1 \\
\tilde{x}_3 & \tilde{y}_3 & 1 \\
\end{bmatrix}\\
r_1 &= [r_1^1, \ldots, r_1^i, \ldots, r_1^K], \; r_1^i = [\alpha_1^i, \alpha_2^i, \beta_1^i] \\
r_2 &= [r_2^1, \ldots, r_2^i, \ldots, r_2^K], \; r_2^i = [\alpha_3^i, \alpha_4^i, \beta_2^i]  \\
r_3 &= [r_3^1, \ldots, r_3^i, \ldots, r_3^K], \; r_3^i = [\alpha_5^i, \alpha_6^i, \beta_3^i] \\
c_1 &= [c_1^1, \ldots, c_1^i, \ldots, c_1^K], \; c_1^i = [x_1^i, x_2^i, x_3^i] \\
c_2 &= [c_2^1, \ldots, c_2^i, \ldots, c_2^K], \; c_2^i = [y_1^i, y_2^i, y_3^i] \\
c_3 &= [c_3^1, \ldots, c_3^i, \ldots, c_3^K], \; c_3^i = [z_1^i, z_2^i, z_3^i] - \alpha_7^i [\tilde{z}_1^i, \tilde{z}_2^i, \tilde{z}_3^i]1
\end{aligned}
$$
We  have uncoupled system of equations,
$$
P r_j^i = c_j^i, \; j = 1, 2, 3, \; i = 1, \ldots, N
$$
Since the points $\{\tilde{P}_j, \; j = 1, 2, 3\}$ forms a non-degenerate triangular region $\Omega$, $P^{-1}$ exists and can be solved for the coefficients of the IFS $\mathcal{I}$ which gives,
$$
r_j^i = P^{-1} c_j^i, \; j = 1, 2, 3, \; i = 1, \ldots, K
$$

Inspired by the reasoning given in Theorem 1, we propose the following iterative map to find the fractal interpolant.

Given the triangulation $T(\Omega)$ and the IFS $\mathcal{I}$ defined above, let $(\mathcal{F}, d)$ be a complete metric space such that $\mathcal{F} = \{ \tilde{f} \in \mathcal{C}(\Omega) : \tilde{f}(\tilde{x}_j, \tilde{y}_j) = \tilde{z}_j, \; j = 1, 2, 3 \}$ with the metric $d(\tilde{f}_1, \tilde{f}_2) = max \{ |\tilde{f}_1(x, y) - \tilde{f}_2(x, y)|\}, \; x, y \in \Omega$. Let $M : \mathcal{F} \mapsto \mathcal{F}$ be defined by
$$
(M\tilde{f})(x, y) = F_i(L_i^{-1}(x, y), \tilde{f}(L_i^{-1}(x, y)), \; x, y \in \Omega^i, \; i = 1, \ldots, K
$$
Then, $M$ has a unique fixed point $f$ such that $f = \lim_{n \mapsto \infty} M^n(\tilde{f})$ for any  $\tilde{f} \in \mathcal{F}$ which interpolates the given data set, i.e. $f(x_i, y_i) = z_i, \; i = 1, \ldots, N$.

```julia
# This dimensinal fractal integration.

using FractalTools
using Triangulation
using PyPlot
using Statistics

# Read mesh
datapath = joinpath(dirname(dirname(pathof(FractalTools))),  "data/gmsh_data")
filename = "t3.msh"
domain = read_mesh(joinpath(datapath, filename))
scale_factor = 1   # Rescale the mesh.
for node in domain.nodes
    node.x /= scale_factor
    node.y /= scale_factor
end
domains = triangular_partition(domain)
regions = gmsh2matplotlibtri.(domains)

# Define function
func(x, y) = x.^2 .+ y.^2
number_of_regions = length(regions)
z = Vector{Vector}(undef, number_of_regions)
for k = 1 : number_of_regions
    z[k] = func(regions[k].x, regions[k].y)
end

# Refine region to evaluate interpolated function
subdiv = 2
x, y = refine(gmsh2matplotlibtri(domain), subdiv, true)

# Compute initial and real values.
func0(xi, yi) = 0.

α = 0.001
interpolant, coeffs = fis(regions, z, α=α, func0=func0, num_iter=10, get_coeffs=true)

# Interpolation and error
real_values = func.(x, y)
interpolated_values = interpolant.(x, y)
absolute_error = abs.(interpolated_values - real_values)
relative_error = 100 * map(i -> abs(real_values[i]) <= 0.1 ? absolute_error[i] : absolute_error[i] / abs(real_values[i]),
    1 : length(absolute_error));
# frerr = mean(absolute_error.^2)

# Filtered relative error
# threshold = 1.
# mask = x.^2 .+ y.^2 .< threshold
# idx = findall(mask)
# relative_error_fltr = copy(relative_error)
# relative_error_fltr[idx] .= 0.
```

```julia
nothing
```

```julia
# # Plot the results.
xd = xcoords(domain)
yd = ycoords(domain)
zd = func.(xd, yd)
minx, meanx, maxx = minimum(x), mean(x), maximum(x)
miny, meany, maxy = minimum(y), mean(y), maximum(y)
plot_trisurf(x, y, real_values)

coluors = ["black", "red", "blue", "green", "purple", "magenta"]
for (color, region) in zip(coluors, regions)
    triplot(region, color=color)
    plot(region.x, region.y, ".", color="orange")
end
xlabel("x", labelpad=10, fontsize=12)
ylabel("y", labelpad=10, fontsize=12)
zlabel("z", labelpad=10, fontsize=12)
xticks(range(minx, maxx, length=5))
yticks(range(miny, maxy, length=5))
zticks(range(minimum(real_values), maximum(real_values), length=5));

```

```julia
plot_trisurf(x, y, interpolated_values)
for (color, region) in zip(coluors, regions)
    triplot(region, color=color)
    plot(region.x, region.y, ".", color="orange")
end
xlabel("x", labelpad=10, fontsize=12)
ylabel("y", labelpad=10, fontsize=12)
zlabel("z", labelpad=10, fontsize=12)
xticks(range(minx, maxx, length=5))
yticks(range(miny, maxy, length=5))
zticks(range(minimum(interpolated_values), maximum(interpolated_values), length=5))
tight_layout()
```

```julia
plot_trisurf(x, y, absolute_error)
for (color, region) in zip(coluors, regions)
    triplot(region, color=color)
    plot(region.x, region.y, ".", color="orange")
end
xlabel("x", labelpad=10, fontsize=12)
ylabel("y", labelpad=10, fontsize=12)
zlabel("z", labelpad=10, fontsize=12)
xticks(range(minx, maxx, length=5))
yticks(range(miny, maxy, length=5))
zticks(range(minimum(absolute_error), maximum(absolute_error), length=5))
tight_layout()
```

```julia
plot_trisurf(x, y, relative_error)
for (color, region) in zip(coluors, regions)
    triplot(region, color=color)
    plot(region.x, region.y, ".", color="orange")
end
xlabel("x", labelpad=10, fontsize=12)
ylabel("y", labelpad=10, fontsize=12)
zlabel("z", labelpad=10, fontsize=12)
xticks(range(minx, maxx, length=5))
yticks(range(miny, maxy, length=5))
zticks(range(minimum(relative_error), maximum(relative_error), length=5))
tight_layout()
```

```julia
plot_trisurf(x, y, relative_error_fltr)
for (color, region) in zip(coluors, regions)
    triplot(region, color=color)
    plot(region.x, region.y, ".", color="orange")
end
xlabel("x", labelpad=10, fontsize=12)
ylabel("y", labelpad=10, fontsize=12)
zlabel("z", labelpad=10, fontsize=12)
xticks(range(minx, maxx, length=5))
yticks(range(miny, maxy, length=5))
zticks(range(minimum(relative_error_fltr), maximum(relative_error_fltr), length=5))
tight_layout()
```

```julia
fig, ax = subplots(1)
for region in regions
    ax.triplot(region)
    ax.plot(region.x, region.y, ".", color="orange")
end
ax.set_xlabel("x", fontsize=12)
ax.set_ylabel("y", fontsize=12)
tight_layout()
```


```python
from statsmodels.tsa.seasonal import seasonal_decompose
import matplotlib.pyplot as plt
import scipy.stats as st
import seaborn as sns
import pandas as pd
from scipy.stats import norm, uniform, expon, t, probplot
import scipy.stats as st
from scipy.integrate import quad
from sympy.solvers import solve
from sympy import Symbol
import numpy as np
from pandas import Series, DataFrame
from statsmodels.graphics.tsaplots import plot_acf
from statsmodels.tsa.stattools import acf
from pandas.plotting import lag_plot
```

```python
Tesla = pd.read_csv("Tesla.csv", sep="\t",header=0)
Tesla["Date"] = pd.DatetimeIndex(Tesla["Date"])
Tesla.set_index("Date", inplace=True)
Tesla["log_volume"] = np.log(Tesla["Volume"])
Tesla["log_return"] = Tesla["log_volume"] - Tesla["log_volume"].shift(1)
Tesla["log_return"].plot()
```

```python
Tesla = pd.read_csv("Tesla.csv", sep="\t",header=0)
Tesla["Date"] = pd.DatetimeIndex(Tesla["Date"])
Tesla.set_index("Date", inplace=True)
Tesla["log_volume"] = np.log(Tesla["Volume"])
Tesla["log_return"] = Tesla["log_volume"] - Tesla["log_volume"].shift(1)
fig, (ax1, ax2) = plt.subplots(ncols=2)
Tesla["log_return"].plot(ax=ax1)
plot_acf(DataFrame(Tesla["log_return"]).dropna(), lags=12, ax=ax2)
```

```python
from scipy import signal
import matplotlib.pyplot as plt
import numpy as np
from statsmodels.tsa.stattools import acf
t = np.linspace(0, 1, 1000, endpoint=False)
noise = np.random.normal(size=1000)
signal = 0.5*t
plt.plot(t, signal + noise)
plt.ylim(-2, 2)
acf(signal + noise)[1]
plot_acf(signal + noise)
```

```python
from scipy import signal
import matplotlib.pyplot as plt
import numpy as np
t = np.linspace(0, 1, 1000, endpoint=False)
noise = np.random.normal(size=1000)
signal = np.cos(2 * np.pi * 20 * t)
fig, (ax1, ax2) = plt.subplots(ncols=2)
Series(signal + noise).plot(ax=ax1)
plt.ylim(-2, 2)
plot_acf(DataFrame(signal+noise).dropna(), lags=12, ax=ax2)
```

```python
plot_acf(DataFrame(signal+noise).dropna(), lags=120, ax=ax2)
```

```python
from scipy import signal
import matplotlib.pyplot as plt
import numpy as np
t = np.linspace(0, 1, 1000, endpoint=False)
noise = np.random.normal(size=1000)
fig, (ax1, ax2) = plt.subplots(ncols=2)
Series(signal.square(2 * np.pi * 20 * t) + noise).loc[:10].plot(ax=ax1)
plot_acf(DataFrame(signal.square(2 * np.pi * 20 * t) + noise).dropna(), lags=120,ax=ax2)
```

```python
from scipy import signal
import matplotlib.pyplot as plt
import numpy as np
t = np.linspace(0, 1, 1000, endpoint=False)
noise = np.random.normal(size=1000)
signal = signal.sawtooth(2 * np.pi * 20 * t)
plt.plot(t, signal + noise)
plt.ylim(-2, 2)
fig, (ax1, ax2) = plt.subplots(ncols=2)
Series(signal+noise).plot(ax=ax1)
plot_acf(Series(signal+noise).dropna(), lags=80,ax=ax2)
```

```python

```


```python
from sympy import *
from sympy.abc import *
import numpy as np
init_printing()

import warnings
warnings.filterwarnings("ignore")

#kt de flexão, kt de torção
ktf, ktt, sf, sut, nf = symbols("K_t_f K_t_t S_f' S_{ut} N_f")

```

```python
#Tensões reais de flexão
flex = ktf*(32*M/(pi*D**3))

#Tensões reais de torção
torc = ktt*((16*T/2)/pi*D**3)
flex, torc
```

```python
#Tensão de von-misses
sigma_a = sqrt(flex**2 + 3*torc**2)
sigma_m = sqrt(3*torc**2)
sigma_a, sigma_m
```

```python
#Critério de falha
crit = Eq(sigma_a/sf + sigma_m/sut, 1/nf)
crit
```

```python
ktf, ktt =  2.16, 1.633
M = 70 #N.m - Momento de flexão
T = 45 #N.m - Torque de torção
sf = 373.3*1E6
sut = 1226*1E6
nf = 2.5
pi = np.pi

crit.evalf(3)
```

```python
solve(Eq(x**2 + x +2, 0))
```

```python

```

```python
a=1.0924e-5*2.5
```

```python
(a)**(1/3)
```

```python

```


# Timeseries prediction of the Mackey-Glass Equation with ESNs

## Introduction

The Mackey-Glass system is essentially the differential equation, where we set the parameters to $\alpha = 0.2$, $\beta = 10$, $\gamma = 0.1$ and the time delay $\tau = 17$ in  order to have a mildly chaotic attractor.

\begin{align}
\label{eq:MackeyGlass}
\dot{y}(t) = \alpha y(t-\tau) / (1 + y(t - \tau)^{\beta}) - \gamma y(t)
\end{align}

```python
import numpy as np
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_squared_error, make_scorer
from sklearn.preprocessing import MinMaxScaler
from sklearn.model_selection import TimeSeriesSplit, GridSearchCV

%matplotlib inline
from matplotlib import pyplot as plt
import seaborn as sns

sns.set_theme()

from pyrcn.echo_state_network import ESNRegressor
from pyrcn.extreme_learning_machine import ELMRegressor
from pyrcn.model_selection import SequentialSearchCV
from pyrcn.datasets import mackey_glass
```

Load the dataset and rescale it to a range of [-1, 1]

```python
# Load the dataset
X, y = mackey_glass(n_timesteps=20000)
scaler = MinMaxScaler(feature_range=(-1, 1)).fit(X=X.reshape(-1, 1))
X = scaler.transform(X=X.reshape(-1, 1))
y = scaler.transform(y.reshape(-1, 1)).ravel()
```

Define Train/Test lengths

```python
trainLen = 1900 # number of time steps during which we train the network
testLen = 2000 # number of time steps during which we test/run the network

X_train = X[:trainLen]
y_train = y[:trainLen]
X_test = X[trainLen:trainLen+testLen]
y_test = y[trainLen:trainLen+testLen]
```

Visualization

```python
fix, axs = plt.subplots()
sns.lineplot(data=X_train.ravel(), ax=axs)
sns.lineplot(data=y_train.ravel(), ax=axs)
axs.set_xlim([0, 1900])
axs.set_xlabel('n')
axs.set_ylabel('u[n]')
plt.legend(["Input", "Target"])
```

Training and Prediction using vanilla ESNs and ELMs

```python
# initialize an ESNRegressor
esn = ESNRegressor()

# initialize an ELMRegressor
elm = ELMRegressor(regressor=Ridge())

# train a model
esn.fit(X=X_train.reshape(-1, 1), y=y_train)
elm.fit(X=X_train.reshape(-1, 1), y=y_train)

# evaluate the models
y_test_pred = esn.predict(X=X_test)
print(mean_squared_error(y_test, y_test_pred))
y_test_pred = elm.predict(X=X_test)
print(mean_squared_error(y_test, y_test_pred))
```

Hyperparameter optimization ESN

```python
# Echo State Network sequential hyperparameter tuning
initially_fixed_params = {'hidden_layer_size': 100,
                          'input_activation': 'identity',
                          'bias_scaling': 0.0,
                          'reservoir_activation': 'tanh',
                          'leakage': 1.0,
                          'bidirectional': False,
                          'k_rec': 10,
                          'alpha': 1e-5,
                          'random_state': 42,
                          'requires_sequence': False}

step1_esn_params = {'input_scaling': np.linspace(0.1, 5.0, 50),
                    'spectral_radius': np.linspace(0.0, 1.5, 16)}
step2_esn_params = {'leakage': np.linspace(0.1, 1.0, 10)}
step3_esn_params = {'bias_scaling': np.linspace(0.0, 1.5, 16)}

scorer = make_scorer(score_func=mean_squared_error, greater_is_better=False)

kwargs = {'verbose': 5,
          'scoring': scorer,
          'n_jobs': -1,
          'cv': TimeSeriesSplit()}

esn = ESNRegressor(regressor=Ridge(), **initially_fixed_params)

searches = [('step1', GridSearchCV, step1_esn_params, kwargs),
            ('step2', GridSearchCV, step2_esn_params, kwargs),
            ('step3', GridSearchCV, step3_esn_params, kwargs)]

sequential_search_esn = SequentialSearchCV(esn, searches=searches).fit(X_train.reshape(-1, 1), y_train)
```

Hyperparameter optimization ELM

```python
# Extreme Learning Machine sequential hyperparameter tuning
initially_fixed_elm_params = {'hidden_layer_size': 100,
                              'activation': 'tanh',
                              'k_in': 1,
                              'alpha': 1e-5,
                              'random_state': 42 }

step1_elm_params = {'input_scaling': np.linspace(0.1, 5.0, 50)}
step2_elm_params = {'bias_scaling': np.linspace(0.0, 1.5, 16)}

scorer = make_scorer(score_func=mean_squared_error, greater_is_better=False)

kwargs = {'verbose': 5,
          'scoring': scorer,
          'n_jobs': -1,
          'cv': TimeSeriesSplit()}

elm = ELMRegressor(regressor=Ridge(), **initially_fixed_elm_params)

searches = [('step1', GridSearchCV, step1_elm_params, kwargs),
            ('step2', GridSearchCV, step2_elm_params, kwargs)]

sequential_search_elm = SequentialSearchCV(elm, searches=searches).fit(X_train.reshape(-1, 1), y_train)
```

Final prediction and visualization

```python
sequential_search_esn.all_best_score_
```

```python
sequential_search_elm.all_best_score_
```

```python
esn = sequential_search_esn.best_estimator_
elm = sequential_search_elm.best_estimator_

y_train_pred_esn = esn.predict(X=X_train)
y_train_pred_elm = elm.predict(X=X_train)
y_test_pred_esn = esn.predict(X=X_test)
y_test_pred_elm = elm.predict(X=X_test)

test_err_esn = mean_squared_error(y_true=y_test, y_pred=y_test_pred_esn)
test_err_elm = mean_squared_error(y_true=y_test, y_pred=y_test_pred_elm)

print("Test MSE ESN:\t{0}".format(test_err_esn))
print("Test MSE ELM:\t{0}".format(test_err_elm))

# Prediction of the test set.
fix, axs = plt.subplots()
sns.lineplot(data=y_test_pred_esn, ax=axs)
sns.lineplot(data=y_test_pred_elm, ax=axs)
axs.set_xlim([0, 1900])
axs.set_xlabel('n')
axs.set_ylabel('u[n]')
plt.legend(["ESN prediction", "ELM prediction"])
```

```python
fig, axs = plt.subplots(1, 2, sharey=True)
sns.heatmap(data=esn.hidden_layer_state[:100, :].T, ax=axs[0], cbar=False)
axs[0].set_xlabel("Time Step")
axs[0].set_ylabel("Neuron Index")
sns.heatmap(data=elm.hidden_layer_state[:100, :].T, ax=axs[1])
axs[1].set_xlabel("Time Step")
```

```python

```