# Laboratório 7: Modelo de Epidemia

### Referente ao capítulo 13

Nesse capítulo, desenvolveremos para um modelo simples: a vacinação para uma epidemia de uma infecção micro-parasítica. Imunidade permanente pode ser atingida através da vacina ou de recuperação natural. Todos nascem suscetíveis à doença. Queremos minimizar o número de infectados e o custo da vacina em um período de tempo fixo. Para isso, estudaremos o modelo compartimental SEIR padrão, onde $S$ é o compatimento de suscetíveis, $E$ é o compartimento de indivíduos infectados, mas não infectantes (período de latência da doença), $I$ é o compartimento dos infectantes e $R$ é o compartimento dos recuperados. A população total é dada por $N(t) = S(t) + E(t) + I(t) + R(t)$.

Seja $u(t)$ o percentual de suscetíveis sendo vacinados no tempo $t$.

|Parâmetro|Significado|
|---------|-----------|
|$b$|taxa de nascimento|
|$d$|taxa de morte natural|
|$c$|taxa de transmissão da doença|
|$e^{-1}$|tempo de latência médio da doença|
|$g^{-1}$|período médio de recuperação|
|$a$|taxa de mortalidade da doença|

O problema pode ser descrito por

$$
\min_u \int_0^T AI(t) + u(t)^2 dt
$$
\begin{align}
\text{sujeito a  }&S'(t) = bN(t) - dS(t) - cS(t)I(t) - u(t)S(t), S(0) = S_0 \ge 0 \\
&E'(t) = cS(t)I(t) - (e + d)E(t), E(0) = E_0 \ge 0 \\
&I'(t) = eE(t) - (g + a + d)I(t), I(0) = I_0 \ge 0 \\
&R'(t) = gI(t) - dR(t) + u(t)S(t), R(0) = R_0 \ge 0 \\
&N'(t) = (b - d)N(t) - aI(t), N(0) = N_0 \\
&0 \le u(t) \le 0.9
\end{align}

## Condições Necessárias

O Hamiltoniano de sistemas desse tipo são muito grandes e, portanto, não precisam ser descrito, dado que a intuição seja clara. Vamos usar as variáveis $S, E, I, N$ e $R = N - S - E - I$. Se usássemos a variável $R$, a função adjunta seria negativa.

### Equação adjunta

Sejam $\lambda_1(t),\lambda_2(t),\lambda_3(t) \text{ e } \lambda_4(t)$ funções deriváveis por partes. Assim

$\lambda_1'(t) = - \frac{\partial H}{\partial S} = - \lambda_1(- d - cI - u) - \lambda_2(cI)$

$\lambda_2'(t) = - \frac{\partial H}{\partial E} = \lambda_2(e + d) - \lambda_3(e)$

$\lambda_3'(t) = - \frac{\partial H}{\partial I} = -A - \lambda_1(- cS) - \lambda_2(cS) + \lambda_3(g + a + d) + \lambda_4(a)$

$\lambda_4'(t) = - \frac{\partial H}{\partial N} = -\lambda_1(b) - \lambda_4(b - d)$

### Condição de transversalidade

$
\lambda_1(T) = \lambda_2(T) = \lambda_3(T) = \lambda_4(T) = 0
$

### Condição de otimalidade

$$
\frac{\partial H}{\partial u} = 2u - \lambda_1(S)
$$

$\frac{\partial H}{\partial u} > 0 \Rightarrow u^*(t) = 0 \Rightarrow 2u - \lambda_1(S) > 0 \Rightarrow S\lambda_1 < 0 \Rightarrow \frac{1}{2}S\lambda_1 < 0 $

$\frac{\partial H}{\partial u} < 0 \Rightarrow u^*(t) = 0.9 \Rightarrow 2u - \lambda_1(S) < 0 \Rightarrow 1.8 < S\lambda_1 \Rightarrow \frac{1}{2}S\lambda_1 > 0.9$

$\frac{\partial H}{\partial u} = 0 \Rightarrow 0 \leq u^*(t) \leq 0.9 \Rightarrow 2u - \lambda_1(S) = 0 \Rightarrow u^*(t) = \frac{1}{2}S\lambda_1 \Rightarrow 0 \leq \frac{1}{2}S\lambda_1 \leq 0.9$

Portanto $u^*(t) = \max\left\{0, \min\left\{\frac{1}{2}S\lambda_1,0.9\right\}\right\}$

### Importanto as bibliotecas

```python
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp
import sympy as sp

import sys
sys.path.insert(0, '../pyscripts/')

from optimal_control_class import OptimalControl
```

Aqui podemos descrever as funções necessárias para a classe. Estamos lidando com variáveis multidimensionais no tempo. Portanto, vamos utilizar `numpy.array` como forma de escrita. Nessa escrita, cada coluna $i$ é um vetor correspondente ao estado $i$. Nesse caso teremos $S \to 0, E \to 1, I \to 2, N \to 3$.

```python
parameters = {'a': None, 'b': None, 'c': None, 'd': None, 'e': None, 'g': None, 'A': None}

diff_state = lambda t, x, u, par: np.array([
    par['b']*x[3] - (par['d'] + par['c']*x[2] + u[0])*x[0],
    par['c']*x[0]*x[2] - (par['e'] + par['d'])*x[1],
    par['e']*x[1] - (par['g'] + par['a'] + par['d'])*x[2],
    (par['b'] - par['d'])*x[3] - par['a']*x[2]
])

diff_lambda = lambda t, x, u, lambda_, par: np.array([
    lambda_[0]*(par['d'] + par['c']*x[2] + u[0]) - lambda_[1]*(par['c']*x[2]),
    lambda_[1]*(par['e'] + par['d']) - lambda_[2]*par['e'],
    -par['A'] + lambda_[0]*par['c']*x[0] - lambda_[1]*par['c']*x[0] + \
               lambda_[2]*(par['g'] + par['a'] + par['d']) + lambda_[3]*par['a'],
    -lambda_[0]*par['b'] - lambda_[3]*(par['b'] - par['d'])
])

update_u = lambda t, x, lambda_, par: np.minimum(0.9, np.maximum(0.5*x[0]*lambda_[0], 0.0))
```

## Aplicando a classe ao exemplo

Vamos fazer algumas exeperimentações. Sinta-se livre para variar os parâmetros. Nesse caso passaremos os limites como parâmetros do método `solve`. Precisamos nos atentar a alguns parâmetros novos da classe:

- `n_controls`: ele indica o número de controles do problema. Ele é por padrão 1.
- `n_states`: ele indica o número de estados do problema. Ele é por padrão 1.
- `diff_phi`: já é conhecido. Observe a estrutura dele: um array com uma linha e 4 colunas, um para cada estado.

```python
problem = OptimalControl(diff_state, diff_lambda, update_u,
                         n_controls = 1, n_states = 4,
                         diff_phi = lambda x, par: np.zeros(shape = (1, 4)),
                         bounds = [(0,0.9)])
```

```python
x0 = np.array([1000, 100, 50, 1165]) # S0, E0, I0, N0
T = 20
parameters['A'] = 0.1
parameters['a'] = 0.2
parameters['b'] = 0.525
parameters['c'] = 0.0001
parameters['d'] = 0.5
parameters['e'] = 0.5
parameters['g'] = 0.1
```

Importante lembrar que esses gráficos são padrões e podem ser melhorados manualmente.

```python
t,x,u,lambda_ = problem.solve(x0, T, parameters)
ax = problem.plotting(t,x,u,lambda_)
ax[0].legend(['S','E','I','N'])
plt.show()
```

Observamos que $c$ é pequeno, o que causa uma baixa incidência da doença. A vacinação começa mais alta no início da epidemia, o que impede o crescimento da doença. Esse combo faz o gráfico dos infectados morrer logo. A vacinação termina com pouco mais de 7.5 anos. Os recuperados também tendem a 0, pois as mortes naturais acabam por se tornar mais altas do que as recuperações dos infectados e vacinações. Façamos $c = 0.01$, uma taxa relativamente mais alta, que torna o número de infectados presente além do tempo considerado e também mantém a vacinação no máximo quase até o final.

```python
parameters['c'] = 0.01
t,x,u,lambda_ = problem.solve(x0, T, parameters)
ax = problem.plotting(t,x,u,lambda_)
ax[0].legend(['S','E','I','N'])
plt.show()
```

## Experimentação

Existem várias variações que podem ser feitas, portanto sugerimos que experimente esse modelo com variações em cada parâmetro.

```python
#t,x,u,lambda_ = problem.solve(x0, T, parameters)
#ax = problem.plotting(t,x,u,lambda_)
#ax[0].legend(['S','E','I','R'])
#plt.show()
```

### Este é o final do notebook


## Univariate Inequality Plots w/ matplotlib

```python
#!pip install matplotlib
```

```python
import matplotlib.pyplot as plt
from mpl_toolkits.axisartist import Subplot

def plot_univariate_inequality(
    xdata,
    inclusive=False,
    raycolor='blue',
    bgcolor='white',
    xlim=None,
    figsize=(5, 0.5),
    figure=None,
    title=None,
    **kwargs):
    """Plot a chart of a univariate inequality ray with matplotlib

    Args:
        xdata (list, tuple): endpoints of the ray: ``[start, ..., end]``

    Kwargs:
        inclusive (bool): if True, draw a filled circle; if False, draw a circle without fill
        raycolor (str): matplotlib color of the line and markers
        bgcolor (str): matplotlib color for the background and circle without fill
        xlim (None, True, or list/tuple): if True, calculate defaults for xmin and xmax of the x-axis
        figsize (tuple): matplotlib figsize
        figure (None or matplotlib.figure.Figure): Figure to add a plot to
        title (None or str): Title to add the the plot
        kwargs: all other kwargs will be passed to `plt.figure()`

    Returns:
        matplotlib.figure.Figure: inequality figure
    """

    _xdata = sorted(xdata)
    xstart = _xdata[0]
    xend = _xdata[-1]
    lefttoright = xstart <= xend
    lefttoright2 = xdata[0] <= xdata[-1]

    fig1 = figure or plt.figure(figsize=figsize, facecolor=bgcolor, **kwargs)
    ax1 = Subplot(fig1, 111)
    fig1.add_subplot(ax1)

    style = {}
    style['linewidth'] = 4
    style['linestyle'] = 'solid'
    style['color'] = raycolor
    style['marker'] = 'o'
    style['markersize'] = 12
    style['markevery'] = [0] if lefttoright else [-1]
    style['markerfacecolor'] = raycolor if inclusive else bgcolor
    style['markeredgecolor'] = raycolor
    style['markeredgewidth'] = 4

    arrowstyle = {}
    arrowstyle['marker'] = '>' if lefttoright2 else '<'
    arrowstyle['markersize'] = 12
    arrowstyle['markerfacecolor'] = raycolor
    arrowstyle['markeredgecolor'] = raycolor
    arrowy = xdata[-1] if lefttoright else xdata[0]

    ax1.set(ylim=(-0.5, 1))

    if xlim is True:
        xlim = (float(xstart - 1), float(xend + 0.5))
    if xlim:
        ax1.set(xlim=xlim)

    ax1.axis["left"].set_visible(False)
    ax1.axis["right"].set_visible(False)
    ax1.axis["top"].set_visible(False)

    #ax1.axis["bottom"].set_axisline_style(axisline_style="->", size=2)
    ax1.plot(1, -0.5, ">k", transform=ax1.get_yaxis_transform(), clip_on=False)
    ax1.plot(0, -0.5, "<k", transform=ax1.get_yaxis_transform(), clip_on=False)

    _x = [xval for xval in xdata]
    _y = [0 for xval in xdata]
    plt.plot(_x, _y, **style)
    if title:
        ax1.set_title(title)
    plt.plot(arrowy, 0, **arrowstyle)
    return fig1

x = [-1, 3]
output = plot_univariate_inequality(x)
```

