```python
import numpy as np
import pandas as pd
import random
from collections import Counter
import math
from operator import itemgetter
import sympy as sp
```

```python
#########################                      Functions used in main program                         ##########################

## Function to get the ground state in a multi-qubit system. ##

def get_ground_state(num_qubits):

    ## qubit numbering starts from 0,
    ## i.e., in a 3 qubit system, 1st qubit is q0, 2nd is q1, etc.
    q0 = np.array([[1], [0]])                                ## |0> state column-vector representation
    q1 = np.array([[0], [1]])                                ## |1> state column-vector representation
    gs = 1                                                   ## gs = ground state variable
    for i in range(num_qubits):
        gs = np.kron(gs, q0)                                 ## tensor product of |0>, |0>, ... num_qubits times
    print("Info : The ground state is initialised with each qubit equal to |0>. \n Total no. of qubits in this system: ",num_qubits)
    print("\n Therefore, the ground state of the system is: \n", gs)
    return gs

## Function to get the matrix representation of gates in a multi-qubit system. ##
## The gates included are one-qubit(Pauli gates and Hadamard Gate) and CNOT (CX) gates.  ##

def get_operator(total_qubits, gate_unitary, target_qubits):
    q0 = np.array([[1], [0]])
    q1 = np.array([[0], [1]])
    qplus = (q0 + q1)/np.sqrt(2)                            ## (|0> + |1>)/sqrt(2) state column-vector representation
    qminus = (q0 - q1)/np.sqrt(2)                           ## (|0> - |1>)/sqrt(2) state column-vector representation
    phase_T = np.exp((1j)*math.pi/4)
    P0x0 = q0 @ q0.T                                        ## Projection operator |0><0|
    P1x1 = q1 @ q1.T                                        ## Projection operator |1><1|
    I = np.identity(2)                                      ## Identity Gate
    HadamardGate = qplus @ q0.T + qminus @ q1.T             ## Hadamard Gate, H = |+><0| + |-><1|
    XGate = q0 @ q1.T + q1 @ q0.T                           ## Pauli X Gate, X = |0><1| + |1><0|
    ZGate = q0 @ q0.T - q1 @ q1.T                           ## Pauli Z Gate, Z = |0><0| - |1><1|
    YGate = (-1j)*(q0 @ q1.T - q1 @ q0.T)                  ## Pauli Y Gate, X = -j|0><1| + j|1><0|
    TGate = q0 @ q0.T + phase_T*q1 @ q1.T                   ## T Gate, T = |0><0| + exp(i*pi/4)|1><1|

    SingleQubit_Gates = {'h' : HadamardGate,
                         'x' : XGate,
                         'z' : ZGate,
                         'y' : YGate,
                         't' : TGate}

    GateKeys = list(SingleQubit_Gates.keys())

    ## To generate operators for single-qubit gates from the SingleQubit_Gates dictionary
    ## with various target qubit positions in a multi-qubit system :
    ## Info: Acts on only one-qubit so [target_qubits] should be one-item list s.t. [target]
    ## for e.g. in a 3-qubit system, to make a 1-qubit gate act on qubit 2: target_qubits = [1]

    if gate_unitary in GateKeys:
        GateOp = 1
        for m in range(total_qubits):
            if [m] == target_qubits:
                U = SingleQubit_Gates[gate_unitary]
            else:
                U = I
            GateOp = np.kron(GateOp, U)
        return GateOp                                        ## Matrix operator representing the gate

    ## To generate an operator for CNOT (CX) gate with various
    ## control and target qubits in a multi-qubit system :
    ## Info: Acts on only one target-qubit with single control-qubit
    ## so [target_qubits] should be two-item list s.t. [control, target]
    ## for e.g. in 3-qubit system, to have control at qubit 0 and target at qubit 1: target_qubits = [0, 1]

    if gate_unitary == 'cx':
        X = SingleQubit_Gates['x']
        GateOp1 = 1
        GateOp2 = 1
        for m in range(total_qubits):
            if m == target_qubits[0]:
                U1 = P0x0
                U2 = P1x1
            elif m == target_qubits[1]:
                U1 = I
                U2 = X
            else:
                U1 = I
                U2 = I
            GateOp1 = np.kron(GateOp1, U1)                   ## tensor product for |0><0| part
            GateOp2 = np.kron(GateOp2, U2)                   ## tensor product for |1><1| part
        GateOp = GateOp1 + GateOp2
        return GateOp

    return print("Check parameters. \n")                    ## Trouble-shooting.

## Function to get U3 (theta, phi, lambda) gate's operator - matrix.

def get_parametric_gateU3(total_qubits, gate_unitary, target_qubits, theta, phi, lam):
    cos = round(np.cos(theta / 2), 1)
    sin = round(np.sin(theta / 2), 1)
    exp_lam = round(np.exp(1j * lam), 1)
    exp_phi = round(np.exp(1j * phi), 1)

    U3 = np.array([[cos, -exp_lam * sin],
                [exp_phi * sin, (exp_lam * exp_phi) * cos]
            ])
    I = np.identity(2)
    if gate_unitary == 'u3':
        GateOp = 1
        for m in range(total_qubits):
            if [m] == target_qubits:
                U = U3
            else:
                U = I
            GateOp = np.kron(GateOp, U)
    return GateOp

## Function to run the circuit given as 'program'.

def run_program(initial_state, program):

    num_qubits = int(math.log(len(initial_state), 2))        ## Calculates total # of qubits in the initial_state.
    NumOfLoops = len(program)                                ## # of "loops"(i.e., sub-circuits) in the circuit
    gates = list(map(itemgetter('gate'), program))           ## List of gates
    targets = list(map(itemgetter('target'), program))       ## List of target qubit

    gate_final = np.identity(2**num_qubits)                   ## Initialising cumulative gate-matrix operator
    print("\n ***Performing circuit operations*** \n")

    for i in range(NumOfLoops):

        ## To check for loops with parametric gate U3:
        if gates[i] == 'u3':
            temp = program[i]
            theta = temp['params']['theta']
            phi = temp['params']['phi']
            lam = temp['params']['lambda']

            gate_in = get_parametric_gateU3(num_qubits, gates[i], targets[i], theta, phi, lam)
        else:
            gate_in = get_operator(num_qubits, gates[i], targets[i])        ## Getting operator for gates in each loop
        gate_final = gate_in @ gate_final                                   ## Final operator to act on initial state
        print("You have applied", gates[i], "gate to qubit(s)", targets[i], ".\n")

    fs = gate_final @ initial_state                                     ## Final state = fs (column vector)
    print("\n ***Retrieving the final state*** \n")

    CompBasis = []
    for i in range(len(fs)):
        CompBasis.append(decbin(i, num_qubits))                         ## List of computational basis states

    print("The final state of the system is: \n", fs)
    print("\n The computational basis states are: ", CompBasis, "\n")

    return fs

## Function to convert decimal to binary number.

def decbin(number, bits):
    a = bin(number)[2:]
    c = a.zfill(bits)
    return c

## Function to simulate 'measurement' of states.

def measure_all(state_vector):
    num_qubits = int(math.log(len(state_vector), 2))
    ListOfProbabilities = []                                     ## List of probabilities
    ListOfIndices = []                                           ## List of indices which actually represent the basis states
    weights = np.multiply(state_vector, state_vector.conj())     ## Probabilities = |<psi|psi>|^2 if |psi> = some state vector

    for i in range(len(state_vector)):
        ListOfIndices.append(decbin(i, num_qubits))
        ListOfProbabilities.append(weights[i, 0])

    ChosenIndex = random.choices(ListOfIndices, ListOfProbabilities)    ## Weighted-random choice

    return ChosenIndex

## Function to give counts of the measured states.

def get_counts(state_vector, num_shots):
    counts = []
    print("\n ***Making measurements*** \n")
    for i in range(num_shots):
        m = measure_all(state_vector)
        counts.append(m[0])
    results = dict(Counter(counts))
    print("Counts of the final state of the system on measurement are: \n", results)
    return results
```

```python

## Basic Program: ##
## Big Endian Representation (left bit belongs to first qubit and right bit belongs to second qubit)

## Gate keys: H gate = 'h', X gate = 'x', Z gate = 'z', Y gate = 'y', T gate = 't', U3 gate = 'u3', CNOT (CX) gate = 'cx'

## For single qubit & U3 (single qubit parametric gate) gates:
## the target qubits are named as: [0], [1], ..., [n-1] for an n-qubit system.

## For CX gate gate:
## The control and target qubits are [control, target] == [n1, n2] where n1 != n2 and n1 & n2 <= n-1 in an n-qubit system.

################################                      Sample program                        ####################################

# Define circuit:

my_circuit = [
{ "gate": "h", "target": [0] },
{ "gate": "cx", "target": [0, 1] }
]

ground_state = get_ground_state(2)                  # Fetches ground state all qubits initially in |0> state
                                                    # get_ground_state(num_qubits), where, num_qubits = # of qubits in the system
                                                    # in this e.g., num_qubits = 2

final_state = run_program(ground_state, my_circuit)                # Final state

counted_qubitstates = get_counts(final_state, 200)                 # Counts of states on measurement

###################################                    End of program                        ###################################

```

    Info : The ground state is initialised with each qubit equal to |0>.
     Total no. of qubits in this system:  2

     Therefore, the ground state of the system is:
     [[1]
     [0]
     [0]
     [0]]

     ***Performing circuit operations***

    You have applied h gate to qubit(s) [0] .

    You have applied cx gate to qubit(s) [0, 1] .

     ***Retrieving the final state***

    The final state of the system is:
     [[0.70710678]
     [0.        ]
     [0.        ]
     [0.70710678]]

     The computational basis states are:  ['00', '01', '10', '11']

     ***Making measurements***

    Counts of the final state of the system on measurement are:
     {'00': 101, '11': 99}

```python
## Output arranged as:

#1. Info on ground state, number of qubits in the system, and the ground state vector.
#2. Performing circuit operations : info on the operators acting step-by-step on the cumulative input state.
#3. Retrieving final state : final state vector shown and the computational basis states.
#4. Making measurements : On measurement, the counts of the final state (upon measuring) are shown.

## Other model circuits to try out:

# my_circuit = [
#   { "gate": "u3", "params": { "theta": 3.1415, "phi": 1.5708, "lambda": -3.1415 }, "target": [0] },
#     { "gate": "cx", "target": [0, 1] }
#     ]

# my_circuit = [
# { "gate": "h", "target": [0] },
# { "gate": "h", "target": [1] },
# { "gate": "cx", "target": [0, 2] },
# { "gate": "cx", "target": [1, 2] }
# ]
# my_circuit = [
#   { "gate": "u3", "params": { "theta": 1.5708, "phi": 1.5708, "lambda": 1.5708 }, "target": [0] },
#     { "gate": "u3", "params": { "theta": 1.5708, "phi": 1.5708, "lambda": 1.5708 }, "target": [0] },
#     { "gate": "cx", "target": [0, 1] },
#     { "gate": "h", "target": [1] },
#     { "gate": "u3", "params": { "theta": 1.5708, "phi": 1.5708, "lambda": 1.5708 }, "target": [2] }

#      ]

```


# Newton Raphon Method
In this we are trying to Find common tangent to two curves using newton raphson method . The equation we use is Gibbs energy for a mixture and the common tangent have a lot of significance in thermodynamics

```python
import numpy as np # General numerics
import matplotlib.pyplot as plt # Plotting
import sympy as sym
```

### Material Properties

```python
gA_alpha = 0
gB_alpha = 0
gA_beta = -2
gB_beta = 2
omega = 10
R = 8.314
T = 1

```

```python
x_alpha = sym.Symbol('x_alpha')#Now x_alpha is a variable so it can br differentiated
x_beta = sym.Symbol('x_beta')
```

```python
f_alpha = gA_alpha*x_alpha + gB_alpha*(1-x_alpha) + R*T*(x_alpha*sym.log(x_alpha) + (1-x_alpha)*sym.log(1-x_alpha))
f_beta = gA_beta*x_beta+gB_beta*(1-x_beta) + R*T*(x_beta*sym.log(x_beta)+(1-x_beta)*sym.log(1-x_beta))+omega*(x_beta*(1-x_beta))
```

```python
f_alpha
```

$\displaystyle 8.314 x_{\alpha} \log{\left(x_{\alpha} \right)} + 8.314 \left(1 - x_{\alpha}\right) \log{\left(1 - x_{\alpha} \right)}$

```python
f_beta
```

$\displaystyle 10 x_{\beta} \left(1 - x_{\beta}\right) + 8.314 x_{\beta} \log{\left(x_{\beta} \right)} - 4 x_{\beta} + 8.314 \left(1 - x_{\beta}\right) \log{\left(1 - x_{\beta} \right)} + 2$

```python
sym.diff(f_beta,x_beta)
```

$\displaystyle - 20 x_{\beta} + 8.314 \log{\left(x_{\beta} \right)} - 8.314 \log{\left(1 - x_{\beta} \right)} + 14.314 - \frac{8.314 - 8.314 x_{\beta}}{1 - x_{\beta}}$

###  Plot Free Energies

```python
%matplotlib notebook
x1 = np.linspace(0.01,0.99,100)
f_alpha_func = sym.lambdify(x_alpha,f_alpha)# lambdify will convert the symbolic functiom to general function
f_beta_func = sym.lambdify(x_beta,f_beta)
plt.plot(x1, f_alpha_func(x1), label='f_alpha')
plt.plot(x1, f_beta_func(x1), label='f_beta')
plt.legend()
plt.show()

```

    <IPython.core.display.Javascript object>

### Create F and G

```python
df_alpha = sym.diff(f_alpha, x_alpha)#sym.diff will differentiate f_alpha with respect to x_alpha
df_beta = sym.diff(f_beta, x_beta)
int_alpha = f_alpha - x_alpha*df_alpha
int_beta = f_beta - x_beta*df_beta
```

```python
df_alpha
```

$\displaystyle 8.314 \log{\left(x_{\alpha} \right)} - 8.314 \log{\left(1 - x_{\alpha} \right)} + 8.314 - \frac{8.314 - 8.314 x_{\alpha}}{1 - x_{\alpha}}$

```python
df_beta
```

$\displaystyle - 20 x_{\beta} + 8.314 \log{\left(x_{\beta} \right)} - 8.314 \log{\left(1 - x_{\beta} \right)} + 14.314 - \frac{8.314 - 8.314 x_{\beta}}{1 - x_{\beta}}$

```python
int_alpha
```

$\displaystyle - x_{\alpha} \left(8.314 \log{\left(x_{\alpha} \right)} - 8.314 \log{\left(1 - x_{\alpha} \right)} + 8.314 - \frac{8.314 - 8.314 x_{\alpha}}{1 - x_{\alpha}}\right) + 8.314 x_{\alpha} \log{\left(x_{\alpha} \right)} + 8.314 \left(1 - x_{\alpha}\right) \log{\left(1 - x_{\alpha} \right)}$

```python
int_beta
```

$\displaystyle 10 x_{\beta} \left(1 - x_{\beta}\right) - x_{\beta} \left(- 20 x_{\beta} + 8.314 \log{\left(x_{\beta} \right)} - 8.314 \log{\left(1 - x_{\beta} \right)} + 14.314 - \frac{8.314 - 8.314 x_{\beta}}{1 - x_{\beta}}\right) + 8.314 x_{\beta} \log{\left(x_{\beta} \right)} - 4 x_{\beta} + 8.314 \left(1 - x_{\beta}\right) \log{\left(1 - x_{\beta} \right)} + 2$

```python
F = df_alpha - df_beta
G = int_alpha - int_beta
```

```python
F
```

$\displaystyle 20 x_{\beta} + 8.314 \log{\left(x_{\alpha} \right)} - 8.314 \log{\left(x_{\beta} \right)} - 8.314 \log{\left(1 - x_{\alpha} \right)} + 8.314 \log{\left(1 - x_{\beta} \right)} - 6.0 + \frac{8.314 - 8.314 x_{\beta}}{1 - x_{\beta}} - \frac{8.314 - 8.314 x_{\alpha}}{1 - x_{\alpha}}$

```python
G
```

$\displaystyle - x_{\alpha} \left(8.314 \log{\left(x_{\alpha} \right)} - 8.314 \log{\left(1 - x_{\alpha} \right)} + 8.314 - \frac{8.314 - 8.314 x_{\alpha}}{1 - x_{\alpha}}\right) + 8.314 x_{\alpha} \log{\left(x_{\alpha} \right)} - 10 x_{\beta} \left(1 - x_{\beta}\right) + x_{\beta} \left(- 20 x_{\beta} + 8.314 \log{\left(x_{\beta} \right)} - 8.314 \log{\left(1 - x_{\beta} \right)} + 14.314 - \frac{8.314 - 8.314 x_{\beta}}{1 - x_{\beta}}\right) - 8.314 x_{\beta} \log{\left(x_{\beta} \right)} + 4 x_{\beta} + 8.314 \left(1 - x_{\alpha}\right) \log{\left(1 - x_{\alpha} \right)} - 8.314 \left(1 - x_{\beta}\right) \log{\left(1 - x_{\beta} \right)} - 2$

### Jacobian Matrix

```python
d2f_alpha = sym.diff(df_alpha, x_alpha)
d2f_beta = sym.diff(df_beta, x_beta)
d_int_alpha = sym.diff(int_alpha, x_alpha)
d_int_beta = sym.diff(int_beta, x_beta)

```

### Newton Raphon Iteration

```python
total_iterations = 10
x_guess = np.array([0.7,0.9])
x = x_guess
for i in range(total_iterations):
    #.evalf will evalute the function with the given inputs
    jacobian = np.array([[d2f_alpha.evalf(subs={x_alpha:x[0],x_beta:x[1]}),-d2f_beta.evalf(subs={x_alpha:x[0],x_beta:x[1]})],[d_int_alpha.evalf(subs={x_alpha:x[0],x_beta:x[1]}),-d_int_beta.evalf(subs={x_alpha:x[0],x_beta:x[1]})]])
    # here as it is calculated from a symbolic expretion no the jacobian is obect type so we need to type cast it
    jacobian = np.array(jacobian).astype(np.float64) #type casting jacobian
    inv_jacobian = np.linalg.inv(jacobian) #this will inverse the matrix
    x = x - np.dot(inv_jacobian, np.array([F.evalf(subs={x_alpha:x[0],x_beta:x[1]}),G.evalf(subs={x_alpha:x[0],x_beta:x[1]})]))
    print(i, x)
```

    0 [0.733732744583695 0.929183055371653]
    1 [0.732039035702686 0.924830052513884]
    2 [0.731917089539305 0.924629002550213]
    3 [0.731916863805514 0.924628616775000]
    4 [0.731916863804681 0.924628616773581]
    5 [0.731916863804681 0.924628616773581]
    6 [0.731916863804681 0.924628616773581]
    7 [0.731916863804681 0.924628616773581]
    8 [0.731916863804681 0.924628616773581]
    9 [0.731916863804681 0.924628616773581]

###  Plot Free Energies With Common Tangent

```python
slop = df_alpha.evalf(subs={x_alpha:x[0]})

```

```python
 f_x=f_alpha.evalf(subs={x_alpha:x[0]})
```

```python
y = f_x + slop*(x_alpha-x[0])

```

```python
%matplotlib notebook
x1 = np.linspace(0.01,0.99,100)
f_alpha_func = sym.lambdify(x_alpha,f_alpha)# lambdify will convert the symbolic functiom to general function
f_beta_func = sym.lambdify(x_beta,f_beta)
y_func = sym.lambdify(x_alpha,y)
plt.plot(x1, f_alpha_func(x1), label='f_alpha')
plt.plot(x1, f_beta_func(x1), label='f_beta')
plt.plot(x1, y_func(x1), label='common tangent')
plt.legend()
plt.show()

```

    <IPython.core.display.Javascript object>

```python

```

```python

```


```python
import seaborn as sns
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
#sns.set_theme(style="whitegrid")
sns.set(rc={'figure.figsize':(16,13)})
```

```python
# Absolute error
def AE(y, y_hat, t, T):

    resid = np.abs(y - y_hat)

    return resid

#############################################

# Temporal decay loss
def EP_td(y, y_hat, t, T, losstype="loss", weight=1):

    decay = ((T-t)/T)
    resid = np.abs(y - y_hat)

    if losstype=="loss":
        EP = resid**decay

    if losstype=="penalty":
        EP = resid+ weight*(resid**decay)

    return EP

# Progressive temporal decay loss
def EP_prog_td(y, y_hat, t, T, losstype="loss", weight=1):

    decay = 1/t

    resid = np.abs(y - y_hat)

    if losstype=="loss":
        EP = resid*decay
    if losstype=="penalty":
        EP = resid+ weight*(resid*decay)

    return EP
```

### Previous formulations

\begin{equation}MAE_{PtD} = \frac{1}{N}\sum_{i=1}^{N}\frac{1}{T}\sum_{t=1}^{T_i}\mid y_{t}^i - \hat{y}_{t}^i\mid ^{\frac{T_i-t}{T_i}}\end{equation}

and

\begin{equation}MAE_{MtD} = \frac{1}{N}\sum_{i=1}^{N}\frac{1}{T}\sum_{t=1}^{T_i}\frac{\mid y_{t}^i - \hat{y}_{t}^i\mid}{t} \end{equation}

```python
"""
Missing the "balance" weight to adjust accuracy versus earliness,
and missing a more flexible rate of decay form
"""
```

    '\nMissing the "balance" weight to adjust accuracy versus earliness,\nand missing a more flexible rate of decay form\n'

## Useful formulas for exponentials:

Radioactive decay example:
\begin{equation}
X_{t+1} = \frac{X_{t}}{2}
\end{equation}
Half-life = 1 year

Bounded exponential growth (Logistic equation):

\begin{equation}
X_{t+1} = rX_{t}\left(1-\frac{X_{t}}{max(X)}\right)
\end{equation}

```python

```

# New loss formulation

Basic components:

\begin{equation}
MAE + \beta f(MAE,\alpha)
\end{equation}

Where $\beta$ is the weighting parameter of the earliness component, versus the $\gamma$ parameter that weight normal accuracy. $\alpha$ acts as a decay-rate parameter specifying to which degree the temporal decay should prioritize the earliest events in a sequence/trace.

The earliness/penalty component consist of:

\begin{equation}
f(MAE,\alpha) = \frac{1}{N}\sum_{i=1}^{N}\frac{1}{T}\sum_{t=1}^{T_i}\frac{\mid y_{t}^i - \hat{y}_{t}^i\mid}{\alpha t}
\end{equation}