```python
x = [-1, 3]
output = plot_univariate_inequality(x, xlim=True, title="x > -1")
```

```python
x = [3, -1]
output = plot_univariate_inequality(x, xlim=True, title="x < -3")
```

```python
x = [-2, 5]
output = plot_univariate_inequality(x, xlim=True)
```

```python
x = [-1, 3]
output = plot_univariate_inequality(x, inclusive=True, xlim=True, title="x >= -1")
```

## Sympy inequalities
- https://docs.sympy.org/latest/modules/core.html#greaterthan

```python
#!pip install sympy
```

```python
import sympy
from sympy import symbols, Eq, solve
x = symbols('x', real=True)
expr = sympy.solve(x >= -3)
expr
```

$\displaystyle -3 \leq x$

```python
display(expr)
display(sympy.latex(expr))
print('## pprint')
sympy.pprint(expr)
sympy.pprint(expr, use_unicode=True)
sympy.pprint(expr, use_unicode=False)
print('## str, repr')
display(str(expr))
display(repr(expr))
```

$\displaystyle -3 \leq x$

    '-3 \\leq x'

    ## pprint
    -3 ≤ x
    -3 ≤ x
    -3 <= x
    ## str, repr

    '-3 <= x'

    '-3 <= x'

```python
display((expr.lhs, expr.rhs))  # left-hand side, # right-hand side
display((expr.lts, expr.gts))  # less-than side, # greater-than side
# Sympy rewrites inequalities so that the left-hand side is the less-than-side:
assert expr.lhs == expr.lts
assert expr.rhs == expr.gts
```

    (-3, x)

    (-3, x)

```python
expr = sympy.solve(x <= 10)
expr2 = sympy.LessThan(x, 10)
expr3 = (x <= 10)
expr4 = (10 >= x)
display(expr, expr2, expr3, expr4)
assert expr == expr2 == expr3 == expr4
# Sympy rewrites inequalities so that the left-hand side is the less-than-side:
assert (expr.lhs, expr.rhs) == (expr.lts, expr.gts)
assert (expr4.lhs, expr4.rhs) == (expr4.lts, expr4.gts)
display((expr2.lts, expr2.gts))
display((expr3.lts, expr3.gts))
display((expr4.lts, expr4.gts))
```

$\displaystyle x \leq 10$

$\displaystyle x \leq 10$

$\displaystyle x \leq 10$

$\displaystyle x \leq 10$

    (x, 10)

    (x, 10)

    (x, 10)

```python
expr.rel_op
```

    '<='

```python
REL_OP_MAP = {
    '<=': dict(offset=-1, inclusive=True),
    '<': dict(offset=-1, inclusive=False),
    '>=': dict(offset=1, inclusive=True),
    '>': dict(offset=1, inclusive=False),
}

def get_points_from_ineq_expr(expr, offsetmultiplier=5):
    xstart = expr.gts if expr.rel_op in {'<=', '<'} else expr.lts
    kwargs = REL_OP_MAP.get(expr.rel_op).copy()
    xend = xstart + (kwargs.pop('offset') * offsetmultiplier)
    x = [xstart, xend]
    return x, kwargs

x = symbols('x', real=True)

xdata, kwargs = get_points_from_ineq_expr(x <= 3)
assert xdata == [3, -2]
assert kwargs == dict(inclusive=True)

xdata, kwargs = get_points_from_ineq_expr(x <= 5)
assert xdata == [5, 0]
assert kwargs == dict(inclusive=True)

xdata, kwargs = get_points_from_ineq_expr(x > 4)
assert xdata == [4, 9]
assert kwargs == dict(inclusive=False)
```

```python
def plot_ineq_expr(expr, **kwargs):
    xdata, kwargs = get_points_from_ineq_expr(expr)
    kwargs.setdefault('xlim', True)
    kwargs.setdefault('title', f'${sympy.latex(expr)}$')
    return plot_univariate_inequality(xdata, **kwargs)

x = symbols('x', real=True)
output = plot_ineq_expr(x >= 3)
output = plot_ineq_expr(10 > x)
output = plot_ineq_expr(100 >= x)
```

```python

```


# Solving the Traveling Salesperson Problem with Azure Quantum QIO
Hello and welcome! In this notebook we will walk you through how you (can) solve the traveling salesperson problem (also known as the traveling salesman problem) with the Azure Quantum quantum inspired optimization (QIO) service.

## Introduction
The traveling salesperson problem is a well-known optimization problem in which the aim is to find a (near) optimal route through a network of nodes. As you will see later on, it is not a straightforward problem to solve as the complexity (difficulty) grows exponentially with the number of nodes. Additionally, due to the rugged (non-convex) optimization space, it is difficult or even impossible to find an optimal route (global minimum/optimal solution) through a large network. Common solvers for these rugged problems are based on searches, which you will implement in this tutorial with Azure QIO!

Imagine you have to calculate an optimal route for the salesperson over $N$ nodes (addresses). Your manager wants you to minimize the traveling cost (time/distance/money/etc.) such that the salesperson is more profitable. There are a number of constraints which have to be satisfied by the salesperson:

1. The salesperson is required to visit each node.
2. The salesperson may visit each node only once!
3. The salesperson starts and finishes in the starting node (headquarters).

For more information regarding the traveling salesperson problem, check out these links:

- [Introduction to binary optimization - Azure Quantum docs](https://docs.microsoft.com/en-us/azure/quantum/optimization-binary-optimization)
- [Traveling salesperson problem - Wikipedia](https://en.wikipedia.org/wiki/Travelling_salesperson_problem)

> Please note this sample is intended to demonstrate how to formulate the cost function for a well-understood problem mathematically and then map it to a binary QUBO/PUBO format. The Traveling Salesperson Problem is not a good example of a problem that scales well in this format, as detailed in [this paper](https://arxiv.org/abs/1702.06248).

## Setup

First, you need to import some dependencies and connect to your Azure Quantum Workspace.

> If you do not have an Azure Quantum workspace, or an Azure subscription, please take a look at the [Get started with Azure Quantum](https://docs.microsoft.com/learn/modules/get-started-azure-quantum/) Learn module, or alternatively the [Azure Quantum docs site](https://docs.microsoft.com/azure/quantum/how-to-create-quantum-workspaces-with-the-azure-portal).

```python
# Import dependencies
import numpy as np
import os
import time
import math
import requests
import json
import datetime

from azure.quantum.optimization import Problem, ProblemType, Term, HardwarePlatform, Solver
from azure.quantum.optimization import SimulatedAnnealing, ParallelTempering, HardwarePlatform, Tabu, QuantumMonteCarlo
from typing import List
```

```python
from azure.quantum import Workspace

# Connect to your Azure Quantum Workspace
workspace = Workspace (
    resource_id = "", # add the Resource ID of your Azure Quantum workspace
    location = ""     # add the location of your Azure Quantum workspace (e.g. "westus")
)
```

## Defining a cost function: Minimizing the travel cost
The generated route should minimize the travel cost for the salesperson. It is time to define this mathematically. You will first need to define the travel costs between the nodes and have a suitable mapping for the location of the salesperson.

### 1. Defining the travel cost matrix

Consider a single trip for the salesperson, from one node to another node. They start at node $i$ and travel to node $j$, which requires $c(i,j)$ in travel costs. Here, $i$ denotes the origin node and $j$ denotes the destination node. To keep matters simple for now, both $i$ and $j$ can be any node in the set of $N$ nodes.

$$ \text{The origin node } (\text{node } i) \text{ with } i \in \{0,N-1\}.$$
$$ \text{The destination node } (\text{node } j) \text{ with } j \in \{0,N-1\}.$$
$$ \text{Time traveling from } \text{node } i \text{ to } \text{node } j \text{ is } c_{i,j}.$$

As you may have already noticed, the travel cost between two nodes can be written out for every $i$ and $j$, which results in a travel cost matrix $C$ (linear algebra), shown below:

$$C = \begin{bmatrix} c_{0,0} & c_{0,1} & \dots & c_{0,N-1} \\ c_{1,0} & c_{1,1} & \dots & c_{1,N-1} \\ \vdots & \ddots & \ddots & \vdots \\ c_{N-1,0} & c_{N-1,1} & \dots & c_{N-1,N-1} \end{bmatrix}. $$

Here, we define the rows to be origin nodes and the columns to be destination nodes.
For example, traveling from node 0 to node 1 is simply described by:

$$ C(0,1) = c_{0,1}. $$

The unit for the travel cost is arbitrary: it can be time, distance, money, or a combination of these and/or other factors.

### 2. Defining the location vectors

Now that the travel cost between two nodes has been formulated, a representation of the origin and destination nodes of the salesperson must be defined to specify which element of the matrix gives the associated travel cost. Remember that this is still for a single trip!

This is the same as saying "we need a way to select a row-column pair in the cost matrix". This can be done by multiplying the cost matrix with a vector from the left, and a vector from the right! The left vector will specify the origin node and the right vector the destination node. For brevity they'll be named the origin vector (left) and the destination vector (right).

Consider the example where you tell the salesperson to travel from node 1 to node 2:

$$ \text{Travel cost node 0 to node 1 }=  \begin{bmatrix} 1 & 0 & \dots & 0 \end{bmatrix} \begin{bmatrix} c_{0,0} & c_{0,1} & \dots & c_{0,N-1} \\ c_{1,0} & c_{1,1} & \dots & c_{1,N-1} \\ \vdots & \ddots & \ddots & \vdots \\ c_{N-1,0} & c_{N-1,1} & \dots & c_{N-1,N-1} \end{bmatrix} \begin{bmatrix} 0 \\ 1 \\ \vdots \\ 0 \end{bmatrix} = c_{0,1}.$$

> Please note that the salesperson can only visit one node at a time (no magic allowed!) and that there is only one salesperson, thus the sum elements in the origin and destination vector must equal 1!

Fantastic, you now know how to the express the travel cost for a single trip. However, it is necessary to express these ideas in mathematical format that the solver understands. In the previous example the trip was hard-coded from node 1 to node 2, let's generalize this from any origin node to any destination node:

$$ x_k \in \{0,1\} \text{ for } k \in \{0,2N-1\}, $$

$$ \text{Travel cost for single trip }=  \begin{bmatrix} x_0 & x_1 & \dots & x_{N-1} \end{bmatrix} \begin{bmatrix} c_{0,0} & c_{0,1} & \dots & c_{0,N-1} \\ c_{1,0} & c_{1,1} & \dots & c_{1,N-1} \\ \vdots & \ddots & \ddots & \vdots \\ c_{N-1,0} & c_{N-1,1} & \dots & c_{N-1,N-1} \end{bmatrix} \begin{bmatrix} x_{N} \\ x_{N+1}\\ \vdots \\ x_{2N-1} \end{bmatrix}. $$

The solver will determine which $x_k$ are given a value of 1 or 0 (this is the binary variable you are optimizing). If the value is 1, it means the salesperson is travelling between the corresponding origin and destination nodes. Correspondingly, if the value is 0 it means the salesperson is not originating from or traveling to that location.

> You may be wondering why the destination vector is indexed from $N+1$ to $2N$ (seperate variables). The reason for this is that otherwise, the solver would consider the left vector and the right vector equal. As as consequence, there would be an origin vector on both sides of the cost matrix, meaning the salesperson would remain at the origin node, which isn't allowed (it would count as visiting the same node more than once, which isn't allowed according to the constraints set out at the start).

To summarize: for the salesperson to be at one node at a time, the sum of the vector elements for both the origin and destination vectors has to be 1.

$$ \text{Sum of the origin vector elements: }\sum_{k = 0}^{N-1} x_k = 1, $$
$$ \text{Sum of the destination vector elements: }\sum_{k = N}^{2N-1} x_k = 1.$$

Later in this notebook, constraints will be designed to disable magic/superposition for the salesperson.

Now that the basic mathematical formulations are covered, the scope can be expanded. Let's take a look at how two trips can be modeled!

### 3. Defining the travel costs for a route

To derive the cost function for a route through a network, you'll need a way to describe the 'total travel cost'. As you might expect, the total cost of a route through the network is the sum of the travel costs between the nodes (sum of the trips). Say you have a route ($R$) from node 1 to node 3 to node 2. The total cost of the route would then be:

$$ \text{Cost of route: } R_{1-3-2} = c_{1,3} + c_{3,2}. $$

Note that for the second trip, the origin node is the same as the destination node of the previous trip. Knowing that the last destination equals the new origin is a useful property when reducing the number of variables the solver has to optimize for. Therefore these vectors can also be called the 'location' vectors.

Recall that the costs can be expressed with linear algebra. Then the total cost of two trips is:

$$ \text{Cost of route } = \begin{bmatrix} x_0 & x_1 & \dots & x_{N-1} \end{bmatrix} \begin{bmatrix} c_{0,0} & c_{0,1} & \dots & c_{0,N-1} \\ c_{1,0} & c_{1,1} & \dots & c_{1,N-1} \\ \vdots & \ddots & \ddots & \vdots \\ c_{N-1,0} & c_{N-1,1} & \dots & c_{N-1,N-1} \end{bmatrix} \begin{bmatrix} x_{N} \\ x_{N+1}\\ \vdots \\ x_{2N-1} \end{bmatrix} + \begin{bmatrix} x_{N} & x_{N+1} & \dots & x_{2N-1} \end{bmatrix} \begin{bmatrix} c_{0,0} & c_{0,1} & \dots & c_{0,N-1} \\ c_{1,0} & c_{1,1} & \dots & c_{1,N-1} \\ \vdots & \ddots & \ddots & \vdots \\ c_{N-1,0} & c_{N-1,1} & \dots & c_{N-1,N-1} \end{bmatrix} \begin{bmatrix} x_{2N} \\ x_{2N+1}\\ \vdots \\ x_{3N-1} \end{bmatrix}.$$

Generalizing the small example to a route in which the salesperson visits all $N$ nodes and returns back to the starting location gives

$$\text{Travel cost of route } = \sum_{k=0}^{N-1} \left(  \begin{bmatrix} x_{Nk} & x_{Nk+1} & \dots & x_{Nk+N-1} \end{bmatrix} \begin{bmatrix} c_{0,0} & c_{0,1} & \dots & c_{0,N-1} \\ c_{1,0} & c_{1,1} & \dots & c_{1,N-1} \\ \vdots & \ddots & \ddots & \vdots \\ c_{N-1,0} & c_{N-1,1} & \dots & c_{N-1,N-1} \end{bmatrix} \begin{bmatrix} x_{N(k+1)} \\ x_{N(k+1)+1}\\ \vdots \\ x_{N(k+1)+N-1} \end{bmatrix} \right),$$

which can equivalently be written as:

$$\text{Travel cost of route} = \sum_{k=0}^{N-1}\sum_{i=0}^{N-1}\sum_{j=0}^{N-1} \left( x_{Nk+i}\cdot x_{N(k+1)+j}\cdot c_{i,j} \right).$$

Fantastic! A cost function to optimize for the salesperson's route has been found! Because you want to minimize (denoted by the 'min') the total travel cost with respect to the variables $x_k$ (given below the 'min'), and make mathematicians happy, you'll want to slightly adjust the model:

$$\text{Travel cost of route} := \underset{x_0, x_1,\dots,x_{(N^2+2N)}}{min}\sum_{k=0}^{N-1}\sum_{i=0}^{N-1}\sum_{j=0}^{N-1} \left( x_{Nk+i}\cdot x_{N(k+1)+j}\cdot c_{i,j} \right).$$

Time to write this out in code.

### 4. Coding the cost function

For the solver to find a suitable route, you'll need to specify how it calculates the travel cost for that route. The solver requires you to define a cost term for each possible trip-origin-destination combination given by the variables $k,i,j$, respectively. As described by the cost function, this weighting term is simply the $c(i,j)$ element of the cost matrix. The solver will optimize for the $x$ variables of the location vectors.

```python
### Define variables

# The number of nodes
NumNodes = 5

# Max cost between nodes
maxCost = 10

# Node names, to interpret the solution later on
NodeName = {0:'A', 1:'B', 2:'C', 3:'D', 4:'E', 5:'F', 6:'G', 7:'H', 8:'I', 9:'J', 10:'K',
            11:'L', 12:'M', 13:'N', 14:'O', 15:'P', 16:'Q', 17:'R', 18:'S', 19:'T',
            20:'U', 21:'V', 22:'W', 23:'X', 24:'Y', 25:'Z'}

# Cost to travel between nodes -- note this matrix is not symmetric (traveling A->B is not same as B->A!)
CostMatrix = np.array([[1, 4, 7, 4, 3], [3, 3, 3, 1, 2], [2, 5, 2, 3, 1], [7, 8, 1, 3, 5], [3, 2, 1, 9, 8]])    # If you want to rerun with the same matrix
#CostMatrix = np.random.randint(maxCost, size=(NumNodes,NumNodes))                                          # If you want to run with a new cost matrix

############################################################################################
##### Define the optimization problem for the QIO Solver
def OptProblem(CostMatrix) -> Problem:

    # 'terms' will contain the cost function terms for the trips
    terms = []

    ############################################################################################
    ##### Cost of traveling between nodes
    for k in range(0, len(CostMatrix)):  # For each trip
        for i in range(0, len(CostMatrix)): # For each origin node
            for j in range(0, len(CostMatrix)): # For each destination node

                # Assign a weight to every possible trip from node i to node j for each trip
                terms.append(
                    Term(
                        c = CostMatrix.item((i,j)) ,
                        # Plus one to denote dependence on next location
                        indices = [i + (len(CostMatrix) * k), j + (len(CostMatrix) * (k + 1))]
                    )
                )
                ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
                #print(f'{i + (len(CostMatrix) * k)}, {j + (len(CostMatrix) * (k + 1))}')                                                                 # Combinations of origin and destination nodes
                #print(f'For x_{i + (len(CostMatrix) * k)}, to x_{j + (len(CostMatrix) * (k + 1))} in trip number {k} costs: {CostMatrix.item((i,j))}')   # In a format for the solver (as formulated in the cost function)
                #print(f'For node {i}, to node {j} in trip number {k} costs: {CostMatrix.item((i,j))}')                                         # In a format that is easier to read for a human

    return Problem(name="Traveling Salesperson", problem_type=ProblemType.pubo, terms=terms)

OptimizationProblem = OptProblem(CostMatrix)
```

## Defining optimization constraints: Penalizing invalid routes
The modeled cost function will allow you to find the cheapest route for the salesperson, however it does not include any information on invalid routes! It is now time to integrate constraints/penalties for routes which the salesperson should not travel.

### Constraint 1: The salesperson may not be at more than one node at a time (no magic)

The salesperson can only be at one node at a time. In the defined cost function this constraint is not enforced, and thus the solver can return origin and location vectors for which the sum is larger than one. Such vectors represent invalid solutions.

To avoid these solutions, the invalid solution has to be penalized. This is done by modifying the cost function. You can imagine the penalization of the cost function as reshaping/redesigning the rugged optimization landscape such that for invalid solutions the solver cannot find (local) minima. Invalid solutions should have such high cost that the solver is very unlikely to select them, but not so high as to block the solver from moving around that area of the cost function to find other, valid solutions.

To ensure that the salesperson is only ever at a single location, before or after a trip, we must require that only one element in the origin or destination vector is equal to 1, with the rest being 0. One way of designing the constraint would be to look at the sum of elements of each location vector (the origin and destination vectors):

$$
\begin{align}
    \text{Location vector 0 (HQ)} &: \text{ } \hspace{0.5cm}  x_0 + x_1 + \dots + x_{N-1} = 1, \\
    \text{Location vector 1} &: \text{ } \hspace{0.5cm} x_{N} + x_{N+1} + \dots + x_{2N-1} = 1, \\
    &\vdots \\
    \text{Location vector N (HQ)} &: \text{ } \hspace{0.5cm} x_{N^2} + x_{N^2+1} + \dots + x_{N^2 + N-1} = 1.
\end{align}
$$

Enforcing the constraint over all trips would then yield ($N+1$ because the salesperson returns to starting node):

$$ \text{For all locations: } \hspace{0.5cm}  x_0 + x_1 + \dots +  x_{N^2 + N-1} = N+1. $$

The equation above is a valid way to model the constraint. However, there is a downside to it as well: in this formulation individual locations are penalized but there is no penalty for being in two locations at once! To see this, take the following example:

$$
\text{ If the first } N + 1 \text{ values are all 1:}\\
x_0=1, x_{1}=1, \dots, x_{N}=1, \\
\text{ } \\
\text{ and all other } x \text{ values are 0:}\\
x_{N+1}=0, x_{N+2}=0, \dots, x_{N^2+N-1}=0,
$$

the constraint is satisfied but the salesperson is still at all nodes at once. Thus the derived equation is not specific enough to model the location constraint. Let's rethink.

Consider three nodes (a length-3 location vector), then if the salesperson is at node 1, they cannot be at node 0 or node 2. If the salesperson is at node 0, they cannot be at node 1 or node 2. Instead of using a sum to express this, an equally valid way would be to use products. The product of elements in a location vector must always be zero, regardless of where the salesperson resides, because only one of the three $x$ values can take value 1 (denoting the salesperson being at that location). Therefore the constraint can also be expressed as:
$$ x_0 \cdot x_1 = 0,$$
$$ x_0 \cdot x_2 = 0,$$
$$ x_1 \cdot x_2 = 0.$$

In this format the constraint is much more specific and stricter for the solver. As a result, the solver will provide solutions that do not violate this constraint. Note that we do not want to count combinations more than once, as this would lead to (assymmetries) inbalances in the cost function. We therefore exclude the reverse combinations:

$$ x_{1}\cdot x_{0}, $$
$$ x_{2}\cdot x_{0}, $$
$$ x_{2}\cdot x_{1}. $$

Generalizing the location constraint for a salesperson who passes through all nodes and returns back to the starting node ($N+1$ nodes in total, iterating over $l$):

$$ \sum_{l=0}^{N} \sum_{i=0}^{N-1} \sum_{j=0}^{N-1} x_{(i+Nl)} \cdot x_{(j+Nl)} = 0 \text{ with } \{ i,j | i<j \}  $$

Great! Now we have an accurate description of this constraint, lets add it to the code.

```python
### Define variables

# The number of nodes
NumNodes = 5

# Max cost between nodes
maxCost = 10

# Node names, to interpret the solution later on
NodeName = {0:'A', 1:'B', 2:'C', 3:'D', 4:'E', 5:'F', 6:'G', 7:'H', 8:'I', 9:'J', 10:'K',
            11:'L', 12:'M', 13:'N', 14:'O', 15:'P', 16:'Q', 17:'R', 18:'S', 19:'T',
            20:'U', 21:'V', 22:'W', 23:'X', 24:'Y', 25:'Z'}

# Cost to travel between nodes -- note this matrix is not symmetric (traveling A->B is not same as B->A!)
CostMatrix = np.array([[1, 4, 7, 4, 3], [3, 3, 3, 1, 2], [2, 5, 2, 3, 1], [7, 8, 1, 3, 5], [3, 2, 1, 9, 8]])    # If you want to rerun with the same matrix
#CostMatrix = np.random.randint(maxCost, size=(NumNodes,NumNodes))                                          # If you want to run with a new cost matrix

############################################################################################
##### Define the optimization problem for the Azure Quantum Solver
def OptProblem(CostMatrix) -> Problem:

    # 'terms' will contain the cost function terms for the trips
    terms = []

    ############################################################################################
    ##### Cost of traveling between nodes
    for k in range(0, len(CostMatrix)):                          # For each trip
        for i in range(0, len(CostMatrix)):                      # For each origin node
            for j in range(0, len(CostMatrix)):                  # For each destination node

                #Assign a weight to every possible trip from node i to node j for each trip
                terms.append(
                    Term(
                        c = CostMatrix.item((i,j)),                                     # Element of the cost matrix
                        indices = [i + (len(CostMatrix) * k), j + (len(CostMatrix) * (k + 1))]    # +1 to denote dependence on next location
                    )
                )
                ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
                #print(f'{i + (len(CostMatrix) * k)}, {j + (len(CostMatrix) * (k + 1))}')                                                                   # Combinations between the origin and destination nodes
                #print(f'For x_{i + (len(CostMatrix) * k)}, to x_{j + (len(CostMatrix) * (k + 1))} in trip number {k} costs: {CostMatrix.item((i,j))}')     # In a format for the solver (as formulated in the cost function)
                #print(f'For node_{i}, to node_{j} in trip number {k} costs: {CostMatrix.item((i,j))}')                                                     # In a format that is easier to read for a human

    ############################################################################################
    ##### Constraint: Location constraint - salesperson can only be at 1 node at a time.
    for l in range(0,len(CostMatrix)+1):                # The total number of nodes that are visited over the route (+1 because returning to starting node)
        for i in range(0,len(CostMatrix)):              # For each origin node
            for j in range(0,len(CostMatrix)):          # For each destination node
                if i!=j and i<j:                        # i<j because we don't want to penalize twice // i==j is forbidden (above)
                    terms.append(
                        Term(
                            c = int(2 * np.max(CostMatrix)),                                     # Assign a weight penalty dependent on maximum distance from the cost matrix elements
                            indices = [i + (len(CostMatrix) * l), j + (len(CostMatrix) * l)]
                        )
                    )
                    ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
                    #print(f'{i + (len(CostMatrix) * k)}, {j + (len(CostMatrix) * (k))}')
                    #print(f'Location constraint 1: x_{i + (len(CostMatrix) * l)} - x_{j + (len(CostMatrix) * (l + 1))} (trip {l}) assigned weight: {int(2 * np.max(CostMatrix))}')  # In a format for the solver (as formulated in the cost function)

    return Problem(name="Traveling Salesperson", problem_type=ProblemType.pubo, terms=terms)

OptimizationProblem = OptProblem(CostMatrix)
```

### Constraint 2: The salesperson must be somewhere, they can't disappear

Due to the first constraint, the salesperson is penalized for being in multiple nodes at once. But due do the formulation of this constraint it is possible that the solver puts all all $x_k$ in a location vector equal to zero, meaning that the salesperson could be 'nowhere' after some trip.

> By looking at the code and previous sections you can come to the conclusion that the minimal cost is obtained by setting all $x_k$ to 0.

To encourage the salesperson to not disappear, it is necessary to reward them for being somewhere. Rewards can be assigned by incorporating negatively weighted terms to the cost function that decrease the cost function value for valid solutions of the optimization problem (remember we are minimizing the cost here, so reducing cost makes it more likely that a solution will be chosen). To demonstrate this point, take the following optimization problem:

$$\text{f(x)} := \underset{x_0, x_1, x_2}{min} x_0 + x_1 - x_2.$$
$$ \text{with } x_0, x_1, x_2 \in \{0,1\}$$

The minimum value for the example is attained for the solution $x_0$ = 0, $x_1$ = 0, $x_2$ = 1, with the optimal function value equal to -1. Here, the negatively weighted third term encourages $x_2$ to take a value 1 rather than 0, unlike $x_0$ and $x_1$. With this idea in mind, you can stop the salesperson from dissapearing! If the salesperson has to visit $N+1$ nodes over a route then $N+1$ of the $x_k$ variables must be assigned the value 1. Written as an equation:

$$ \sum_{k=0}^{N(N+1)-1} x_k = N+1,$$

with $(N(N+1))$ equal to the number of variables used to represent the origin and destination nodes for each trip. You could split the equation for each location vector seperately, but if you keep the constraint linear in $x_k$ the resulting cost function will be the same. To assign a reward to the salesperson for being at a node, the $x_k$ terms are moved to the right side of the equation:

$$ 0 = (N+1) -\left( \sum_{k=0}^{N(N+1)-1} x_k \right).$$

As you may be aware, there is no guarantee which particular $x_k$ the solver will assign the value 1 to in this equation. However, in the previous constraint the salesperson is already enforced to be in a maximum of one node before/after each trip. Therefore with the cost function weights properly tuned, it can be assumed that the salesperson will not be in two or more nodes at once.

In other words, the previous constraint penalizes the salesperson for being at more than one node at a once, while this constraint rewards them for being at as many nodes before/after any trip (also being in multiple nodes at once). The weights of the constraints will effectively determine how well they are satisfied, a balance between the two needs to be found such that both are adhered to.

Incorporating this constraint in the code can be done by assigning a negative term to each $x_k$. The $N+1$ value can be ignored, since it results in a linear shift of the optimization landscape and does not have an effect on solutions of the the minimization problem.

```python
### Define variables

# The number of nodes
NumNodes = 5

# Max cost between nodes
maxCost = 10

# Node names, to interpret the solution later on
NodeName = {0:'A', 1:'B', 2:'C', 3:'D', 4:'E', 5:'F', 6:'G', 7:'H', 8:'I', 9:'J', 10:'K',
            11:'L', 12:'M', 13:'N', 14:'O', 15:'P', 16:'Q', 17:'R', 18:'S', 19:'T',
            20:'U', 21:'V', 22:'W', 23:'X', 24:'Y', 25:'Z'}

# Cost to travel between nodes -- note this matrix is not symmetric (traveling A->B is not same as B->A!)
CostMatrix = np.array([[1, 4, 7, 4, 3], [3, 3, 3, 1, 2], [2, 5, 2, 3, 1], [7, 8, 1, 3, 5], [3, 2, 1, 9, 8]])    # If you want to rerun with the same matrix
#CostMatrix = np.random.randint(maxCost, size=(NumNodes,NumNodes))                                          # If you want to run with a new cost matrix

############################################################################################
##### Define the optimization problem for the Quantum Inspired Solver
def OptProblem(CostMatrix) -> Problem:

    #'terms' will contain the weighting terms for the trips!
    terms = []

    ############################################################################################
    ##### Cost of traveling between nodes
    for k in range(0, len(CostMatrix)):                          # For each trip
        for i in range(0, len(CostMatrix)):                      # For each origin node
            for j in range(0, len(CostMatrix)):                  # For each destination node

                #Assign a weight to every possible trip from node i to node j for each trip
                terms.append(
                    Term(
                        c = CostMatrix.item((i,j)),                                     # Element of the cost matrix
                        indices = [i + (len(CostMatrix) *k ), j + (len(CostMatrix) * (k + 1))]    # +1 to denote dependence on next location
                    )
                )
                ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
                #print(f'{i + (len(CostMatrix) * k)}, {j + (len(CostMatrix) * (k + 1))}')                                                                   # Combinations between the origin and destination nodes
                #print(f'For x_{i + (len(CostMatrix) * k)}, to x_{j + (len(CostMatrix) * (k + 1))} in trip number {k} costs: {CostMatrix.item((i,j))}')     # In a format for the solver (as formulated in the cost function)
                #print(f'For node_{i}, to node_{j} in trip number {k} costs: {CostMatrix.item((i,j))}')                                                     # In a format that is easier to read for a human

    ############################################################################################
    ##### Constraint: Location constraint - salesperson can only be at 1 node at a time.
    for l in range(0,len(CostMatrix)+1):                # The total number of nodes that are visited over the route (+1 because returning to starting node)
        for i in range(0,len(CostMatrix)):              # For each origin node
            for j in range(0,len(CostMatrix)):          # For each destination node
                if i!=j and i<j:                        # i<j because we don't want to penalize twice // i==j is forbidden (above)
                    terms.append(
                        Term(
                            c = int(2 * np.max(CostMatrix)),                                     # Assign a weight penalty dependent on maximum distance from the cost matrix elements
                            indices = [i + (len(CostMatrix) * l), j + (len(CostMatrix) * l)]
                        )
                    )
                    ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
                    #print(f'{i + (len(CostMatrix) * k)}, {j + (len(CostMatrix) * (k))}')
                    #print(f'Location constraint 1: x_{i + (len(CostMatrix) * l)} - x_{j + (len(CostMatrix) * (l + 1))} (trip {l}) assigned weight: {int(2 * np.max(CostMatrix))}')  # In a format for the solver (as formulated in the cost function)

    ############################################################################################
    ##### Constraint: Location constraint - encourage the salesperson to be 'somewhere' otherwise all x_k might be 0 (for example).
    for v in range(0, len(CostMatrix) + len(CostMatrix) * (len(CostMatrix))):    # Select variable (v represents a node before/after any trip)
        terms.append(
            Term(
                c = int(-1.65 * np.max(CostMatrix)),          # Assign a weight penalty dependent on maximum distance from the cost matrix elements
                indices = [v]
            )
        )
        ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
        #print(v)
        #print(f'Location constraint 2: x_{v} assigned weight: {int(-1.65 * np.max(CostMatrix))}')                                                      # In a format for the solver (as formulated in the cost function)
        #print(f'Location constraint 2: node_{v % NumNodes} after {np.floor(v / NumNodes)} trips assigned weight: {int(-1.65 * np.max(CostMatrix))}')   # In a format that is easier to read for a human

    return Problem(name="Traveling Salesperson", problem_type=ProblemType.pubo, terms=terms)

OptimizationProblem = OptProblem(CostMatrix)
```

### Constraint 3: Same node constraint - can't travel to the same node more than once (except the starting node)

The salesperson may only visit each node once, meaning that routes containing revisits of a node (other than the starting node) must be penalized. As an example, consider node 3 for each trip (these $x$ describe the same node):

$$ \text{Node 3:   } x_3, x_{3+N}, x_{3+2N}, \dots $$

If the salesperson has been in node 3 after any trip, they may not pass through the same node again. This means that if $x_3$ is 1 for example, $x_{3+N}$ and $x_{3+2N}$ have to be 0. As done similarly with the location constraint, the product of these variables is one way of designing a constraint. As you know that the product between variables representing the same node has to be zero, the following can be derived:

$$ x_3 \cdot x_{3+N} \cdot x_{3+2N} = 0. $$

Even though the equation seems to represent the constraint correctly, it is not stringent enough. With such an equation, if $x_3$ and $x_{3+N}$ are both 1 and $x_{3+2N}$ is 0, the constraint is satisfied with the salesperson having been in the same node twice. Therefore, similarly to the location constraint, more specificity is required. Luckily, the constraint can be split into smaller products:

$$ x_3 \cdot x_{3+N} =0$$
$$ x_3 \cdot x_{3+2N} = 0$$
$$ x_{3+N} \cdot x_{3+2N} = 0$$

Continuing this for all N trips and N nodes yields:

$$ \large{\sum}_{p=0}^{N^2+N-1}\hspace{0.25cm} \large{\sum}_{f=p+N,\hspace{0.2cm} \text{stepsize: } N}^{N^2-1} \hspace{0.35cm} (x_p \cdot x_f) = 0,$$

in which the first summation assigns a reference node $x_p$, and the second summation the same node but after some number of trips $x_f$ (multiple of N: stepsize).

This constraint penalizes routes in which nodes are visited more than once. It does not include the last trip back to the starting node (headquarters). The last trip does not need to penalized since the salesperson has already visited each node. Therefore, all remaning travels would result in a violation of the constraint. Incorporating it would only make the cost function larger without adding any value to the optimization problem.

```python
### Define variables

# The number of nodes
NumNodes = 5

# Max cost between nodes
maxCost = 10

# Node names, to interpret the solution later on
NodeName = {0:'A', 1:'B', 2:'C', 3:'D', 4:'E', 5:'F', 6:'G', 7:'H', 8:'I', 9:'J', 10:'K',
            11:'L', 12:'M', 13:'N', 14:'O', 15:'P', 16:'Q', 17:'R', 18:'S', 19:'T',
            20:'U', 21:'V', 22:'W', 23:'X', 24:'Y', 25:'Z'}

# Cost to travel between nodes -- note this matrix is not symmetric (traveling A->B is not same as B->A!)
CostMatrix = np.array([[1, 4, 7, 4, 3], [3, 3, 3, 1, 2], [2, 5, 2, 3, 1], [7, 8, 1, 3, 5], [3, 2, 1, 9, 8]])    # If you want to rerun with the same matrix
#CostMatrix = np.random.randint(maxCost, size=(NumNodes,NumNodes))                                          # If you want to run with a new cost matrix

############################################################################################
##### Define the optimization problem for the Quantum Inspired Solver
def OptProblem(CostMatrix) -> Problem:

    #'terms' will contain the weighting terms for the trips!
    terms = []

    ############################################################################################
    ##### Cost of traveling between nodes
    for k in range(0, len(CostMatrix)):                          # For each trip
        for i in range(0, len(CostMatrix)):                      # For each origin node
            for j in range(0, len(CostMatrix)):                  # For each destination node

                #Assign a weight to every possible trip from node i to node j for each trip
                terms.append(
                    Term(
                        c = CostMatrix.item((i,j)),                                     # Element of the cost matrix
                        indices = [i + (len(CostMatrix) *k ), j + (len(CostMatrix) * (k + 1))]    # +1 to denote dependence on next location
                    )
                )
                ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
                #print(f'{i + (len(CostMatrix) * k)}, {j + (len(CostMatrix) * (k + 1))}')                                                                   # Combinations between the origin and destination nodes
                #print(f'For x_{i + (len(CostMatrix) * k)}, to x_{j + (len(CostMatrix) * (k + 1))} in trip number {k} costs: {CostMatrix.item((i,j))}')     # In a format for the solver (as formulated in the cost function)
                #print(f'For node_{i}, to node_{j} in trip number {k} costs: {CostMatrix.item((i,j))}')                                                     # In a format that is easier to read for a human

    ############################################################################################
    ##### Constraint: Location constraint - salesperson can only be at 1 node at a time.
    for l in range(0,len(CostMatrix)+1):                # The total number of nodes that are visited over the route (+1 because returning to starting node)
        for i in range(0,len(CostMatrix)):              # For each origin node
            for j in range(0,len(CostMatrix)):          # For each destination node
                if i!=j and i<j:                        # i<j because we don't want to penalize twice // i==j is forbidden (above)
                    terms.append(
                        Term(
                            c = int(2 * np.max(CostMatrix)),                                     # Assign a weight penalty dependent on maximum distance from the cost matrix elements
                            indices = [i + (len(CostMatrix) * l), j + (len(CostMatrix) * l)]
                        )
                    )
                    ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
                    #print(f'{i + (len(CostMatrix) * k)}, {j + (len(CostMatrix) * (k))}')
                    #print(f'Location constraint 1: x_{i + (len(CostMatrix) * l)} - x_{j + (len(CostMatrix) * (l + 1))} (trip {l}) assigned weight: {int(2 * np.max(CostMatrix))}')  # In a format for the solver (as formulated in the cost function)

    ############################################################################################
    ##### Constraint: Location constraint - encourage the salesperson to be 'somewhere' otherwise all x_k might be 0 (for example).
    for v in range(0, len(CostMatrix) + len(CostMatrix) * (len(CostMatrix))):    # Select variable (v represents a node before/after any trip)
        terms.append(
            Term(
                c = int(-1.65 * np.max(CostMatrix)),          # Assign a weight penalty dependent on maximum distance from the cost matrix elements
                indices = [v]
            )
        )
        ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
        #print(v)
        #print(f'Location constraint 2: x_{v} assigned weight: {int(-1.65 * np.max(CostMatrix))}')                                                      # In a format for the solver (as formulated in the cost function)
        #print(f'Location constraint 2: node_{v % NumNodes} after {np.floor(v / NumNodes)} trips assigned weight: {int(-1.65 * np.max(CostMatrix))}')   # In a format that is easier to read for a human

    ############################################################################################
    ##### Penalty for traveling to the same node again --- (in the last step we can travel without penalties (this is to make it easier to specify an end node =) ))
    for p in range(0, len(CostMatrix) + len(CostMatrix) * (len(CostMatrix))):                                  # This selects a present node x: 'p' for present
        for f in range(p + len(CostMatrix), len(CostMatrix) * (len(CostMatrix)), len(CostMatrix)):              # This selects the same node x but after upcoming trips: 'f' for future
            terms.append(
                Term(
                    c = int(2 * np.max(CostMatrix)),                               # assign a weight penalty dependent on maximum distance from the cost matrix elements
                    indices = [p,f]
                )
            )
            ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
            #print(f'x_{p}, x_{f}')                                                                                # Just variable numbers
            #print(f'Visit once constraint: x_{p} - x_{f}  assigned weight: {int(2 * np.max(CostMatrix))}')                                                                     # In a format for the solver (as formulated in the cost function)
            #print(f' Visit once constraint: node_{p % NumNodes} - node_{(p + f) % NumNodes} after {(f - p) / NumNodes} trips assigned weight: {int(2 * np.max(CostMatrix))}')  # In a format that is easier to read for a human

    return Problem(name="Traveling Salesperson", problem_type=ProblemType.pubo, terms=terms)

OptimizationProblem = OptProblem(CostMatrix)
```

### Constraint 4 & 5: Beginning and ending at a specific node

The salesperson starts and finishes at the headquarters: the starting node from which they depart on their journey. Similarly to constraint 2, the salesperson can be rewarded for starting/finishing at a specific node.

For example, if you want the salesperson to start/end at a particular node, you can assign negative weights to the respective $x_k$ term in the first and last location vector. For node 0 that would result in negatively weighting $x_0$ and $x_{N^2}$, for example. This constraint is the most flexible one, and you can also expand it to encourage the salesperson to visit a specific node, or nodes, at pre-determined trip number $k$. Alternatively, you may see this constraint as a way to integrate prior-knowledge on the set of nodes into the optimization problem. Let's say you want the salesperson to visit node 1 after the second trip ($k=2$, it is the third node they visit), then by negatively weighting $x_{2N+1}$ the cost function will (likely) obtain a lower/smaller optimal (minimal) value if it gives this variable the value 1.

In this code, the salesperson is hard-coded to start and finish in node 0.

```python
### Define variables

# The number of nodes
NumNodes = 5

# Max cost between nodes
maxCost = 10

# Node names, to interpret the solution later on
NodeName = {0:'A', 1:'B', 2:'C', 3:'D', 4:'E', 5:'F', 6:'G', 7:'H', 8:'I', 9:'J', 10:'K',
            11:'L', 12:'M', 13:'N', 14:'O', 15:'P', 16:'Q', 17:'R', 18:'S', 19:'T',
            20:'U', 21:'V', 22:'W', 23:'X', 24:'Y', 25:'Z'}

# Cost to travel between nodes -- note this matrix is not symmetric (traveling A->B is not same as B->A!)
CostMatrix = np.array([[1, 4, 7, 4, 3], [3, 3, 3, 1, 2], [2, 5, 2, 3, 1], [7, 8, 1, 3, 5], [3, 2, 1, 9, 8]])    # If you want to rerun with the same matrix
#CostMatrix = np.random.randint(maxCost, size=(NumNodes,NumNodes))                                          # If you want to run with a new cost matrix

############################################################################################
##### Define the optimization problem for the Quantum Inspired Solver
def OptProblem(CostMatrix) -> Problem:

    #'terms' will contain the weighting terms for the trips!
    terms = []

    ############################################################################################
    ##### Cost of traveling between nodes
    for k in range(0, len(CostMatrix)):                          # For each trip
        for i in range(0, len(CostMatrix)):                      # For each origin node
            for j in range(0, len(CostMatrix)):                  # For each destination node

                #Assign a weight to every possible trip from node i to node j for each trip
                terms.append(
                    Term(
                        c = CostMatrix.item((i,j)),                                     # Element of the cost matrix
                        indices = [i + (len(CostMatrix) *k ), j + (len(CostMatrix) * (k + 1))]    # +1 to denote dependence on next location
                    )
                )
                ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
                #print(f'{i + (len(CostMatrix) * k)}, {j + (len(CostMatrix) * (k + 1))}')                                                                   # Combinations between the origin and destination nodes
                #print(f'For x_{i + (len(CostMatrix) * k)}, to x_{j + (len(CostMatrix) * (k + 1))} in trip number {k} costs: {CostMatrix.item((i,j))}')     # In a format for the solver (as formulated in the cost function)
                #print(f'For node_{i}, to node_{j} in trip number {k} costs: {CostMatrix.item((i,j))}')                                                     # In a format that is easier to read for a human

    ############################################################################################
    ##### Constraint: Location constraint - salesperson can only be at 1 node at a time.
    for l in range(0,len(CostMatrix)+1):                # The total number of nodes that are visited over the route (+1 because returning to starting node)
        for i in range(0,len(CostMatrix)):              # For each origin node
            for j in range(0,len(CostMatrix)):          # For each destination node
                if i!=j and i<j:                        # i<j because we don't want to penalize twice // i==j is forbidden (above)
                    terms.append(
                        Term(
                            c = int(2 * np.max(CostMatrix)),                                     # Assign a weight penalty dependent on maximum distance from the cost matrix elements
                            indices = [i + (len(CostMatrix) * l), j + (len(CostMatrix) * l)]
                        )
                    )
                    ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
                    #print(f'{i + (len(CostMatrix) * k)}, {j + (len(CostMatrix) * (k))}')
                    #print(f'Location constraint 1: x_{i + (len(CostMatrix) * l)} - x_{j + (len(CostMatrix) * (l + 1))} (trip {l}) assigned weight: {int(2 * np.max(CostMatrix))}')  # In a format for the solver (as formulated in the cost function)

    ############################################################################################
    ##### Constraint: Location constraint - encourage the salesperson to be 'somewhere' otherwise all x_k might be 0 (for example).
    for v in range(0, len(CostMatrix) + len(CostMatrix) * (len(CostMatrix))):    # Select variable (v represents a node before/after any trip)
        terms.append(
            Term(
                c = int(-1.65 * np.max(CostMatrix)),          # Assign a weight penalty dependent on maximum distance from the cost matrix elements
                indices = [v]
            )
        )
        ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
        #print(v)
        #print(f'Location constraint 2: x_{v} assigned weight: {int(-1.65 * np.max(CostMatrix))}')                                                      # In a format for the solver (as formulated in the cost function)
        #print(f'Location constraint 2: node_{v % NumNodes} after {np.floor(v / NumNodes)} trips assigned weight: {int(-1.65 * np.max(CostMatrix))}')   # In a format that is easier to read for a human

    ############################################################################################
    ##### Penalty for traveling to the same node again --- (in the last step we can travel without penalties (this is to make it easier to specify an end node =) ))
    for p in range(0, len(CostMatrix) + len(CostMatrix) * (len(CostMatrix))):                                  # This selects a present node x: 'p' for present
        for f in range(p + len(CostMatrix), len(CostMatrix) * (len(CostMatrix)), len(CostMatrix)):              # This selects the same node x but after upcoming trips: 'f' for future
            terms.append(
                Term(
                    c = int(2 * np.max(CostMatrix)),                               # assign a weight penalty dependent on maximum distance from the cost matrix elements
                    indices = [p,f]
                )
            )
            ##----- Uncomment one of the below statements if you want to see how the weights are assigned! -------------------------------------------------------------------------------------------------
            #print(f'x_{p}, x_{f}')                                                                                # Just variable numbers
            #print(f'Visit once constraint: x_{p} - x_{f}  assigned weight: {int(2 * np.max(CostMatrix))}')                                                                     # In a format for the solver (as formulated in the cost function)
            #print(f' Visit once constraint: node_{p % NumNodes} - node_{(p + f) % NumNodes} after {(f - p) / NumNodes} trips assigned weight: {int(2 * np.max(CostMatrix))}')  # In a format that is easier to read for a human

    #############################################################################################
    ##### Begin at x0
    terms.append(
        Term(
            c = int(-10 * np.max(CostMatrix)),                   # Assign a weight penalty dependent on maximum distance from the cost matrix elements
            indices = [0]
        )
    )

    ############################################################################################
    ##### End at x0
    terms.append(
        Term(
            c = int(-10 * np.max(CostMatrix)),                   # Assign a weight penalty dependent on maximum distance from the cost matrix elements
            indices = [len(CostMatrix) * (len(CostMatrix))]
        )
    )

    return Problem(name="Traveling Salesperson", problem_type=ProblemType.pubo, terms=terms)

OptimizationProblem = OptProblem(CostMatrix)
```

### Submitting the optimization problem

The optimization problem subject to the necessary constraints have been defined! Now it is time hand the problem over to the Azure QIO solvers and analyze the routes that are returned to us.

Some essential specifications/explanations:

1. The problem type is a [PUBO (Polynomial Unconstrained Binary Optimization)](https://docs.microsoft.com/azure/quantum/optimization-binary-optimization) - the variables ($x_k$) that are optimized for can take a value of 0, or 1.

2. We submit the problem to an Azure QIO solver, which one is up to you. Each has benefits/drawbacks, and [selecting the best one requires some experimentation](https://docs.microsoft.com/azure/quantum/optimization-which-solver-should-you-use). To read more about the available solvers, please refer to the [Microsoft QIO solver overview page](https://docs.microsoft.com/azure/quantum/provider-microsoft-qio) on the Azure Quantum docs site.

3. These optimizers are heuristics, which means they aren't guaranteed to find the optimal solution (or even a valid one, depending on how well you have encoded your cost function). Due to this, it is important to validate the solution returned. It is also recommended to run the solver several times to see if it returns the same solution. Additionally, having longer optimization times (larger timeout) can return better solutions.

4. The solution returned by the solver is heavily influenced by the constraint weights. Feel free to play around with these (called tuning), and discover if you can make the optimization more efficient (speed vs solution quality trade-off). A suggestion would be to make the weights dependent on the cost matrix norm (Frobenius,1,2,etc.).

5. Here the hardware implementation is defaulted to CPU. For further information on available hardware, please refer to the [Microsoft QIO solver overview page](https://docs.microsoft.com/azure/quantum/provider-microsoft-qio) on the Azure Quantum docs site.

```python
############################################################################################
##### Choose the solver and parameters --- uncomment if you wish to use a different one  --- timeout = 120 seconds

solver = SimulatedAnnealing(workspace, timeout = 120)
#solver = ParallelTempering(workspace, timeout = 120)
#solver = Tabu(workspace, timeout = 120)
#solver = QuantumMonteCarlo(workspace, sweeps = 2, trotter_number = 10, restarts = 72, seed = 22, beta_start = 0.1, transverse_field_start = 10, transverse_field_stop = 0.1) # QMC is not available parameter-free yet

route = solver.optimize(OptimizationProblem)                                        # Synchronously submit the optimization problem to the service -- wait until done.
print(route)
```

### Parse the results

Below are defined some utility functions which are needed to read and analyze the result returned by the solver.

```python

############################################################################################
##### Read the results returned by the solver - need to make the solution readable
def ReadResults(Config: dict, NodeName, CostMatrix, NumNodes):

    #############################################################################################
    ##### Read the return result (dictionary) from the solver and sort it
    PathChoice = Config.items()
    PathChoice = [(int(k), v) for k, v in Config.items()]
    PathChoice.sort(key=lambda tup: tup[0])

    #############################################################################################
    ##### Initialize variables to understand the routing
    TimeStep=[]                                                     # This will contain an array of times/trips - each node is represented during/for each time/trip interval
    Node = []                                                       # This will contain an array of node names
    Location = []                                                   # This will contain the locations the salesperson is for each time/trip
    RouteMatrixElements = []                                        # This will contain the indices of the cost matrix representing where the salesperson has traveled (to determine total cost)

    #############################################################################################
    ##### Go through nodes during each timestep/trip to see where the salesperson has been
    for Index in PathChoice:
        TimeStep.append(math.floor(Index[0] / len(CostMatrix)))         # Time step/trip = the k-th is floor of the index divided by the number of nodes
        Node.append(NodeName[(Index[0] % len(CostMatrix))])             # Append node names for each time step
        Location.append(Index[1])                                       # Append locations for each time step
        if Index[1] == 1:                                               # Save selected node where the salesperson travels to in that trip (if the variable == 1, the salesperson goes to that node)
            RouteMatrixElements.append(Index[0] % len(CostMatrix))      # Save the indices (this returns the row index)
    SimulationResult = np.array([TimeStep, Node, Location])             # Save all the route data (also where the salesperson did not go during a turn/trip/timestep)

    #############################################################################################
    ##### Create the route dictionary
    k=0
    PathDict = {}
    PathDict['Route'] = {}
    Path = np.array([['Timestep,', 'Node']])
    for i in range(0, (NumNodes * (NumNodes + 1))):
        if SimulationResult[2][i] == '1':                                                                       # If the SimulationResult[2][i] (location) == 1, then that's where the salesperson goes/went
            Path = np.concatenate((Path, np.array([[SimulationResult[j][i] for j in range(0, 2)]])), axis=0)    # Add the rows where the salesperson DOES travel to Path matrix
            PathDict['Route'].update({k: Path[k + 1][1]})                                                       # Save the route to a dictionary
            k += 1                                                                                # Iterable keeps track for the dictionary, but also allows to check for constraint
    AnalyzeResult(Path, NumNodes)                                                                               # Check if Path array satisfies other constraints as well (could integrate previous one above in function)

    #############################################################################################
    ###### Calculate the total cost of the route the salesperson made (can be in time (minutes) or in distance (km))
    TotalRouteCost = 0
    for trips in range(0, NumNodes):
        TotalRouteCost = TotalRouteCost+float(CostMatrix.item(RouteMatrixElements[trips], RouteMatrixElements[trips + 1]))     # The sum of the matrix elements where the salesperson has been (determined through the indices)
    PathDict['RouteCost'] = {'Cost':TotalRouteCost}

    ##### Return the simulation result in a human understandable way =)
    return PathDict

############################################################################################
##### Check whether the solution satisfies the optimization constraints
def AnalyzeResult(Path, NumNodes):

    ############################################################################################
    ##### Check if the number of travels is equal to the number of nodes + 1 (for returning home)
    if (len(Path) - 1) != NumNodes + 1:
        raise RuntimeError('This solution is not valid -- Number of nodes visited invalid!')
    else:
        NumNodesPassed = NumNodes
        print(f"Number of nodes passed = {NumNodesPassed}. This is valid!")

    ############################################################################################
    ##### Check if the nodes are different (except start/end node)
    PastNodes = []
    for k in range(1, len(Path) - 1):                                                                           # Start to second last node must all be different - skip header so start at 1, skip last node so - 1
        for l in range(0, len(PastNodes)):
            if Path[k][1] == PastNodes[l]:
                raise RuntimeError('This solution is not valid -- Traveled to a non-starting node more than once')
        PastNodes.append(Path[k][1])
    print(f"Number of different nodes passed = {NumNodes}. This is valid!")

    ############################################################################################
    ##### Check if the end node is same as the start node
    if Path[1][1] != Path[-1][1]:
        raise RuntimeError(f'This solution is not valid -- Start node {Path[1][1]} is not equal to end node {Path[-1][1]}')
    print('Start and end node are the same. This is valid!')

    print('Valid route!')
```

### Read the results and analyze the path

In reading the returned solution, the route is mapped into a human readable format.

In analyzing the path, the validity of the route is checked by going over whether constraints are satisfied.

```python
##### Call the function to interpret/convert/analyze the optimization results into a more meaningful/understandable format
PathDict = ReadResults(route['configuration'], NodeName, CostMatrix, NumNodes)
PathDict
```

Well done! If the solver returned a valid solution, you have solved the traveling salesperson problem! If an error was raised then feel free to go back and adjust some settings and weights.

## Next steps

Now that you understand the problem scenario and how to define the cost function, there are a number of experiments you can perform to deepen your understanding and improve the solution defined above:

- Modify the problem definition (e.g. by changing the number of nodes)
- Rewrite the penalty functions to improve their efficiency
- Tune the parameters (weights)
- Try using a different solver, or a parameterized version (see [Which optimization solver should I use?](https://docs.microsoft.com/en-gb/azure/quantum/optimization-which-solver-should-you-use) for some tips)


# Aula 1 - Sensores Resistivos

<style>
.output {
    align-items: center;
}
</style>

<form action="javascript:code_toggle()"><input type="submit" id="toggleButton" value="Show Code"></form>

```python
# Important Libraries
import sympy as sp
import numpy as np

# Display Settings
from sympy.interactive import printing
from IPython.display import display, Latex, Markdown, Math

printing.init_printing(use_latex=True)
def mydisp(textvar,contentvar):
    display(Math(textvar+" = "+sp.latex(contentvar)))
```

# Ponte de Wheatstone

A ponte de Wheatstone é um circuito bastante utilizado para converter o valor de uma variação de resistência em uma variação de tensão, possibilitando assim sua medida. É constituido por quatro resistências ligadas a uma fonte de tensão como ilustrado logo abaixo:

Podemos obter a relação entre as resistências avaliando o circuito pela lei. Desta maneira teremos as seguinte equação:

```python
Vb, R1,R2,R3,Rx = sp.symbols("V_b R_1 R_2 R_3 R_x",real=True)

Vd = Vb*R2/(R1+R2)
Vb = Vb*Rx/(Rx+R3)

Vg = Vb - Vd
mydisp("V_G",Vg)

# Rescrevendo a equação
Vg = ((Vg*((Rx+R3)*(R1+R2))).expand()/((Rx+R3)*(R1+R2))).simplify()
mydisp("V_G",Vg)
```

$$V_G = - \frac{R_{2} V_{b}}{R_{1} + R_{2}} + \frac{R_{x} V_{b}}{R_{3} + R_{x}}$$

$$V_G = \frac{V_{b} \left(R_{1} R_{x} - R_{2} R_{3}\right)}{R_{1} R_{3} + R_{1} R_{x} + R_{2} R_{3} + R_{2} R_{x}}$$

Assumindo $R_2 = 0$ e $R_1 = R_2 = R$ temos

```python
R = sp.symbols("R")
dR =sp.symbols("Delta_R")

Vg1 = Vg.subs([(R1,R),(R2,0),(R3,R)]).simplify()
mydisp("V_G",Vg1)
```

$$V_G = \frac{R_{x} V_{b}}{R + R_{x}}$$

## Potênciometro

Supondo $R = R_1 + R_2$ , $V_0$ como a tensão medida em $R_2$ e $x = \frac{R_1}{R_2}$ temos que a tensão de saída num caso ideal, isto é com $R_L = 0$ temos que a tensão de saída será

```python
'''
 Variáveis Potênciometro
 R: Resistência Total Potênciometro
 x: Fração do Comprimento Total do Potênciometro
 Vi: Tensão de Entrada do Potênciometro
 Vo: Tensão de Saída do Potênciometro
'''
R, x, Vi = sp.symbols("R x V_s");

# Modelo Sem Carga
Vo = Vi*x

mydisp("V_0",Vo)
```

$$V_0 = V_{s} x$$

Para $R_L > 0$, temos

```python
'''
Rl: Resistência da Carga
Vol: Tensão
'''
Rl = sp.Symbol("R_L");
# Modelo Acrescido da Carga
Vol = Vi*x*(Rl/(R*x*(1-x)+Rl))
mydisp("V_0",Vol)
```

$$V_0 = \frac{R_{L} V_{s} x}{R x \left(- x + 1\right) + R_{L}}$$

Fazendo $k = \frac{R_l}{R}$ e substituindo em $V_0$ temos

```python
# Estudo Modelo Acrescido da Carga
k = sp.symbols("k") # Rl/R
Vol2 = Vi*x*(1/(x*(1-x)/k +1))
mydisp("V_0",Vol2)
```

$$V_0 = \frac{V_{s} x}{1 + \frac{x}{k} \left(- x + 1\right)}$$

# Anemômetro de fio quente

O anemômetro de fio quente é um sensor bastante simples e comum usado para medir o fluxo de ar em uma tubulação. É costituido de um fio de baixa resistência o qual é submetido a uma tensão constante. O fio esquenta dissipando calor pelo efeito Joule. Parte deste calor é dissipado pelo fenômeno da convecção e por consequência o a resistência do fio é alterada e pode ser medida através de uma [ponte de wheatstone](#Ponte-de-Wheatstone) [1].

[1]: http://www.dee.ufrn.br/~luciano/arquivos/ins_ele/Apresenta%E7%F5es_2008_2/CAYO%20CID/Instrumentao_Eletrnica_Cayo_Cid_200321285_AFQ.pdf
[2]: http://www.abcm.org.br/anais/conem/2010/PDF/CON10-1587.pdf


# 1-D Convection-Diffusion equation

In this tutorial, we consider the **1D** convection-diffusion equation

$$
\frac{\partial u}{\partial t} + c \partial_x u - \nu \frac{\partial^2 u}{\partial x^2} = 0
$$

```python
# needed imports
from numpy import zeros, ones, linspace, zeros_like
from matplotlib.pyplot import plot, show
%matplotlib inline
```

```python
# Initial condition
import numpy as np
u0 = lambda x: np.exp(-(x-.5)**2/.05**2)

grid = linspace(0., 1., 401)
u = u0(grid)
plot(grid, u) ; show()
```

### Time scheme

$$\frac{u^{n+1}-u^n}{\Delta t}  + c \partial_x u^{n+1} - \nu \partial_{xx} u^{n+1} = 0 $$

$$ \left(I + c \Delta t \partial_x - \nu \Delta t \partial_{xx} \right) u^{n+1} = u^n $$

### Weak formulation

$$
\langle v, u^{n+1} \rangle - c \Delta t ~ \langle \partial_x v, u^{n+1} \rangle + \nu \Delta t ~ \langle \partial_x v, \partial_x u^{n+1} \rangle = \langle v, u^n \rangle
$$
expending $u^n$ over the fem basis, we get the linear system

$$A U^{n+1} = M U^n$$

where

$$
M_{ij} = \langle b_i, b_j \rangle
$$

$$
A_{ij} = \langle b_i, b_j \rangle - c \Delta t ~ \langle \partial_x b_i, b_j \rangle + \nu \Delta t ~ \langle \partial_x b_i, \partial_x b_j \rangle
$$

## Abstract Model using SymPDE

```python
from sympde.core import Constant
from sympde.expr import BilinearForm, LinearForm, integral
from sympde.topology import ScalarFunctionSpace, Line, element_of, dx
from sympde.topology import dx1 # TODO: this is a bug right now
```

```python
# ... abstract model
domain = Line()

V = ScalarFunctionSpace('V', domain)

x = domain.coordinates

u,v = [element_of(V, name=i) for i in ['u', 'v']]

c = Constant('c')
nu = Constant('nu')
dt = Constant('dt')

# bilinear form
# expr = v*u - c*dt*dx(v)*u # TODO BUG not working
expr = v*u - c*dt*dx1(v)*u + nu*dt*dx1(v)*dx1(u)
a = BilinearForm((u,v), integral(domain , expr))

# bilinear form for the mass matrix
expr = u*v
m  = BilinearForm((u,v), integral(domain , expr))

# linear form for initial condition
from sympy import exp
expr = exp(-(x-.5)**2/.05**2)*v
l = LinearForm(v, integral(domain, expr))
```

## Discretization using Psydac

```python
from psydac.api.discretization import discretize
```

```python
c = 1     # wavespeed
nu = 0.01   # viscosity
T = 0.2  # T final time
dt = 0.001
niter = int(T / dt)

degree = [3]    # spline degree
ncells = [64]   # number of elements
```

```python
# Create computational domain from topological domain
domain_h = discretize(domain, ncells=ncells, comm=None)

# Discrete spaces
Vh = discretize(V, domain_h, degree=degree)

# Discretize the bilinear forms
ah = discretize(a, domain_h, [Vh, Vh])
mh = discretize(m, domain_h, [Vh, Vh])

# Discretize the linear form for the initial condition
lh = discretize(l, domain_h, Vh)
```

```python
# assemble matrices and convert them to scipy
M = mh.assemble().tosparse()
A = ah.assemble(c=c, nu=nu, dt=dt).tosparse()

# assemble the rhs and convert it to numpy array
rhs = lh.assemble().toarray()
```

```python
from scipy.sparse.linalg import cg, gmres
```

```python
# L2 projection of the initial condition
un, status = cg(M, rhs, tol=1.e-8, maxiter=5000)
```

```python
from simplines import plot_field_1d
plot_field_1d(Vh.knots[0], Vh.degree[0], un, nx=401)
```

```python
for i in range(0, niter):
    b = M.dot(un)
    un, status = gmres(A, b, tol=1.e-8, maxiter=5000)
```

```python
plot_field_1d(Vh.knots[0], Vh.degree[0], un, nx=401)
```

```python

```

```python

```


In this notebook, we analyze an object in free fall under constant acceleration. As a first demonstration of the Kalman filter, there are no hidden variables. Said another way, the measurement vector are the same variables as the state vector.

```julia
using LinearAlgebra
using Random
using CairoMakie
using LaTeXStrings

CairoMakie.activate!()

include("kalmanflt.jl");
```

The system is a particle falling due to gravity without air resistance. The equation of state are:

```julia
# measurement = Vector{Float64}([1, 2])
# obs_mat = zeros((2, 2));
# obsCov_mat = zeros((2, 2))
# obs = KalmanObservation{Float64}(obs_mat, obsCov_mat)
```

$$
\begin{align}
h &= h_{0} + v_{0} t + \frac{1}{2} a t^{2} \\
v &= v_{0} + a t
\end{align}
$$

```julia
time_steps = 125
delta_time_sec = 0.02  # sec
accel_ms2 = -9.8;  # meter / sec / sec
```

```julia
"""
    get_model_estimate(initial_state, measurement_time_sec)

Assuming constant acceleration, compute how the position and velocity change from their initial state

# Arguments
- `initial_state::Vector`: Must be a 2-item vector exactly as `Vector{Float64}([p, v])` where `p` is the position and `v` is the velocity
- `measurement_time_sec::Number`: The number of seconds from the initial state

# Returns
`Vector` of the state estimate

# Examples
```jldoctest
julia> v = get_model_estimate(Vector([9.8, 0.]), sqrt(2.))
2-element Vector{Float64}:
  -1.7763568394002505e-15
 -13.859292911256333
```
"""
function get_model_estimate(
    initial_state::Vector,
    measurement_time_sec::Number)::Vector

    transition_mat = [1.0 measurement_time_sec;
                      0.0 1.0]
    external_input_vector = Vector([1, 1]) .* accel_ms2
    control_mat = [0.5 * measurement_time_sec^2 0.0;
                   0.0 measurement_time_sec]
    return ((transition_mat * initial_state)
            + (control_mat * external_input_vector))
end;
```

Initialize the initial and true states

```julia
initial_state_vector = Vector([1000., -0.01])
true_state_vector = Vector([1015., 2.5]);
```

Populate an array of the true state evolution in time

```julia
true_state_fcn_time = zeros(Float64, (2, time_steps))
for time_step_index = 1:time_steps
    time_sec = delta_time_sec * (time_step_index - 1)
    true_state_fcn_time[:, time_step_index] = get_model_estimate(true_state_vector, time_sec)
end;
```

Plot the true position

```julia
time_axis_sec = [delta_time_sec * (t-1) for t in 1:time_steps]

scatter(
    time_axis_sec,
    true_state_fcn_time[1, :],
    axis = (aspect = 2, xlabel = "Time (second)", ylabel = "Position (meters)"),
    label="True position"
)
lines!(
    time_axis_sec,
    true_state_fcn_time[1, :]
)
axislegend()
current_figure()

```

Plot the true velocity

```julia
scatter(
    time_axis_sec,
    true_state_fcn_time[2, :],
    axis = (aspect = 2, xlabel = "Time (second)", ylabel = "Velocity (meters  / second)"),
    label="True velocity"
)
lines!(
    time_axis_sec,
    true_state_fcn_time[2, :]
)
axislegend()
current_figure()
```

```julia
"""
    get_measurement(true_state, measurement_time_sec)

Simulate the measurement at the specified time given the true state. Artificial noise is added to simulate a real system

# Arguments
- `true_state::Vector`: Must be a 2-item vector exactly as `Vector{Float64}([p, v])` where `p` is the position and `v` is the velocity
- `measurement_time_sec::Number`: The number of seconds from the true starting state

# Returns
`Vector` of the measurement

See also [`get_model_estimate`](@ref) for an example
"""
function get_measurement(
    true_state::Vector,
    measurement_time_sec)::Vector
    """Get the next measurement
    In a 'real' physical system, we would not need to simulate the measurement
    """

    model_estimate_vec = get_model_estimate(
        true_state, measurement_time_sec
    )
    # Pseudo-measurement using classical physics calculation with noise
    position_noise_m = 1.0  # meter
    velocity_noise_ms = 0.5  # meter / sec
    artificial_noise_vec = Vector([position_noise_m, velocity_noise_ms]) .* rand(2)
    measurement::Vector = model_estimate_vec + artificial_noise_vec
    return measurement
end;
```

The state vector is position, velocity. We will use very generous covariance values here as values too small provide no little room for the algorithm to work.

```julia
initial_state_cov = diagm([100.0^2, # meters^2
                              10.0^2])  # (meters / seconds)^2
initial_state = KalmanState{Float64}(initial_state_vector, initial_state_cov);
```

```julia
state_transition_mat = [1.0 delta_time_sec;
                        0.0 1.0]
control_mat = [0.5 * delta_time_sec^2 0;
               0                      delta_time_sec]
system_noise_mat = [0.1  0.05;
                    0.05 0.01]
external_input_vector = Vector([accel_ms2, accel_ms2])
update_matices = KalmanUpdate{Float64}(state_transition_mat, control_mat, system_noise_mat);
```

Initialize the observation matrix. The measurement and state is one-to-one. For the measurement, the variance for the position (velocity) is 100 meters-squared (1 meters-squared/sec-squared)

$$
\begin{align}
z_{n} &= H x_{n} \\
\begin{pmatrix}
h_{z_{n}} \\
v_{z_{n}}
\end{pmatrix} &= \begin{pmatrix}
1 & 0\\
0 & 1
\end{pmatrix}
\begin{pmatrix}
h_{x_{n}} \\
v_{x_{n}}
\end{pmatrix} \\
\text{cov}\left(z\right) &=
\begin{pmatrix}
10^2 & 0 \\
0   & 5^2
\end{pmatrix}
\end{align}
$$

```julia
obs_mat = convert(Matrix{Float64}, I(2))
obsCov = diagm([10.0^2, 5.0^2])
observations = KalmanObservation{Float64}(obs_mat, obsCov);
```

Gather measurements and update the state

```julia
states_fcn_time = Vector{KalmanState{Float64}}(undef, time_steps);
measurements = Array{Vector{Float64}}(undef, time_steps)
# This is the seed fo r the algorithm
next_state = KalmanState{Float64}(initial_state.state, initial_state.cov)

for step in 1:time_steps
    # At current time, get measurement
    time_sec = (step - 1) * delta_time_sec
    measurement_vec = get_measurement(true_state_vector, time_sec)
    observations.meas = measurement_vec
    measurements[step] = measurement_vec
    true_state_vec = get_model_estimate(true_state_vector, time_sec)

    # Predict the next state
    predicted_state = predict_state(next_state, update_matices, external_input_vector)

    # Update the estimate
    next_state = correct_state(predicted_state, observations)
    states_fcn_time[step] = next_state

end;
```

```julia
scatter(
    time_axis_sec,
    [measurements[step][1] for step in 1:time_steps],
    axis = (aspect = 2, xlabel = "Time (second)", ylabel = "Position (meters)"),
    label="Measured position"
)
lines!(time_axis_sec, [measurements[step][1] for step in 1:time_steps])
axislegend()
current_figure()
```

```julia
scatter(
    time_axis_sec,
    [measurements[step][2] for step in 1:time_steps],
    axis = (aspect = 2, xlabel = "Time (second)", ylabel = "Position (meters)"),
    label="Measured velocity"
)
lines!(time_axis_sec, [measurements[step][2] for step in 1:time_steps])
axislegend()
current_figure()
```

Plot the true and estimated position

```julia
scatter(
    time_axis_sec,
    true_state_fcn_time[1, :],
    axis = (aspect = 2, xlabel = "Time (second)", ylabel = "Position (meters)"),
    label="True position"
)

scatter!(
    time_axis_sec,
    [s.state[1] for s in states_fcn_time],
    label="Estimated position"
)

errorbars!(
    time_axis_sec,
    [s.state[1] for s in states_fcn_time],
    [sqrt(s.cov[1, 1]) for s in states_fcn_time]
)

lines!(
    time_axis_sec,
    true_state_fcn_time[1, :]
)

lines!(
    time_axis_sec,
    [s.state[1] for s in states_fcn_time]
)
axislegend()
current_figure()

```

Plot the true and estimated velocity

```julia
scatter(
    time_axis_sec,
    true_state_fcn_time[2, :],
    axis = (aspect = 2, xlabel = "Time (second)", ylabel = "Velocity (meter /second)"),
    label="True position"
)

scatter!(
    time_axis_sec,
    [s.state[2] for s in states_fcn_time],
    label="Estimated velocity"
)

errorbars!(
    time_axis_sec,
    [s.state[2] for s in states_fcn_time],
    [sqrt(s.cov[2, 2] * Int(s.cov[2, 2] > 0)) for s in states_fcn_time]
)

lines!(
    time_axis_sec,
    true_state_fcn_time[2, :]
)

lines!(
    time_axis_sec,
    [s.state[2] for s in states_fcn_time]
)
axislegend()
current_figure()

```

```julia

```


```python
from matplotlib import pyplot as plt, ticker as tk
%matplotlib inline
%config InlineBackend.figure_format='retina'

import numpy as np
import pandas as pd

from sympy import *
init_printing()

from fun_expr import Function_from_Expression as FE
```

```python
k_0 = 400
k_inf = 1100
beta = 1/20

t = Symbol('t')

k = FE(t, k_inf + (k_0-k_inf)*exp(-beta*t),name='k')
k.equation()
```

$\displaystyle k\left(t\right) = 1100 - 700 e^{- 0.05 t}$

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

ax.axhline(k_inf,c='r')
lx = np.linspace(0,120)
ax.plot(lx,k.lambdified(lx))

ax.set(
    xlim=(0,120), xlabel='Zeit $t$ in Minuten',
    ylim=(0,1200), ylabel='CO_2-Konzentration in ppM'
)

ax.grid(lw=0.25,c='k')
plt.savefig('U03-A02-grafik.pdf',bbox_inches='tight')
```