and thus the full loss becomes:

\begin{equation}
MAE_{td}: \frac{1}{N}\sum_{i=1}^{N}\frac{1}{T}\sum_{t=1}^{T_i}\gamma\mid y_{t}^i - \hat{y}_{t}^i\mid + \beta \frac{\mid y_{t}^i - \hat{y}_{t}^i\mid}{\alpha t}
\end{equation}

However, the first batches of experiments show that adding the accuracy component only leads to inferior performance compared to baseline MAE. Current implementation of MAE_td is thus as simple as:

\begin{equation}
MAE_{td}: \frac{1}{N}\sum_{i=1}^{N}\frac{1}{T}\sum_{t=1}^{T_i}\frac{\mid y_{t}^i - \hat{y}_{t}^i\mid}{\alpha t}
\end{equation}

```python
# Temporal decay loss
def EP_td(y, y_hat, t, T, losstype="penalty", alpha=1, beta=1, gamma=1):

    resid = np.abs(y - y_hat)

    EP = gamma*resid + beta*(resid/t*alpha)

    return EP
```

```python
def generate_obs(metric, T = 10, alpha = 1, beta = 1, gamma=1, loss_comp="loss"):
    y = np.linspace(start=250,stop=50, num=10,endpoint=True)
    y_hat = np.linspace(start=250,stop=50, num=10,endpoint=True)+50
    t = np.array([1,2,3,4,5,6,7,8,9,10])

    #placeholder
    resid = [0]*len(t)

    if metric == "AE":
        resid = AE(y, y_hat, t, T)

    if metric == "EP_td":
        resid = EP_td(y, y_hat, t, T, losstype=loss_comp, alpha=alpha, beta=beta, gamma=gamma)

    #if metric == "EP_prog_td":
    #    resid = EP_prog_td(y, y_hat, t, T, losstype=loss_comp, weight=weight_1)

    df = pd.DataFrame({"y":y,
                      "y_hat":y_hat,
                      "t":t,
                      "resid":resid,
                      "loss":[metric]*T,
                       "alpha":[alpha]*T,
                       "beta":[beta]*T,
                       "gamma":[gamma]*T})
    return df

"""
Test the data generator with no loss function
"""
generate_obs("EP_td",
             T = 10,
             alpha = 1,
             beta = 1,
             gamma=1,
             loss_comp="loss")

```

      y
      y_hat
      t
      resid
      loss
      alpha
      beta
      gamma

      0
      250.000000
      300.000000
      1
      100.000000
      EP_td
      1
      1
      1

      1
      227.777778
      277.777778
      2
      75.000000
      EP_td
      1
      1
      1

      2
      205.555556
      255.555556
      3
      66.666667
      EP_td
      1
      1
      1

      3
      183.333333
      233.333333
      4
      62.500000
      EP_td
      1
      1
      1

      4
      161.111111
      211.111111
      5
      60.000000
      EP_td
      1
      1
      1

      5
      138.888889
      188.888889
      6
      58.333333
      EP_td
      1
      1
      1

      6
      116.666667
      166.666667
      7
      57.142857
      EP_td
      1
      1
      1

      7
      94.444444
      144.444444
      8
      56.250000
      EP_td
      1
      1
      1

      8
      72.222222
      122.222222
      9
      55.555556
      EP_td
      1
      1
      1

      9
      50.000000
      100.000000
      10
      55.000000
      EP_td
      1
      1
      1

```python
metrics_of_interest = ["AE","EP_td"] #,"EP_prog_td"

#accuracy/earliness balance
betas = [1] #[0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75]
gammas = [1] #[0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75]

#decay rate parameter
alphas = [0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75]

data = []

for balance1 in betas:

    for balance2 in gammas:

        for rate in alphas:

            for metric in metrics_of_interest:

                obs = generate_obs(metric, beta = balance1, gamma=balance2, alpha = rate)

                data.append(obs)

residuals = pd.concat(data)
residuals.head()
```

      y
      y_hat
      t
      resid
      loss
      alpha
      beta
      gamma

      0
      250.000000
      300.000000
      1
      50.0
      AE
      0.0
      1
      1

      1
      227.777778
      277.777778
      2
      50.0
      AE
      0.0
      1
      1

      2
      205.555556
      255.555556
      3
      50.0
      AE
      0.0
      1
      1

      3
      183.333333
      233.333333
      4
      50.0
      AE
      0.0
      1
      1

      4
      161.111111
      211.111111
      5
      50.0
      AE
      0.0
      1
      1

```python
g = sns.FacetGrid(residuals, col="alpha", col_wrap=4, height=4, size=3,  hue="loss", aspect=1) #
g.map(sns.barplot, 't','resid')
plt.savefig("loss_functions.png")
plt.show()
```

## Multiple settings

```python
metrics_of_interest = ["EP_td"] #,"EP_prog_td"

#accuracy/earliness balance
betas = [1] #[0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75]
gammas = [0, 0.5, 1] #[0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75]

#decay rate parameter
alphas = [0.01, 0.5, 1.0]#, 1.75]

data = []

for balance1 in betas:

    for balance2 in gammas:

        for rate in alphas:

            for metric in metrics_of_interest:

                obs = generate_obs(metric, beta = balance1, gamma=balance2, alpha = rate)

                data.append(obs)

residuals = pd.concat(data)
residuals#.head(10)
```

      y
      y_hat
      t
      resid
      loss
      alpha
      beta
      gamma

      0
      250.000000
      300.000000
      1
      0.500000
      EP_td
      0.01
      1
      0.0

      1
      227.777778
      277.777778
      2
      0.250000
      EP_td
      0.01
      1
      0.0

      2
      205.555556
      255.555556
      3
      0.166667
      EP_td
      0.01
      1
      0.0

      3
      183.333333
      233.333333
      4
      0.125000
      EP_td
      0.01
      1
      0.0

      4
      161.111111
      211.111111
      5
      0.100000
      EP_td
      0.01
      1
      0.0

      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...
      ...

      5
      138.888889
      188.888889
      6
      58.333333
      EP_td
      1.00
      1
      1.0

      6
      116.666667
      166.666667
      7
      57.142857
      EP_td
      1.00
      1
      1.0

      7
      94.444444
      144.444444
      8
      56.250000
      EP_td
      1.00
      1
      1.0

      8
      72.222222
      122.222222
      9
      55.555556
      EP_td
      1.00
      1
      1.0

      9
      50.000000
      100.000000
      10
      55.000000
      EP_td
      1.00
      1
      1.0

90 rows × 8 columns

```python
g = sns.FacetGrid(residuals, col="gamma", row="alpha", hue="loss")
g.map(sns.barplot, 't','resid')
plt.savefig('loss_weights.png', dpi=300)
plt.show()
```

```python

```

```python
metrics_of_interest = ["EP_td"] #,"EP_prog_td"

balance_weights = np.linspace(start=0.5,stop=2, num=4,endpoint=True) #[ 0.5, 1.0, 1.5]
rates = np.linspace(start=0.5, stop=2, num=4, endpoint=True) #[1.0, 1.25, 1.5]

data = []

for rate in rates:

    for balance in balance_weights:

        for metric in metrics_of_interest:

            obs = generate_obs(metric, balance = balance, rate = rate)

            data.append(obs)

residuals = pd.concat(data)
residuals.head(20)
```

      y
      y_hat
      t
      resid
      loss
      balance
      rate

      0
      250.000000
      300.000000
      1
      62.500000
      EP_td
      0.5
      0.5

      1
      227.777778
      277.777778
      2
      56.250000
      EP_td
      0.5
      0.5

      2
      205.555556
      255.555556
      3
      54.166667
      EP_td
      0.5
      0.5

      3
      183.333333
      233.333333
      4
      53.125000
      EP_td
      0.5
      0.5

      4
      161.111111
      211.111111
      5
      52.500000
      EP_td
      0.5
      0.5

      5
      138.888889
      188.888889
      6
      52.083333
      EP_td
      0.5
      0.5

      6
      116.666667
      166.666667
      7
      51.785714
      EP_td
      0.5
      0.5

      7
      94.444444
      144.444444
      8
      51.562500
      EP_td
      0.5
      0.5

      8
      72.222222
      122.222222
      9
      51.388889
      EP_td
      0.5
      0.5

      9
      50.000000
      100.000000
      10
      51.250000
      EP_td
      0.5
      0.5

      0
      250.000000
      300.000000
      1
      75.000000
      EP_td
      1.0
      0.5

      1
      227.777778
      277.777778
      2
      62.500000
      EP_td
      1.0
      0.5

      2
      205.555556
      255.555556
      3
      58.333333
      EP_td
      1.0
      0.5

      3
      183.333333
      233.333333
      4
      56.250000
      EP_td
      1.0
      0.5

      4
      161.111111
      211.111111
      5
      55.000000
      EP_td
      1.0
      0.5

      5
      138.888889
      188.888889
      6
      54.166667
      EP_td
      1.0
      0.5

      6
      116.666667
      166.666667
      7
      53.571429
      EP_td
      1.0
      0.5

      7
      94.444444
      144.444444
      8
      53.125000
      EP_td
      1.0
      0.5

      8
      72.222222
      122.222222
      9
      52.777778
      EP_td
      1.0
      0.5

      9
      50.000000
      100.000000
      10
      52.500000
      EP_td
      1.0
      0.5

```python
g = sns.FacetGrid(residuals, col="balance",row="rate", hue="loss")
g.map(sns.barplot, 't','resid')
```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```

```python

```

analysis/Temporal loss parameterized version.ipynb


# Quantization of Signals

*This jupyter notebook is part of a [collection of notebooks](../index.ipynb) on various topics of Digital Signal Processing. Please direct questions and suggestions to [Sascha.Spors@uni-rostock.de](mailto:Sascha.Spors@uni-rostock.de).*

## Quantization Error of a Linear Uniform Quantizer

As illustrated in the [preceding section](linear_uniform_characteristic.ipynb), quantization results in two different types of distortions. Overload distortions are a consequence of exceeding the minimum/maximum amplitude of the quantizer. Granular distortions are a consequence of the quantization process when no clipping occurs. Various measures are used to quantify the distortions of a quantizer. We limit ourselves to the signal-to-noise ratio as commonly used measure.

### Signal-to-Noise Ratio

A quantizer can be evaluated by its [signal-to-noise ratio](https://en.wikipedia.org/wiki/Signal-to-noise_ratio) (SNR), which is defined as the power of the continuous amplitude signal $x[k]$ divided by the power of the quantization error $e[k]$. Under the assumption that both signals are drawn from a zero-mean wide-sense stationary (WSS) process, the average SNR is given as

\begin{equation}
SNR = 10 \cdot \log_{10} \left( \frac{\sigma_x^2}{\sigma_e^2} \right) \quad \text{ in dB}
\end{equation}

where $\sigma_x^2$ and $\sigma_e^2$ denote the variances of the signals $x[k]$ and $e[k]$, respectively. The SNR quantifies the average impact of the distortions introduced by quantization. The statistical properties of the signal $x[k]$ and the quantization error $e[k]$ are required in order to evaluate the SNR of a quantizer. First, a statistical model for the quantization error is introduced.

### Model for the Quantization Error

In order to derive the statistical properties of the quantization error, the probability density functions (PDFs) of the quantized signal $x_\text{Q}[k]$ and the error $e[k]$, as well as its bivariate PDFs have to be derived. The underlying calculus is quite tedious due to the nonlinear nature of quantization. Please refer to [[Widrow](../index.ipynb#Literature)] for a detailed treatment. The resulting model is summarized in the following. We focus on the non-clipping case $x_\text{min} \leq x[k] < x_\text{max}$ first, hence on granular distortions. Here the quantization error is in general bounded $|e[k]| < \frac{Q}{2}$.

Under the assumption that the input signal has a wide dynamic range compared to the quantization step size $Q$, the quantization error $e[k]$ can be approximated by the following statistical model

1. The quantization error $e[k]$ is not correlated with the input signal $x[k]$

2. The quantization error is [white](../random_signals/white_noise.ipynb)

    $$ \Phi_{ee}(\mathrm{e}^{\,\mathrm{j}\,\Omega}) = \sigma_e^2 $$

3. The probability density function (PDF) of the quantization error is given by the zero-mean [uniform distribution](../random_signals/important_distributions.ipynb#Uniform-Distribution)

    $$ p_e(\theta) = \frac{1}{Q} \cdot \text{rect} \left( \frac{\theta}{Q} \right) $$

The variance of the quantization error is then [derived from its PDF](../random_signals/important_distributions.ipynb#Uniform-Distribution) as

\begin{equation}
\sigma_e^2 = \frac{Q^2}{12}
\end{equation}

Let's assume that the quantization index is represented as binary or [fixed-point number](https://en.wikipedia.org/wiki/Fixed-point_arithmetic) with $w$-bits. The common notation for the mid-tread quantizer is that $x_\text{min}$ can be represented exactly. Half of the $2^w$ quantization indexes is used for the negative signal values, the other half for the positive ones including zero. The quantization step is then given as

\begin{equation}
Q = \frac{ |x_\text{min}|}{2^{w-1}} = \frac{ x_\text{max}}{2^{w-1} - 1}
\end{equation}

where $x_\text{max} = |x_\text{min}| - Q$. Introducing the quantization step, the variance of the quantization error can be expressed by the word length $w$ as

\begin{equation}
\sigma_e^2 = \frac{x^2_\text{max}}{3 \cdot 2^{2w}}
\end{equation}

The average power of the quantization error quarters per additional bit spend. Introducing the variance into the definition of the SNR yields

\begin{equation}
\begin{split}
SNR &= 10 \cdot \log_{10} \left( \frac{3 \sigma_x^2}{x^2_\text{max}}  \right) + 10 \cdot \log_{10} \left( 2^{2w} \right) \\
& \approx 10 \cdot \log_{10} \left( \frac{3 \sigma_x^2}{x^2_\text{max}}  \right) + 6.02 w \quad \text{in dB}
\end{split}
\end{equation}

It now can be concluded that the SNR decays approximately by 6 dB per additional bit spend. This is often referred to as the 6 dB per bit rule of thumb for linear uniform quantization. Note, this holds only under the assumptions stated above.

### Uniformly Distributed Signal

A statistical model for the input signal $x[k]$ is required in order to calculate the average SNR of a linear uniform quantizer. For a signal that conforms to a zero-mean uniform distribution and under the assumption $x_\text{max} \gg Q$ its PDF is given as

\begin{equation}
p_x(\theta) = \frac{1}{2 x_\text{max}} \text{rect}\left( \frac{\theta}{2 x_\text{max}} \right)
\end{equation}

Hence, all amplitudes between $-x_\text{max}$ and $x_\text{max}$ occur with the same probability. The variance of the signal is then calculated to

\begin{equation}
\sigma_x^2 = \frac{4 x_\text{max}^2}{12}
\end{equation}

Introducing $\sigma_x^2$ and $\sigma_e^2$ into the definition of the SNR yields

\begin{equation}
SNR = 10 \cdot \log_{10} \left( 2^{2 w} \right) \approx 6.02 \, w \quad \text{in dB}
\end{equation}

The word length $w$ and resulting SNRs for some typical digital signal representations are

| | $w$ | SNR |
|----|:----:|:----:|
| Compact Disc (CD) | 16 bit | 96 dB |
| Digital Video Disc (DVD) | 24 bit | 144 dB |
| Video Signals | 8 bit | 48 dB |

Note that the SNR values hold only if the continuous amplitude signal conforms reasonably well to a uniform PDF and if it uses the full amplitude range of the quantizer. If the latter is not the case this can be considered by introducing the level $0 < A \leq 1$ into above considerations, such that $x_\text{min} \leq \frac{x[k]}{A} < x_\text{max}$. The resulting variance is given as

\begin{equation}
\sigma_x^2 = \frac{4 x_\text{max}^2 A^2}{12}
\end{equation}

introduced into the definition of the SNR yields

\begin{equation}
SNR = 10 \cdot \log_{10} \left( 2^{2 w} \right) + 20 \cdot \log_{10} ( A ) \approx 6.02 \, w + 20 \cdot \log_{10} ( A ) \quad \text{in dB}
\end{equation}

From this it can be concluded that a level of -6 dB is equivalent to a loss of one bit in terms of SNR of the quantized signal.

#### Example - Quantization of a uniformly distributed signal

In this example the linear uniform quantization of a random signal drawn from a uniform distribution is evaluated. The amplitude range of the quantizer is $x_\text{min} = -1$ and $x_\text{max} = 1 - Q$.

```python
import numpy as np
import matplotlib.pyplot as plt
import scipy.signal as sig

w = 8  # wordlength of the quantized signal
xmin = -1  # mimimum amplitude of input signal
N = 8192  # number of samples
K = 30  # maximum lag for cross-correlation

def uniform_midtread_quantizer(x, Q):
    '''Uniform mid-tread quantizer with limiter.'''
    # limiter
    x = np.copy(x)
    idx = np.where(x <= -1)
    x[idx] = -1
    idx = np.where(x > 1 - Q)
    x[idx] = 1 - Q
    # linear uniform quantization
    xQ = Q * np.floor(x/Q + 1/2)

    return xQ

def analyze_quantizer(x, e):
    '''Compute and plot PDF, CCF and PSD of quantizer.'''
    # estimated PDF of error signal
    pe, bins = np.histogram(e, bins=20, density=True, range=(-Q, Q))
    # estimate cross-correlation between input and error
    ccf = 1/len(x) * np.correlate(x, e, mode='full')
    # estimate PSD of error signal
    nf, Pee = sig.welch(e, nperseg=128)
    # estimate SNR
    SNR = 10*np.log10((np.var(x)/np.var(e)))
    print('SNR = %f in dB' % SNR)

    # plot statistical properties of error signal
    plt.figure(figsize=(9, 4))

    plt.subplot(121)
    plt.bar((bins[:-1] + bins[1:])/(2*Q), pe*Q, width=2/len(pe))
    plt.title('Estimated histogram of quantization error')
    plt.xlabel(r'$\theta / Q$')
    plt.ylabel(r'$\hat{p}_x(\theta) / Q$')
    plt.axis([-1, 1, 0, 1.2])
    plt.grid()

    plt.subplot(122)
    plt.plot(nf*2*np.pi, Pee*6/Q**2)
    plt.title('Estimated PSD of quantization error')
    plt.xlabel(r'$\Omega$')
    plt.ylabel(r'$\hat{\Phi}_{ee}(e^{j \Omega}) / \sigma_e^2$')
    plt.axis([0, np.pi, 0, 2])
    plt.grid()
    plt.tight_layout()

    plt.figure(figsize=(10, 6))
    ccf = ccf[N-K-1:N+K-1]
    kappa = np.arange(-len(ccf)//2, len(ccf)//2)
    plt.stem(kappa, ccf, use_line_collection=True)
    plt.title('Cross-correlation function between input signal and error')
    plt.xlabel(r'$\kappa$')
    plt.ylabel(r'$\varphi_{xe}[\kappa]$')
    plt.grid()

# quantization step
Q = 1/(2**(w-1))
# compute input signal
np.random.seed(1)
x = np.random.uniform(size=N, low=xmin, high=(-xmin-Q))
# quantize signal
xQ = uniform_midtread_quantizer(x, Q)
e = xQ - x
# analyze quantizer
analyze_quantizer(x, e)
```

    SNR = 48.090272 in dB

**Exercise**

* Change the number of bits `w` and check if the derived SNR holds
* How does the SNR change if you lower the magnitude of the minimum amplitude `xmin` of the input signal?
* What happens if you chose the magnitude of the minimum amplitude `xmin` in the range of the quantization step? Why?

Solution: The numerically computed SNR conforms well to the theoretic result derived above. Lowering the magnitude of the minimum amplitude results in a lower SNR as predicted above. The input signal $x[k]$ is correlated to the quantization error $e[k]$ if the magnitude of the minimum amplitude is lowered such that it is close to the quantization step. Here the assumptions made for the statistical model of the quantization error do not hold.

### Harmonic Signal

For a harmonic input signal $x[k] = x_\text{max} \cdot \cos[\Omega_0 k]$ the variance $\sigma_x^2$ is given by its squared [root mean square](https://en.wikipedia.org/wiki/Root_mean_square) (RMS) value

\begin{equation}
\sigma_x^2 = \frac{x_\text{max}^2}{2}
\end{equation}

Introducing this into the definition of the SNR together with the variance $\sigma_e^2$ of the quantization error yields

\begin{equation}
SNR = 10 \cdot \log_{10} \left(2^{2 w} \cdot \frac{3}{2} \right) \approx 6.02 \, w + 1.76 \quad \text{in dB}
\end{equation}

The gain of 1.76 dB with respect to the case of a uniformly distributed input signal is due to the fact that the amplitude distribution of a harmonic signal is not uniform

\begin{equation}
p_x(\theta) = \frac{1}{\pi \sqrt{1 - (\frac{\theta}{x_\text{max}})^2}}
\end{equation}

for $|\theta| < x_\text{max}$. High amplitudes are more likely to occur. The relative power of the quantization error is lower for higher amplitudes which results in an increase of the average SNR.

### Normally Distributed Signal

So far, we did not consider clipping of the input signal $x[k]$, e.g. by ensuring that its minimum/maximum values do not exceed the limits of the quantizer. However, this cannot always be ensured for practical signals. Moreover, many practical signals cannot be modeled as a uniform distribution. For instance, a [normally distributed](../random_signals/important_distributions.ipynb#Normal-Distribution) random signal exceeds a given maximum value with non-zero probability. Hence, clipping will occur for such an input signal. Clipping results in overload distortions whose amplitude can be much higher that $\frac{Q}{2}$. For the overall average SNR both granular and overload distortions have to be included.

The root mean square (RMS) of the normal distributed input signal is given by its standard deviation $\sigma_x$. The RMS level $A$ of the input signal normalized to the maximum level of the quantizer as

\begin{equation}
A = \frac{\sigma_x}{x_\text{max}}
\end{equation}

The probability that clipping occurs can be derived from the [cumulative distribution function](../random_signals/important_distributions.ipynb#Normal-Distribution) (CDF) of the normal distribution as

\begin{equation}
\Pr \{ |x[k]| > x_\text{max} \} = 1 + \text{erf} \left( \frac{-1}{\sqrt{2} A} \right)
\end{equation}

where $x_\text{max} = - x_\text{min}$ was assumed. For a normally distributed signal with a given probability that clipping occurs $\Pr \{ |x[k]| > x_\text{max} \} = 10^{-5}$ the SNR can be approximately calculated to [[Zölzer](../index.ipynb#Literature)]

\begin{equation}
SNR \approx 6.02 \, w - 8.5 \quad \text{in dB}
\end{equation}

The reduction of the SNR by 8.5 dB results from the fact that small signal values are more likely to occur for a normally distributed signal. The relative quantization error for small signals is higher, which results in a lower average SNR. Overload distortions due to clipping result in a further reduction of the average SNR.

#### Example - Quantization of a normal distributed signal

The following example evaluates the SNR of a linear uniform quantizer with $w=8$ for a normally distributed signal $x[k]$. The SNR is computed and plotted for various RMS levels, the probabilities for clipping are shown additionally.

```python
from scipy.special import erf

w = 8  # wordlength of the quantizer
A = np.logspace(-2, 0, num=500)  # RMS levels
N = int(1e6)  # number of samples
np.random.seed(1)

def compute_SNR(a):
    '''Numerically evaluate SNR of a quantized normally distributed signal.'''
    # compute input signal
    x = np.random.normal(size=N, scale=a)
    # quantize signal
    xQ = uniform_midtread_quantizer(x, Q)
    e = xQ - x
    # compute SNR
    SNR = 10*np.log10((np.var(x)/np.var(e)))

    return SNR

def plot_SNR(A, SNR):
    '''Plot SNR.'''
    # plot results
    plt.figure(figsize=(8, 4))
    plt.plot(20*np.log10(A), SNR)
    plt.xlabel(r'RMS level $\sigma_x / x_\mathrm{min}$ in dB')
    plt.ylabel('SNR in dB')
    plt.grid()

# quantization step
Q = 1/(2**(w-1))
# compute SNR for given RMS levels
SNR = [compute_SNR(a) for a in A]
# plot results
plot_SNR(A, SNR)
# find maximum SNR
Amax = A[np.argmax(SNR)]
Pc = 1 + erf(-1/(np.sqrt(2)*Amax))
print(r'Maximum SNR = {0:2.3f} dB for A = {1:2.1f} dB with clipping probability {2:2.1e}'
      .format(np.array(SNR).max(), 20*np.log10(Amax), Pc))
```

    Maximum SNR = 40.854 dB for A = -11.7 dB with clipping probability 1.2e-04

**Exercise**

* Can you explain the overall shape of the SNR?
* For which RMS level and probability of clipping is the SNR optimal?
* Change the wordlength `w` of the quantizer. How does the SNR change?

Solution: The SNR is low for low RMS levels of the input signal since the relative level of the quantization error is high. The SNR increases with increasing level until the clipping errors become dominant which make the SNR decay after its maximum. The SNR is optimal for $A \approx -12$ dB which is equivalent to $\Pr \{ |x[k]| > x_\text{max} \} \approx 10^{-4}$. Increasing the wordlength by one bit increases the SNR approximately by 6 dB.

### Laplace Distributed Signal

The [Laplace distribution](../random_signals/important_distributions.ipynb#Laplace-Distribution) is a commonly applied model for speech and music signals. As for the normal distribution, clipping will occur with a non-zero probability. The probability that clipping occurs can be derived from the [cumulative distribution function](../random_signals/important_distributions.ipynb#Laplace-Distribution) (CDF) of the normal distribution as

\begin{equation}
\Pr \{ |x[k]| > x_\text{max} \} = e^{- \frac{\sqrt{2}}{A}}
\end{equation}

The SNR for a Laplace distributed signal is in general lower compared to a normal distributed signal. The reason for this is, that the Laplace distribution features low signal values with a higher and large values with a lower probability in comparison to the normal distribution. The relative quantization error for small signals is higher, which results in a lower average SNR. The probability of overload distortions is also higher compared to the normal distribution.

#### Example - Quantization of a Laplace distributed signal

The following example evaluates the SNR of a linear uniform quantizer with $w=8$ for a Laplace distributed signal $x[k]$. The SNR is computed and plotted for various RMS levels.

```python
w = 8  # wordlength of the quantizer
A = np.logspace(-2, 0, num=500)  # relative RMS levels
N = int(1e6)  # number of samples
np.random.seed(1)

def compute_SNR(a):
    '''Numerically evaluate SNR of a quantized Laplace distributed signal.'''
    # compute input signal
    x = np.random.laplace(size=N, scale=a/np.sqrt(2))
    # quantize signal
    xQ = uniform_midtread_quantizer(x, Q)
    e = xQ - x
    # compute SNR
    SNR = 10*np.log10((np.var(x)/np.var(e)))

    return SNR

# quantization step
Q = 1/(2**(w-1))
# compute SNR for given RMS levels
SNR = [compute_SNR(a) for a in A]
# plot results
plot_SNR(A, SNR)
# find maximum SNR
Amax = A[np.argmax(SNR)]
Pc = np.exp(-np.sqrt(2)/Amax)
print(r'Maximum SNR = {0:2.3f} dB for A = {1:2.1f} dB with clipping probability {2:2.1e}'
      .format(np.array(SNR).max(), 20*np.log10(Amax), Pc))
```

    Maximum SNR = 35.581 dB for A = -16.6 dB with clipping probability 7.1e-05

**Exercise**

* Compare the SNR for the Laplace distributed signal to the case of a normally distributed signal. What is different?

Solution: The overall SNR is lower compared to the case of a normally distributed signal. Its maximum is also at lower RMS levels. Both can be explained by the properties of the Laplace distribution discussed above.

**Copyright**

This notebook is provided as [Open Educational Resource](https://en.wikipedia.org/wiki/Open_educational_resources). Feel free to use the notebook for your own purposes. The text is licensed under [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/), the code of the IPython examples under the [MIT license](https://opensource.org/licenses/MIT). Please attribute the work as follows: *Sascha Spors, Digital Signal Processing - Lecture notes featuring computational examples*.


Lecture 3: Digital Audio Signals

Audio Processing, MED4, Aalborg University, 2021

By Jesper Kjær Nielsen (jkn@create.aau.dk) and Cumhur Erkut (cer@create.aau.dk)

Central aspects of the course
- [x] What is sound?
- [x] How is sound generated?
- [ ] **How is sound turned into signals (i.e., data) on a computer?**
- [ ] How can we analyse these signals (i.e. extract information from them)?
- [ ] How can we modify these signals?

# Sampling and reconstruction
In the next 20 minutes, you will learn
- What a **continuous-time** signal is.
- What a **discrete-time** signal is.
- How you turn a continuous-time signal into a discrete-time signal (i.e., **sampling**).
- How you turn a discrete-time signal into a continuous-time signal (i.e., **reconstruction**).

In the figure below, the following is happening:
1. An audio signal $p_\text{i}(t)$ is propagating through the air as **pressure variations**.
2. The microphone picks up the pressure variations and turns them into **voltage variations** $v_\text{i}(t)$.
3. The voltage is converted into a series of numbers $x_n$ via **sampling**.
4. A voltage signal $v_\text{o}(t)$ is **reconstructed** from the series of numbers $x_n$.
5. A loudspeaker converts the voltage variations into pressure variations $p_\text{o}(t)$.

## Continuous-time signal
A **continuous-time** signal is characterised by
- **time**: the signal has a value $x(t)$ for every possible time $t$
- **amplitude**: the signal value $x(t)$ can take on any value from a continuum of numbers (such as the real numbers).

A continuous-time signal is often also referred to as an **analog signal**.

Informally: You draw a continuous-time signal without lifting your pen from the paper.

## Sampling
- Storing a continuous-time signal on a computer requires an infinite amount of memory!
- Solution: We only measure the value of a continuous-time signal every $T_\text{s}$ seconds. This is called **sampling**.
- A very important quantity is
$$
    f_\text{s} = 1/T_\text{s}
$$
where
 - $f_\text{s}$ is the **sampling frequency** (measured in Hz) and describes how many times per second the continuous-time signal is sampled
 - $T_\text{s}$ is the **sampling time** (measured in seconds).

We can illustrate sampling by a person controlling a contact (see figure below):
1. When $T_\text{s}$ seconds has passed, the contact is pushed and released immidiately.
2. At that exact time instant, the value of the continuous-time signal $x(T_\text{s})$ is stored on the computer.
3. After another $T_\text{s}$ seconds, the contact is again pushed and released immidiately so that $x(2T_\text{s})$ is now stored.
4. If we keep pushing/relasing the contact every $T_\text{s}$ seconds, we will after $n$ times store the signal value $x(t_n)$ where
$$
    t_n = nT_\text{s} = n/f_\text{s}\ .
$$
The scaler $n$ is often referred to as the **sampling index**.

Note that people often write $x(t_n)$ as
$$
    x(t_n) = x_n = x[n]\ .
$$

## Discrete-time signal
A **discrete-time** signal is characterised by
- **time**: the signal only has a value $x_n$ at certain times, i.e., $t_n=nT_\text{s}$ for $n=\cdots,-3,-2,1,0,1,2,3,\cdots$. Therefore, the $x$-axis is often the sampling index $n$ instead of time.
- **amplitude**: the signal value $x_n$ can take on any value from a continuum of numbers (such as the real numbers).

A discrete-time signal is sometimes also referred to as a **digital signal** (although we will use this term for something slightly different later).

Informally: A discrete-time signal is a series of time-ordered numbers.

## Reconstruction
If we want to play back a discrete-time signal on, e.g., a loudspeaker, we have to convert the discrete-time signal back into a continuous-time signal.
- Converting a discrete-time signal $x_n$ into a continuous-time signal $x(t)$ is called **reconstruction**.
- Reconstruction is performed using two components:
 - **hold circuit**: holds a value $x_n$ for $T_\text{s}$ seconds. This will create a **staircase** signal.
 - **post filter**: smooth out the discountinuities in the staircase signal by using a low-pass filter with a cut-off frequency of $f_\text{s}/2$ Hz.

---
Note that we will talk much more about filtering in the next lectures.

## Summary
1. A **continuous-time** signal $x(t)$ can be drawn without lifting the pen from the paper.
2. A **discrete-time** signal $x_n = x(t_n)$ is a series of time-ordered numbers.
3. **Sampling** converts a $x(t)$ into $x_n$ by measuring the value of $x(t)$ at the times
$$
    t_n = nT_\text{s} = n/f_\text{s}
$$
where
 - $n$ is the **sampling index**
 - $T_\text{s}$ is the **sampling time**
 - $f_\text{s}=1/T_\text{s}$ is the **sampling frequency**
4. **Reconstruction** converts $x_n$ into $x(t)$ by first creating a staircase signal from $x_n$ and then by filtering this staircase signal with a low-pass filter.

## Assignment
Assume that we have a continuous-time signal given by
$$
    x(t) = \sin(2\pi f t)
$$
where the frequency of the sinusoid is $f=1$ Hz.
1. Sketch the signal from $t=-0.5$ s to $t= 2$ s.

We now sample the signal with a sampling frequency of $f_\text{s}=4$ Hz.
2. What is the sampling time $T_\text{s}$?
3. Sketch the sampled signal $x_n$ (in a new plot with the time index $n$ on the $x$-axis).
4. Sketch the staircase signal $x_\text{sc}(t)$ from $x_n$.
5. Repeat 2. to 4. with a sampling frequency of $f_\text{s}=1$ Hz.

# Aliasing
In the next 20 minutes, you will learn
- How we write a discrete-time sinusoid.
- What aliasing is.
- How we can avoid aliasing by selecting the sampling frequency $f_\text{s}$.
- What an anti-aliasing filter is and why we need it.

### Discrete-time sinusoid
As we have seen in the first two lectures, a continuous-time sinusoid can be written as
$$
    x(t) = A\cos(\Omega t+\psi)
$$
where
- $A\geq 0$ is an amplitude
- $\Omega=2\pi f$ is a frequency measured in rad/s
- $\psi$ is the inial phase.

Let us now sample this signal with a sampling frequency of $f_\text{s}$ Hz. We then get the discrete-time sinusoid
\begin{align}
    x_n &= x(t_n) = x(n/f_\text{s}) = A\cos(\Omega n/f_\text{s}+\psi) = A\cos((2\pi f/f_\text{s}) n+\psi)\\
    &= A\cos(\omega n+\psi)
\end{align}
where
- $\omega = \Omega f_\text{s}= 2\pi f/f_\text{s}$ is the **digital frequency** measured in **radians/sample**.

For a discrete-time signal, $\omega = 2\pi$ corresponds to the sampling frequency, and we will also write this frequency as $\omega_\text{s}$.

## What is Aliasing?
- **Aliasing** comes from the word **alias**.
- It refers to that a sinusoidal component of one frequency is 'disguising' itself as a sinusoidal component with another frequency.

As an example, let us try to sample the three continuous-time sinusoids
\begin{align}
    x(t) &= \cos(2\pi f t)\\
    y(t) &= \cos(2\pi (f_\text{s}-f) t)
\end{align}
using a sampling frequency of $f_\text{s}$ Hz.

For the first sinusoid, we get that
$$
    x_n = \cos(\omega n)\ .
$$

For the second sinusoid, we get that
\begin{align}
    y_n &= \cos((\omega_\text{s}-\omega) n) = \text{Re}\left[\mathrm{e}^{j(\omega_\text{s}-\omega) n}\right] = \text{Re}\left[\mathrm{e}^{j\omega_\text{s} n}\mathrm{e}^{-j\omega n}\right]
\end{align}
However, we have that
$$
    \mathrm{e}^{j\omega_\text{s}n} = \mathrm{e}^{j2\pi n} = \cos(2\pi n)+j\sin(2\pi n) = 1
$$
for all time indices $n$ since $n$ is an integer. This means that
$$
    y_n = \text{Re}\left[\mathrm{e}^{-j\omega n}\right] = \cos(-\omega n) = \cos(\omega n)
$$
which is exactly the same as $x_n$.

**Observation:** Even though the continuous time signals $x(t)$ and $y(t)$ have **different frequencies**, the discrete-time signals $x_n$ and $y_n$ have the **same digital frequency**.

Some consequences:
- Reconstructing a continuous-time signal from $y_n$ results in $x(t)$ - not $y(t)$.
- We say that $y(t)$ has been **aliased** when we cannot recover it again after sampling it.
- A discrete-time sinusoid of digital frequency $\omega=2\pi f/f_\text{s}$, could be a sampled continuous-time sinusoid given by
$$
    y(t) = A\cos((\Omega+k2\pi f_\text{s})t + \psi)
$$
for any integer $k$.

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

def sinusoid(samplingIndices, digitalFreq):
    '''Compute a cosine'''
    return np.cos(2*np.pi*digitalFreq*samplingIndices)

nData = 100
samplingFreq = 100 # Hz
samplingTime = 1/samplingFreq # s
samplingIndices = np.arange(nData)
time = samplingIndices*samplingTime
freqA = 10 # Hz
freqB = 5 # Hz (samplingFreq-freqA)

# plot the results
plt.figure(figsize=(10,6))
plt.plot(time, sinusoid(samplingIndices,freqA/samplingFreq), linewidth=2, marker='o', label="$x(t)$")
plt.plot(time, sinusoid(samplingIndices,freqB/samplingFreq), linewidth=2, marker='o', label="$y(t)$")
plt.legend()
plt.xlim((time[0],time[nData-1])), plt.ylim((-1.5,1.5))
plt.xlabel('time [s]'), plt.ylabel('Amplitude [.]');
```

## Nyquist-Shannon sampling theorem
To avoid aliasing, the maximum frequency $f_\text{max}$ in a continuous-time signal must satisfy that
$$
    2f_\text{max} < f_\text{s}
$$
where $f_\text{s}$ is the sampling frequency.

We can satisfy the sampling theorem in two ways:
1. Select the sampling frequency $f_\text{s}$ high enough
2. Pre-filter the continuous-time signal with a low-pass filter (a so-called **anti-aliasing filter**) with a cut-off frequency below $f_\text{s}/2$.

### Typical sampling frequencies used for recording audio
| Application | Sampling frequency $f_\text{s}$ |
|-------------|---------------------------------|
|CD           | 44100 Hz|
|Narrowband speech| 8000 Hz|
|Widebannd speech, VoIP| 16000 Hz|
|Video recorders| 48000 Hz|
|DVD-audio and Blu-ray| 96000 and 192000 Hz|

## Anti-aliasing filter
- Often, we do not know the highest frequency $f_\text{max}$ in our input signal.
- Instead, we simply filter out all frequency content above $f_\text{s}/2$ to avoid aliasing.
- This filter is called an **anti-aliasing filter** and is present in all practical sampling blocks.

### Aliasing also occours in videos and images

## Summary
1. A discrete-time sinusoid is written as
$$
    x_n = A\cos(\omega n +\psi)
$$
where $A$ and $\psi$ have the same meaning as for the continuous-time sinusoid and
- $\omega = 2\pi f/f_\text{s}$ is the **digital frequency** and measured in rad/sample
- $f_\text{s}$ is the **sampling frequency** measured in Hz.
2. Aliasing refers to when the frequency of a sinusoid is lowered due to undersampling the signal.
3. To avoid aliasing, we must satisfy **Nyquist's sampling theorem** stating that
$$
    2f_\text{max} < f_\text{s}
$$
where $f_\text{max}$ is the maximum frequency in the continuous-time input signal.
4. We can limit the maximum frequency of a continuous-time input signal by passing it through an **anti-aliasing filter**. This filter, which is a low-pass filter, ensures that aliasing does not occur.

# Binary numbers
In the next 20 minutes, you will learn
- What a binary number is
- What a bit and a byte is
- How you store data on, e.g., a computer or a CD
- To get the following slightly geeky joke :)

> There are 10 types of people in this world.
> Those who understand binary numbers and those who don't!

## The decimal numer systems
We are used to the decimal number system where we encounter numbers such 3, 42, and 89809.

Let's look at the example
$$
    1314\ .
$$
We can make the following observations about this decimal number:
- The number consists of four symbols, each called a **digit**
- Each digit can be one of ten possible symbols (either 0, 1, 2, 3, 4, 5, 6, 7, 8, or 9).
- The order of the digits matter. For example, the right-most one in 1314 represents the number of 10s whereas the left-most one represents the number of 1000s. A number system with ordering is called a **positional number system**.

We can rewrite the decimal number 1314 as
\begin{align}
    1314 &= 1000 + 300 + 10 + 4\\
         &= 1\cdot 1000 + 3\cdot 100 + 1\cdot 10 + 4\cdot 1\\
         &= 1\cdot 10^3 + 3\cdot 10^2 + 1\cdot 10^1 + 4\cdot 10^0\ .
\end{align}

In general, we can write an $N$ digit decimal number $d_{N-1}d_{N-2}\cdots d_2d_1d_0$ as
$$
    d_{N-1}d_{N-2}\cdots d_2d_1d_0 = \sum_{n=0}^{N-1}d_n10^n\ .
$$

Note that
- $d_n\in\{0,1,2,3,4,5,6,7,8,9\}$
- 10 is the number of symbols that $d_n$ can take on and is called the **base** of the decimal number system.

Let us now allow for an arbitrary base $b$. Then we can write numbers as
$$
    d_{N-1}d_{N-2}\cdots d_2d_1d_0 = \sum_{n=0}^{N-1} d_n b^n
$$
where
- $d_n\in\{0,1,\ldots,b-1\}$
- $b$ is the base of the number.

For different values of $b$, we get different number systems. Some examples are
- $b=10$: The **decimal number system** with possible symbols 0,1,2,3,4,5,6,7,8,9
- $b=2$: The **binary number system** with possible symbols 0,1
- $b=16$: The **hexadecimal number system** with possible symbols 0,1,2,3,4,5,6,7,8,9, A, B, C, D, E, F

## The binary number system
A binary number
- has base 2 and
- is written only in terms of 0s and 1s.

An example of a binary number is
$$
    0110\ 1101_2
$$
where the subscript 2 is here only added to make it explicit that $0110\ 1101$ is a binary number.

Note that
- a 'digit' in a binary number is called a **bit**
- a collection of 8 bits is called a **byte** with symbol B
- a computer represents everything (numbers, colours, text, etc.) as binary numbers

Thus, the binary number $0110\ 1101_2$ has 8 bits and 1 byte (or 1 B)

### Converting binary numbers to decimal numbers
To convert from a binary number to a decimal number, we simple use the expression
$$
    d_{N-1}d_{N-2}\cdots d_2d_1d_0 = \sum_{n=0}^{N-1} d_n b^n\ .
$$

As an example, we get that $1101_2$ converts to
\begin{align}
    1101_2 &= 1\cdot 2^3 + 1\cdot 2^2 + 0\cdot 2^1 + 1\cdot 2^0\\
    &= 1 \cdot 8 + 1 \cdot 4 + 0\cdot 2 + 1\cdot 1\\
    &= 13_{10}\ .
\end{align}

Converting from decimal numbers to binary numbers is also possible, but is not covered here.

### Adding binary numbers
You do it exactly as you learned in 2nd grade with decimal numbers. That is,
- $0_2+0_2 = 0_2$
- $0_2+1_2 = 1_2$
- $1_2+1_2 = 0_2$ with $1_2$ in carry

Using these three rules, we obtain
$$
\begin{array}[t]{r}
    0100\ 1001_2 \\
+ \ 0111\ 1100_2 \\ \hline
    1100\ 0101_2
\end{array}
$$

### Example: Representing text using binary numbers

</center

### Example: Storing data on a disc
Information is stored by making tiny indentations known as **pits** on a disc.
- A pit represents a 0
- The opposite of a pit (called land) represents a 1

</center

- The binary data is stored along one long spiral on the disc.
 - **CD**: The spiral is 5.7 km long
 - **DVD**: The spiral is 12.3 km long
 - **Blu-ray**: The spiral is 28.4 km long
- A laser is used for reading the binary data by following this spiral path.

 There are 10 types of people in this world.
> Those who understand binary numbers and those who don't!

## Assignment
1. Convert the following binary numbers to decimal numbers: $10_2$, $110_2$, and $0101_2$.
2. How many different binary numbers could you write with 4 bits?
3. How many bits can be stored on a 700 GB harddrive?
4. If you have an internet connection with a download speed of 10 Mbit/s, how much time does it then take to download a 100 MB file?

---
Bonus info:
1. With base $b=2$, a binary number can be written as
$$
    d_{N-1}d_{N-2}\cdots d_2d_1d_0 = \sum_{n=0}^{N-1} d_n b^n\ .
$$
2. G means billion ($10^9$), M means million ($10^6$), k means thousand ($10^3$), and B means byte (8 bit)

# Quantisation
In the next 20 minutes, you will learn
- What quantisation is and why it is necessary
- How you will typically do it
- What signal-to-noise ratio (SNR) and dynamic range is

## Example: Storing $\pi$ on a computer
How would you store $\pi$ (or other irrational numbers) on a computer?

</center

- It requires an **infinite** amount of memory to store $\pi$ on a computer.
- Therefore, we have to store an approximation to $\pi$ with only a **finite** number of digits. Let us call this approximation $p$.
- The approximation error $e$ can be written as
$$
    e = \pi-p\ .
$$

**Example**: Let $p$ contain only the first two digits of $\pi$ after the comma (i.e., $p=3.14$). Then
$$
    e = \pi-p = \pi-3.14 = 0.001592653589\ldots
$$

We say that we have rounded of (or **quantized**) $\pi$ to its nearest two-digits-after-the-comma representation.

## The need for quantisation
Sampling converts a continuous-time signal into a discrete-time signal. That is, we go from an infinite number of time values to a finite number of time values.

However, we also have to do something about the signal value $x(t_n)=x_n$ for every sampling time, so that we can store this number on the computer using a finite number of digits. This is called **quantisation**.

## Uniform quantisation
Assume that we sample the signal value $x_n$ and that
- the signal value is in the interval $(-\alpha,\alpha)$
- we have $\beta$ bits available for storing this signal value.
Note that we can represent $2^\beta$ different values with $\beta$ bits.

We now do the following.
1. Divide the interval $(-\alpha,\alpha)$ into $2^\beta$ equally large cells, each of size
$$
    \Delta = \frac{\alpha-(-\alpha)}{2^\beta} = \frac{2\alpha}{2^\beta} = \frac{\alpha}{2^{\beta-1}}\ .
$$
2. Round (or **quantise**) the signal value $x_n$ to the value $y_n$ at the nearest cell boundary, i.e.,
$$
    y_n = Q(x_n) = \Delta \left\lfloor\frac{x_n}{\Delta}\right\rceil = \Delta \left\lfloor\frac{x_n}{\Delta}+\frac{1}{2}\right\rfloor
$$
where $\lfloor\cdot\rceil$ and $\lfloor\cdot\rfloor$ refer to the rounding and flooring operations, respectively.

### Example: 3 bit quantisation
In the figure below, the continuous-time signal (dashed gray) is first sampled (green) and then quantised (orange) using a three bit quantiser. The horisontal dashed red lines mark the quantisation levels. The final bit stream is
$$
    100\ 110\ 111\ 111\ 111\ 110\ 110\ 100\ 011\ 010\ 001\ 001\ .
$$

## Quantisation error
The quantisation error $e_n$ is the difference between the signal value $x_n$ and its rounded value $y_n=Q(x_n)$, i.e.,
$$
    e_n = x_n-Q(x_n)\ .
$$

We can rearrange this into
$$
    Q(x_n) = x_n+e_n\ .
$$
That is, we can think of quantisation as **adding an error** to the signal value $x_n$.

A measure of quantisation quality is how big the average power of $e_n$ is compared to the average power of $x_n$. The average power of, e.g., $e_n$ is defined as
$$
    P_e = \frac{1}{N}\sum_{n=0}^{N-1} e_n^2\ .
$$

If we define $P_x$ in a similar way, the signal-to-noise ratio (SNR) is defined as
$$
    \text{SNR} = 10\log_{10}\frac{P_x}{P_e}\ ,
$$
and it is measured in decibel (dB).

Now, assume that
- the signal values $x_n$ take on values in $(-\alpha,\alpha)$ equally often (a uniform distribution)
- the quantisation errors $e_n$ take on values in $(-\Delta/2,\Delta/2)$ equally often (a uniform distribution)

We then get
\begin{align}
    P_x &= \frac{(2\alpha)^2}{12} = \frac{\alpha^2}{3}\\
    P_e &= \frac{\Delta^2}{12} = \frac{1}{12}\left(\frac{\alpha}{2^{\beta-1}}\right)^2\ .
\end{align}
These results can be derived by computing the variance of a uniform distribution.

Finally, we get the SNR
\begin{align}
    \text{SNR} &= 10\log_{10}\frac{P_x}{P_e} = 10\log_{10}\left(\frac{\alpha^2}{3}12\left(\frac{2^{\beta-1}}{\alpha}\right)^2\right)\\
    &= 10\log_{10}\left(2^{2\beta}\right) = \beta 20 \log_{10} 2 \approx 6\beta\ .
\end{align}

Thus, for every additional bit, the SNR is improved by approximately 6 dB.

## Dynamic range
The **dynamic range** is the ratio between the loudest and softest values we can represent using a $\beta$ bit quantiser.
- **Softest value**: $1$
- **Loudest value**: $2^\beta$

The dynamic range of a quantiser is thus
$$
    \text{DR} = 10\log_{10}\left(\left(\frac{2^\beta}{1}\right)^2\right) = 10\log_{10}\left(2^{2\beta}\right) = \beta 20 \log_{10} 2 \approx 6\beta\ .
$$

Thus, we get a dynamic range of 96 dB for a 16 bit quantiser (typical CD quality) and 144 dB for a 24 bit quantiser. Note that the dynamic range of the human ear is approximately 120 dB.

## Summary
1. A quantiser rounds the signal values to a value on a grid.
2. All the points of the grid can be represented using $\beta$ bits which results in $2^\beta$ possible values.
3. Quantisation introduces noise into the digital signal. The signal-to-noise ratio (SNR) describes how powerful the signal is compared to this quantisation noise.
4. The SNR (and dynamic range) depends on the number of bits used as
$$
    \beta20\log_{10} \approx 6\beta\ .
$$

## Assignment
Think of ways for increasing the dynamic range without increasing the number of the bits. *Hint* = Could non-uniform quantisation work?


```python
try:
    import openmdao.api as om
    import dymos as dm
except ImportError:
    !python -m pip install openmdao[notebooks]
    !python -m pip install dymos[docs]
    import openmdao.api as om
    import dymos as dm
```

# Modeling Dynamic Systems with Dymos

```{admonition} Things you'll learn through this example
- How to define a basic Dymos ODE system.
- How to explicitly propagate the system from some initial state.
```

Dymos is a library for modeling dynamic systems and performing optimal
control with the [OpenMDAO](https://github.com/OpenMDAO/OpenMDAO) framework.
Dynamic systems are typically defined by some set of ordinary
differential equations (the ODE) which governs their behavior.

Consider a simple damped harmonic oscillator.

\begin{align}
    \ddot{x} &= -\frac{kx}{m} - \frac{c \dot{x}}{m}
\end{align}

Converting this to a first order system results in an ODE system with two states:

\begin{align}
    \dot{x} &= v \\
    \dot{v} &= -\frac{kx}{m} - \frac{c \dot{x}}{m}
\end{align}

## The OpenMDAO model of the ODE

In Dymos, the ODE is an OpenMDAO System (a Component, or a Group of components).
The following ExplicitComponent computes the velocity rate for the damped harmonic oscillator.

More detail on the workings of an ExplicitComponent can be found in the OpenMDAO documentation.  In summary, an ExplicitComponent used as part of an ODE in Dymos should override the following methods:

- **initialize**:  Called at setup, and used to define options for the component.  **ALL** Dymos ODE components should have the property `num_nodes`, which defines the number of points at which the outputs are simultaneously computed.
- **setup**: Used to add inputs and outputs to the component, and declare which outputs (and indices of outputs) are dependent on each of the inputs.
- **compute**: Used to compute the outputs, given the inputs.
- **compute_partials**: Used to compute the derivatives of the outputs w.r.t. each of the inputs analytically.  This method may be omitted if finite difference or complex-step approximations are used, though analytic is recommended.

```python
import numpy as np
import openmdao.api as om

class OscillatorODE(om.ExplicitComponent):
    """
    A Dymos ODE for a damped harmonic oscillator.
    """

    def initialize(self):
        self.options.declare('num_nodes', types=int)

    def setup(self):
        nn = self.options['num_nodes']

        # Inputs
        self.add_input('x', shape=(nn,), desc='displacement', units='m')
        self.add_input('v', shape=(nn,), desc='velocity', units='m/s')
        self.add_input('k', shape=(nn,), desc='spring constant', units='N/m')
        self.add_input('c', shape=(nn,), desc='damping coefficient', units='N*s/m')
        self.add_input('m', shape=(nn,), desc='mass', units='kg')

        # self.add_output('x_dot', val=np.zeros(nn), desc='rate of change of displacement', units='m/s')
        self.add_output('v_dot', val=np.zeros(nn), desc='rate of change of velocity', units='m/s**2')

        self.declare_partials(of='*', wrt='*', method='fd')

    def compute(self, inputs, outputs):
        x = inputs['x']
        v = inputs['v']
        k = inputs['k']
        c = inputs['c']
        m = inputs['m']

        f_spring = -k * x
        f_damper = -c * v

        outputs['v_dot'] = (f_spring + f_damper) / m
```

```{admonition} Things to note about the ODE system
- In this case, the ODE is a function of both states, but this isn't always the case.  If the dynamics aren't functions of some states, those states aren't needed as inputs.
- This ODE only computes the rate of change of velocity.  Since the rate of change of displacement can directly be obtained from another state variable, it doesn't need to be computed by the ODE. This would also be true if the state's rate was a control, design parameter, or input parameter value.
- It's possible that we might want to use parameters `c`, `k`, and `m` as design variables at some point, so they're also included as inputs here. Alternatively, if we had no interest in ever treating them as design variables, we could add their values as options to the ODE system in the `initialize` method.
```

## Hello World: Propagating the dynamics

One of the first things one might be interested in regarding an ODE is propagating it from some given initial conditions.
This is known as solving the initial value problem (IVP), and there are many software packages that can do this.
The following is a minimal script that starts the system at some set of initial conditions and propagates them for some fixed duration.
Some elements of this code will be explained later, but we'll hit the highlights now.

```python
import openmdao.api as om
import dymos as dm
import matplotlib.pyplot as plt
# plt.switch_backend('Agg')  # disable plotting to the screen

from dymos.examples.oscillator.doc.oscillator_ode import OscillatorODE

# Instantiate an OpenMDAO Problem instance.
prob = om.Problem()

# Instantiate a Dymos Trajectory and add it to the Problem model.
traj = dm.Trajectory()
prob.model.add_subsystem('traj', traj)

# Instantiate a Phase and add it to the Trajectory.
# Here the transcription is necessary but not particularly relevant.
phase = dm.Phase(ode_class=OscillatorODE, transcription=dm.Radau(num_segments=4))
traj.add_phase('phase0', phase)

# Tell Dymos the states to be propagated using the given ODE.
phase.add_state('v', rate_source='v_dot', targets=['v'], units='m/s')
phase.add_state('x', rate_source='v', targets=['x'], units='m')

# The spring constant, damping coefficient, and mass are inputs to the system
# that are constant throughout the phase.
phase.add_parameter('k', units='N/m', targets=['k'])
phase.add_parameter('c', units='N*s/m', targets=['c'])
phase.add_parameter('m', units='kg', targets=['m'])

# Setup the OpenMDAO problem
prob.setup()

# Assign values to the times and states
prob.set_val('traj.phase0.t_initial', 0.0)
prob.set_val('traj.phase0.t_duration', 15.0)

prob.set_val('traj.phase0.states:x', 10.0)
prob.set_val('traj.phase0.states:v', 0.0)

prob.set_val('traj.phase0.parameters:k', 1.0)
prob.set_val('traj.phase0.parameters:c', 0.5)
prob.set_val('traj.phase0.parameters:m', 1.0)

# Perform a single execution of the model (executing the model is required before simulation).
prob.run_model()

# Perform an explicit simulation of our ODE from the initial conditions.
sim_out = traj.simulate(times_per_seg=50)

# Plot the state values obtained from the phase timeseries objects in the simulation output.
t_sol = prob.get_val('traj.phase0.timeseries.time')
t_sim = sim_out.get_val('traj.phase0.timeseries.time')

states = ['x', 'v']
fig, axes = plt.subplots(len(states), 1)
for i, state in enumerate(states):
    sol = axes[i].plot(t_sol, prob.get_val(f'traj.phase0.timeseries.states:{state}'), 'o')
    sim = axes[i].plot(t_sim, sim_out.get_val(f'traj.phase0.timeseries.states:{state}'), '-')
    axes[i].set_ylabel(state)
axes[-1].set_xlabel('time (s)')
fig.legend((sol[0], sim[0]), ('solution', 'simulation'), 'lower right', ncol=2)
plt.tight_layout()
plt.show()
```

## What happened?

This script consists of the following general steps:

1. After importing relevant packages, an OpenMDAO Problem is instantiated
2. A Dymos Trajectory object is instantiated, and a single Phase named `'phase0'` is added to it.
   1. That Phase takes the ODE _class_ as one of its arguments.  It will instantiate instances of it as needed.
   2. The transcription determines how the implicit integration and optimization are performed.  It's necessary but not particularly relevant in this example.
3. The states to be integrated are added to the phase.
   1. Each state needs a rate source - an ODE-relative path of the output which provides the time derivative of the state variable.
      For the rate_source of `x`, we provide `v`.  Dymos understands this is the name of one of the other states (or time, or controls, or parameters).
   2. Those states which are inputs to the ODE need to provide their targets in the ODE (again, with an ODE-relative path).
4. The problem is setup (this prepares the model for execution in OpenMDAO).
5. Default values are assigned to the states and time.
   1. Variables `t_initial` and `t_duration` (the initial time and duration of the Phase) are scalars.
   2. State variables are vectors whose values are provided throughout the Phase.  Here they're all being filled with a single value (10.0 for displacement, 0.0 for the velocity)
6. `Problem.run_model` is called.  This executes the Problem's `model` one time.  This is a necessary step before using the `Trajectory.simulate` method.
7.  The trajectory is simulated using the `simulate()` method and the results returned as an OpenMDAO Problem instance called `sim_out`.
8.  The states are plotted using values obtained from the _timeseries_.  Phases contain `_timeseries_` data that provides contiguous values regardless of the transcription used.

Method `simulate` exists on both Trajectory and Phase objects.
It uses the [scipy.integrate.solve_ivp](https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.solve_ivp.html) function to propagate the states defined in each phase of the trajectory from their initial values at the initial time to some final value at `time = t_initial + t_duration`.

In Dymos, the `simulate` method is useful for testing the functionality of an ODE (making sure it behaves as expected) and for checking the validity of answers after optimization.
However, it cannot solve boundary value problems.

## Why is the solution different from the simulation results?

The plots above display both the solution from the implicit transcription (blue dots) and the results of the simulation (orange line).
Here they do not match because we only performed a single execution of the model.
**The purpose of a model execution in Dymos is to calculate the objective and constraints for the optimizer.**
These constraints include the collocation _defect_ constraints, which (when driven to zero) indicate that the current polynomial representation of the state-time history matches the physically correct trajectory.
In this case, no iteration was performed, and thus the solution is not physically valid.

To be clear, the output of Dymos in this case is not a physically valid trajectory.
The `simulate()` call after executing the model is the expected result using the variable step integrator from Scipy.

There are two ways to converge this solution using the implicit transcription techniques in Dymos.
1. We can run an optimization driver with some "dummy" objective to converge the collocation defect constraints.
2. We can have Dymos use a nonlinear solver to vary the state time-history until the collocation defect constraints are satisfied.

Traditionally, many collocation optimal control techniques have use an optimizer-based approach because it is extremely efficient.
OpenMDAO provides a lot of versatility in adding nonlinear solvers within the optimization problem.
In our case, using a solver to converge state-time history means that the optimizer "sees" a physical trajectory at every iteration.
In an optimization context, we can use the solver-based convergence of defects to obtain a shooting method with analytic derivatives.

## Using a solver to converge the physical trajectory

We let Dymos know that one or more states should be converged using the `solve_segments=forward` argument.
If passed to the transcription, it applies to all states.
Otherwise, we can pass it only to certain states as an argument to `add_state` or `set_state_options`.

```python
import openmdao.api as om
import dymos as dm
import matplotlib.pyplot as plt
plt.switch_backend('Agg')  # disable plotting to the screen

from dymos.examples.oscillator.doc.oscillator_ode import OscillatorODE

# Instantiate an OpenMDAO Problem instance.
prob = om.Problem()

# Instantiate a Dymos Trajectory and add it to the Problem model.
traj = dm.Trajectory()
prob.model.add_subsystem('traj', traj)

# Instantiate a Phase and add it to the Trajectory.
phase = dm.Phase(ode_class=OscillatorODE, transcription=dm.Radau(num_segments=4, solve_segments='forward'))
traj.add_phase('phase0', phase)

# Tell Dymos the states to be propagated using the given ODE.
phase.add_state('x', fix_initial=True, rate_source='v', targets=['x'], units='m')
phase.add_state('v', fix_initial=True, rate_source='v_dot', targets=['v'], units='m/s')

# The spring constant, damping coefficient, and mass are inputs to the system that are
# constant throughout the phase.
phase.add_parameter('k', units='N/m', targets=['k'])
phase.add_parameter('c', units='N*s/m', targets=['c'])
phase.add_parameter('m', units='kg', targets=['m'])

# Setup the OpenMDAO problem
prob.setup()

# Assign values to the times and states
prob.set_val('traj.phase0.t_initial', 0.0)
prob.set_val('traj.phase0.t_duration', 15.0)

prob.set_val('traj.phase0.states:x', 10.0)
prob.set_val('traj.phase0.states:v', 0.0)

prob.set_val('traj.phase0.parameters:k', 1.0)
prob.set_val('traj.phase0.parameters:c', 0.5)
prob.set_val('traj.phase0.parameters:m', 1.0)

# Now we're using the optimization driver to iteratively run the model and vary the
# phase duration until the final y value is 0.
prob.run_model()

# Perform an explicit simulation of our ODE from the initial conditions.
sim_out = traj.simulate(times_per_seg=50)

# Plot the state values obtained from the phase timeseries objects in the simulation output.
t_sol = prob.get_val('traj.phase0.timeseries.time')
t_sim = sim_out.get_val('traj.phase0.timeseries.time')

states = ['x', 'v']
fig, axes = plt.subplots(len(states), 1)
for i, state in enumerate(states):
    sol = axes[i].plot(t_sol, prob.get_val(f'traj.phase0.timeseries.states:{state}'), 'o')
    sim = axes[i].plot(t_sim, sim_out.get_val(f'traj.phase0.timeseries.states:{state}'), '-')
    axes[i].set_ylabel(state)
axes[-1].set_xlabel('time (s)')
fig.legend((sol[0], sim[0]), ('solution', 'simulation'), 'lower right', ncol=2)
plt.tight_layout()
plt.show()
```

## Using an optimization driver to converge the physical trajectory

Alternatively, we can use an optimization driver to converge the state time histories.

In the case of an initial value problem (fixed time duration, fixed initial states, and no controls or parameters as design variables) there are no
degrees of freedom to optimize the problem, just single possible trajectory which satisfies the collocation constraints.

In OpenMDAO (and thus Dymos) optimizers require an objective.
Even though the initial time and duration of the phase are fixed, we provide the final time as a "dummy" objective here.

```python
import openmdao.api as om
import dymos as dm
import matplotlib.pyplot as plt
plt.switch_backend('Agg')  # disable plotting to the screen

from dymos.examples.oscillator.doc.oscillator_ode import OscillatorODE

# Instantiate an OpenMDAO Problem instance.
prob = om.Problem()

# We need an optimization driver.  To solve this simple problem ScipyOptimizerDriver will work.
prob.driver = om.ScipyOptimizeDriver()

# Instantiate a Dymos Trajectory and add it to the Problem model.
traj = dm.Trajectory()
prob.model.add_subsystem('traj', traj)

# Instantiate a Phase and add it to the Trajectory.
phase = dm.Phase(ode_class=OscillatorODE, transcription=dm.Radau(num_segments=4))
traj.add_phase('phase0', phase)

# Tell Dymos that the duration of the phase is bounded.
phase.set_time_options(fix_initial=True, fix_duration=True)

# Tell Dymos the states to be propagated using the given ODE.
phase.add_state('x', fix_initial=True, rate_source='v', targets=['x'], units='m')
phase.add_state('v', fix_initial=True, rate_source='v_dot', targets=['v'], units='m/s')

# The spring constant, damping coefficient, and mass are inputs to the system that are
# constant throughout the phase.
phase.add_parameter('k', units='N/m', targets=['k'])
phase.add_parameter('c', units='N*s/m', targets=['c'])
phase.add_parameter('m', units='kg', targets=['m'])

# Since we're using an optimization driver, an objective is required.  We'll minimize
# the final time in this case.
phase.add_objective('time', loc='final')

# Setup the OpenMDAO problem
prob.setup()

# Assign values to the times and states
prob.set_val('traj.phase0.t_initial', 0.0)
prob.set_val('traj.phase0.t_duration', 15.0)

prob.set_val('traj.phase0.states:x', 10.0)
prob.set_val('traj.phase0.states:v', 0.0)

prob.set_val('traj.phase0.parameters:k', 1.0)
prob.set_val('traj.phase0.parameters:c', 0.5)
prob.set_val('traj.phase0.parameters:m', 1.0)

# Now we're using the optimization driver to iteratively run the model and vary the
# phase duration until the final y value is 0.
prob.run_driver()

# Perform an explicit simulation of our ODE from the initial conditions.
sim_out = traj.simulate(times_per_seg=50)

# Plot the state values obtained from the phase timeseries objects in the simulation output.
t_sol = prob.get_val('traj.phase0.timeseries.time')
t_sim = sim_out.get_val('traj.phase0.timeseries.time')

states = ['x', 'v']
fig, axes = plt.subplots(len(states), 1)
for i, state in enumerate(states):
    sol = axes[i].plot(t_sol, prob.get_val(f'traj.phase0.timeseries.states:{state}'), 'o')
    sim = axes[i].plot(t_sim, sim_out.get_val(f'traj.phase0.timeseries.states:{state}'), '-')
    axes[i].set_ylabel(state)
axes[-1].set_xlabel('time (s)')
fig.legend((sol[0], sim[0]), ('solution', 'simulation'), 'lower right', ncol=2)
plt.tight_layout()
plt.show()
```

## But the solution still doesn't match the simulation

If you look at the plots from the last two examples, you'll notice that the state time-history from the solution has some pretty significant deviations from the simulation results.
This is an important lesson in using implicit collocation techniques.

```{Note}
A converged trajectory isn't necessarily correct
```

As we mentioned before, the `simulate()` method exists to provide a check on a converged trajectory.
In this case, the trajectory found using `simulate()` doesn't really interpolate the solution from the collocation technique.
In the next section, we'll explain how to deal with this.