# Lecture 12, Methods for multiobjective optimization

If you want to know more about the topic of this lecture, I urge you to read Professor Miettinen's book Nonlinear Multiobjective Optimization

## Classification of methods

Methods for multiobjective optimization are often charactermized by the involvement of the decision maker in the process.

The types of methods are
* **no preference methods**, where the decision maker does not play a role,
* **a priori methods**, where the decision maker gives his/her preference information at first and then the optimization method find the best match to that preference information,
* **a posteriori methods**, where the optimization methods try to characterize all/find a good represenatation of the Pareto optimal solutions and the decision maker chooses the most preferred one of those,
* **interactive methods**, where the optimization method and the decision maker alternate in iteratively search for the most preferred solution.

##  Our example problem for this lecture

We study a hypothetical decision problem of buying a car, when you can choose to have a car with power between (denoted by $p$) 50 and 200 kW and average consumption (denoted by $c$) per 100 km between 3 and 10 l. However, in addition to the average consumption and power, you need to decide the volume of the cylinders (v), which may be between 1000 $cm^3$ and $4000cm^3$. Finally, the price of the car follows now a function
$$
\left(\sqrt{\frac{p-50}{50}}\\
+\left(\frac{p-50}{50}\right)^2+0.3(10-c)\\ +10^{-5}\left(v-\left(1000+3000\frac{p-50}{150}\right)\right)^2\right)10000\\+5000
$$
in euros. This problem can be formulated as a multiobjective optimization problem
$$
\begin{align}
\min \quad & \{c,-p,P\},\\
\text{s.t. }\quad
&50\leq p\leq 200\\
&3\leq c\leq 10\\
&1000\leq v\leq 4000,\\
\text{where }\quad&P = \left(\sqrt{\frac{p-50}{50}}+\frac{p-50}{50}^2+\frac{10-c}{10} +\right.\\
&\left.\frac{p-50}{50}\frac{10-c}{10}\right)10000+5000
\end{align}
$$

```python
#Let us define a Python function which returns the value of this
import math
def car_problem(c,p,v):
#    import pdb; pdb.set_trace()
    return [#Objective function values
        c,-p,
        (math.sqrt((p-40.)/50.)+((p-50.)/50.)**2+
        0.3*(10.-c)+0.00001*(v-(1000.+3000.*(p-50.)/150.))**2)*10000.
        +5000.]
```

```python
print("Car with 3 l/100km consumption, 50kW and 1000$cm^3$ engine would cost "
      +str(car_problem(3,50,1000)[2])+"€")
print("Car with 3 l/100km consumption, 100kW and 2000$cm^3$ engine would cost "
      +str(car_problem(3,100,2000)[2])+"€")
print("Car with 3 l/100km consumption, 100kW and 1000$cm^3$ engine would cost "
      +str(car_problem(3,100,1000)[2])+"€")
```

    Car with 3 l/100km consumption, 50kW and 1000$cm^3$ engine would cost 30472.135955€
    Car with 3 l/100km consumption, 100kW and 2000$cm^3$ engine would cost 46954.4511501€
    Car with 3 l/100km consumption, 100kW and 1000$cm^3$ engine would cost 146954.45115€

## Normalization of the objectives

**In many of the methods, the normalization of the objectives is necessary. **

We can normalize the objectives using the nadir and ideal and setting the normalized objective as
$$ \tilde f_i = \frac{f_i-z_i^{ideal}}{z_i^{nadir}-z_i^{ideal}}$$

## Calculating the ideal

**Finding the ideal for problems is usually easy, if you can optimize the objective functions separately.**

For the car problem, ideal can be computed easily using the script:

```python
#Calculating the ideal
from scipy.optimize import minimize
import ad
def calc_ideal(f):
    ideal = [0]*3 #Because three objectives
    solutions = [] #list for storing the actual solutions, which give the ideal
    bounds = ((3,10),(50,200),(1000,4000)) #Bounds of the problem
    for i in range(3):
        res=minimize(
            #Minimize each objective at the time
            lambda x: f(x[0],x[1],x[2])[i], [3,50,1000], method='SLSQP'
            #Jacobian using automatic differentiation
            ,jac=ad.gh(lambda x: f(x[0],x[1],x[2])[i])[0]
            #bounds given above
            ,bounds = bounds
            ,options = {'disp':True, 'ftol': 1e-20, 'maxiter': 1000})
        solutions.append(f(res.x[0],res.x[1],res.x[2]))
        ideal[i]=res.fun
    return ideal,solutions
```

```python
ideal, solutions= calc_ideal(car_problem)
print ("ideal is "+str(ideal))
```

    Optimization terminated successfully.    (Exit mode 0)
                Current function value: 3.0
                Iterations: 1
                Function evaluations: 12
                Gradient evaluations: 1
    Positive directional derivative for linesearch    (Exit mode 8)
                Current function value: -200.000007903
                Iterations: 14
                Function evaluations: 10
                Gradient evaluations: 10
    Positive directional derivative for linesearch    (Exit mode 8)
                Current function value: 9371.55596176
                Iterations: 10
                Function evaluations: 6
                Gradient evaluations: 6
    ideal is [3.0, -200.00000790345445, 9371.5559617552626]

## Pay-off table method

**Finding the nadir value is however, usually much harder.**

Usually, the nadir value is estimated using the so-called pay-off table method.

The pay-off table method does not guarantee to find the nadir for problems with more than two objectives. (One of your exercises this week will be to show this.)

The method is, however, a generally accepted way of approximating the nadir vector.

In the pay-off table method:
1. the objective values for attaining the individual minima are added in table
2. the nadir is estimated by each objectives maxima in the table.

### The nadir for the car selection problem
The table now becomes by using the *solutions* that we returned while calculating the ideal

```python
for solution in solutions:
    print solution
```

    [3.0, -50.0, 30472.13595499958]
    [3.0, -200.00000790345445, 1033888.6485874163]
    [10.033526664414772, -50.0, 9371.5559617552626]

Thus, the esimation of the nadir vector is
$$(10,-50,1033888.543820).$$

This is actually the real Nadir vector for this problem.

### Normalized car problem

```python
#Let us define a Python function which returns the value of this
import math
def car_problem_normalized(c,p,v):
    z_ideal = [3.0, -200.0, 9472.1359549995796]
    z_nadir = [10,-50,1033888.543820]
#    import pdb; pdb.set_trace()
    z = car_problem(c,p,v)
    return [(zi-zideali)/(znadiri-zideali) for
            (zi,zideali,znadiri) in zip(z,z_ideal,z_nadir)]
```

```python
print("Normalized value of the car problem at (3,50,1000) is "
      +str(car_problem_normalized(3,50,1000)))
print("Normalized value of the car problem at (3,125,2500) is "
      +str(car_problem_normalized(3,125,2500)))
print("Normalized value of the car problem at (10,100,1000) is "
      +str(car_problem_normalized(10,100,1000)))
```

    Normalized value of the car problem at (3,50,1000) is [0.0, 1.0, 0.020499476422645723]
    Normalized value of the car problem at (3,125,2500) is [0.0, 0.5, 0.05082529765792966]
    Normalized value of the car problem at (10,100,1000) is [1.0, 0.6666666666666666, 0.11370602257129601]

** So, value 1 now indicates the worst value on the Pareto frontier and value 0 indicates the best values**

Let's set the ideal and nadir for later reference:

```python
z_ideal = [3.0, -200.0, 9472.1359549995796]
z_nadir = [10.,-50,1033888.543820]
```

**From now on, we will deal with the normalized problem, although, we write just $f$.** The aim of this is to simplify presentation.

## No preference methods

* Usually only for situations, where the decision maker is not available or does not want to get involved
* These just compute a single Pareto optimal solution, which is in somehow mathematically thought as the best compromise

## Notation

For short, let us denote the feasible set $\{x\in\mathbb R^n: g_j(x) \geq 0 \text{ for all }j=1,\ldots,J \text{ and } h_k(x) = 0\text{ for all }k=1,\ldots,K\}$ by $S$.

### Method of Global criterion

Involved minimization of the p-norm of $f(x)-z^{ideal}$, that is we solve the problem
$$
\min_{x\in S} \|f(x) - z^{ideal}\|_p.
$$

**An optimal solution to this problem is Pareto optimal**

### Applying to our problem studied

```python
import numpy as np
def global_criterion_method(f,p):
    #Ideal for any normalized problem is (0,0,0)
    ideal = [0,0,0]
    bounds = ((3,10),(50,200),(1000,4000)) #Bounds of the problem
    #Objective is the norm of objective function values minus
    #the ideal
    obj = lambda x: np.linalg.norm(np.array(f(x[0],x[1],x[2]))
                                   -np.array(ideal),ord=p)
    res=minimize(
            #Minimize p distance from the ideal
            obj, [3,50,1000], method='SLSQP'
            #Jacobian using automatic differentiation
            ,jac=ad.gh(obj)[0]
            #bounds given above
            ,bounds = bounds,options = {'disp':True})
    return res.x
```

```python
%pdb
```

    Automatic pdb calling has been turned ON

```python
solution = global_criterion_method(car_problem_normalized,
                                           2)
print "variable values are ",solution
f_global_criterion = car_problem(solution[0],solution[1],solution[2])
print "objective values are ",f_global_criterion
```

    Optimization terminated successfully.    (Exit mode 0)
                Current function value: 0.549669334387
                Iterations: 15
                Function evaluations: 15
                Gradient evaluations: 15
     variable values are  [    3.05211179   138.12821175  1000.00729216]
    objective values are  [3.0521117947829333, -138.12821174914697, 381579.84409750014]

## A posteriori methods

* A posteriori methods generate a representation of the Pareto optimal solutions, or the complete set of Pareto optimal solutions
* Benefits
  * The solutions can be visualized for problems with 2 or 3 objectives so the decision making is possible
  * When succesful, they give an understanding of the Pareto front
* Drawbacks
  * Approximating the Pareto optimal set often time-consuming
  * Decision making from a large representation may be very difficut

### The weighting method

Based on solving optimization problem
$$
\min_{x\in S} \sum_{i=1}^kw_if_i(x)
$$
for different weights $w_i\geq0$, $i=1,\ldots,k$.

**The idea is to generate weights evenly and then have evenly spread solutions.**

**An optimal solution the weighted problem is Pareto optimal, if all the weights $w_i$ are $>0$.**

### Application to our problem

```python
import numpy as np
def weighting_method(f,w):
    points = []
    bounds = ((3,10),(50,200),(1000,4000)) #Bounds of the problem
    for wi in w:
        res=minimize(
            #weighted sum
            lambda x: sum(np.array(wi)*np.array(f(x[0],x[1],x[2]))),
            [3,50,1000], method='SLSQP'
            #Jacobian using automatic differentiation
            ,jac=ad.gh(lambda x: sum(np.array(wi)*np.array(f(x[0],x[1],x[2]))))[0]
            #bounds given above
            ,bounds = bounds,options = {'disp':False})
        points.append(res.x)
    return points
```

```python
w = np.random.random((500,3)) #500 random weights
repr = weighting_method(car_problem_normalized,w)
```

```python
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
f_repr_ws = [car_problem(repri[0],repri[1],repri[2]) for repri in repr]
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
ax.scatter([f[0] for f in f_repr_ws],[f[1] for f in f_repr_ws],[f[2] for f in f_repr_ws])
plt.show()
```

### Epsilon-constraint method

Based on solving optimization problem
$$
\begin{align}
\min \quad &f_j(x)\\
\text{s.t. }\quad &x\in S\\
&f_i(x)\leq \epsilon_i \text{ for all }i\neq j
\end{align}
$$
for different bounds $\epsilon_i, $i\neq j$.

**The idea is to generate $\epsilon$ evenly within the bounds of the ideal and nadir vectors and then have evenly spread solutions.**

**A solution is Pareto optimal $x^*$, if it is the solution to the epsilon constraint problem for all $j=1,\ldots,k$ and $\epsilon = f(x^*)$.**

### Application to our problem

```python
import numpy as np
from scipy.optimize import minimize
import ad
def e_constraint_method(f,eps,z_ideal,z_nadir):
    points = []
    for epsi in eps:
        bounds = ((3,epsi[0]*(z_nadir[0]-z_ideal[0])+z_ideal[0]),
                  (-1.*(epsi[1]*(z_nadir[1]-z_ideal[1])+z_ideal[1]),
                   200),(1000,4000)) #Added bounds for two first objectives
        res=minimize(
            #Third objective
            lambda x: f(x[0],x[1],x[2])[2],
            [3,200,1000], method='SLSQP'
            #Jacobian using automatic differentiation
            ,jac=ad.gh(lambda x: f(x[0],x[1],x[2])[2])[0]
            #bounds given above
            ,bounds = bounds,options = {'disp':False})
        if res.success:
            points.append(res.x)
    return points
```

```python
eps = np.random.random((100,2))
repr = e_constraint_method(car_problem_normalized,eps,z_ideal,z_nadir)
```

```python
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
f_repr_eps = [car_problem(repri[0],repri[1],repri[2]) for repri in repr]
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
ax.scatter([f[0] for f in f_repr_eps],[f[1] for f in f_repr_eps],[f[2] for f in f_repr_eps])
plt.show()
```

## Comparison of the weighted sum method and the epsilon constraint method

```python
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
f_repr_eps = [car_problem(repri[0],repri[1],repri[2]) for repri in repr]
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')
#Mark solutions to epsilon constsraint problem using crosses
ax.scatter([f[0] for f in f_repr_eps],[f[1] for f in f_repr_eps],
           [f[2] for f in f_repr_eps],marker='x')
#Mark solutions to weighted sum problem using dots
ax.scatter([f[0] for f in f_repr_ws],[f[1] for f in f_repr_ws],
           [f[2] for f in f_repr_ws],marker='o')
plt.show()
```

**The weighting method can find all the Pareto optimal solutions only, when the objective functions are convex and the feasible set $S$ is convex.**

**The weighting method produces very unevenly spread Pareto optimal solutions, even when the problem is convex. **

**The epsilon constraint method, however, adds constraints to the problem, which may make it much harder to solve**

## A priori methods

* A priori methods ask for preferences from the decision maker, and then find the Pareto optimal solution that best matches these preferences
* Benefits
  * If the decision maker knows what he/she wants and understands the preference information asked for, then application is fast
* Drawbacks
  * The decision maker may not know what he/she wants, because he does not know the Pareto optimal solutions
  * The decision maker may not understand how the preferences he/she gives affect the solutions found

## Achievement scalarizing problem

There are multiple versions of the achievement scalarizing problem, but all of them are based on a refence point.

A reference point
$$z^{ref} = (z^{ref}_1,\ldots,z^{ref}_k)$$
contains preferable values (so-called aspiration levels) for the objectives.

Then the achievement scalarizing problem maps this point and a feasible solution to the multiobjective problem to a scalar (i.e., scalarizes it). One of the most commonly used is
$$
\min_{x\in S}\left( \max_{i=1}^k(f_i(x)-z_i^{ref}) +\rho\sum_{i=1}^nf_i(x)\right)
$$
where $\rho>0$ is a small value. The second part is called an augmentation term.

** The solution to the problem is guaranteed to be Pareto optimal **

** Any (properly) Pareto optimal solution can be found with some reference point**

### Application to our car problem

```python
import numpy as np
from scipy.optimize import minimize
import ad
def asf(f,ref,z_ideal,z_nadir,rho):
    bounds = ((3,10),(50,200),(1000,4000)) #Bounds of the problem
    #Normalizing the reference point
    ref_norm = [(refi-z_ideali)/(z_nadiri-z_ideali)
                for (refi,z_ideali,z_nadiri) in zip(ref,z_ideal,z_nadir)]
    def obj(x):
        return np.max(np.array(f(x[0],x[1],x[2]))-ref_norm)\
           +rho*np.sum(f(x[0],x[1],x[2]))
    res=minimize(
        #Objective function defined above
        obj,
        [3,200,1000], method='SLSQP'
        #Jacobian using automatic differentiation
        ,jac=ad.gh(obj)[0]
        #bounds given above
        ,bounds = bounds,options = {'disp':True, 'ftol': 1e-20,
                                    'maxiter': 1000})
    return res
```

```python
rho = 0.000001
#The reference point for the problem
ref =  [5,-200,300000] #To be added at the class
res = asf(car_problem_normalized,ref,z_ideal,z_nadir,rho)
print "Solution is ",res.x
print "Objective function values are ",car_problem(res.x[0],res.x[1],res.x[2])
```

    Positive directional derivative for linesearch    (Exit mode 8)
                Current function value: 1.36797651381e-07
                Iterations: 100
                Function evaluations: 271
                Gradient evaluations: 96
    Solution is  [  3.00000000e+00   2.00000000e+02   3.60349841e+03]
    Objective function values are  [3.000000000000008, -200.00000000000009, 149609.89516963021]

```python
car_problem(10,149.70923875,3000)
```

## Interactive methods

* Interactive methods iteratively search for the preferred solution with decision maker and optimization alternating
* Benefits
  * Decision maker gets to learn about
    * the available solutions, and
    * how preferences affect the solutions found
  * Computation is less intensive, because no need to generate a large representation of Pareto optimal solutions
* Drawbacks
  * Needs active involvement from the decision maker
  * If the proble is computationally expensive, then the decision maker may need to wait a long time between solutions

## Interactive methods (cont)

**Interactive methods are one of the main research areas here at the Industrial optimization research group**

We will study interactive methods using the IND-NIMBUS software developed at the research group.

The IND-NIMBUS software contains many different interactive methods.

### The synchronous NIMBUS Method

Introduced by Profesors Kaisa Miettinen and Marko Mäkelä in 2006.

Is based on classification of objectives into
* those that should be improved as much as possible,
* those that should be improved until a given limit,
* those that are acceptable at the moment,
* those that can be allowed to worsen until a given limit,
* those that are allowed to move freely at the moment.

Synchronous NIMBUS is based on representing this information as four different single-objective optimization problems that are then solved and solutions are shown to the decision maker.

The Synchronous NIMBUS is implemented as a part of IND-NIMBUS (http://ind-nimbus.it.jyu.fi/) software framework.

There also exists a WWW-NIMBUS (https://wwwnimbus.it.jyu.fi/) web implementation (has not been maintained in approx 10 years, but works).

Lecture 12, Methods for multiobjective optimization.ipynb


# Notebook Description
This notebook helps find the solution to an initial value problem of a linear ODE of arbitrary order.

The user must provide:
 * ODE definition
 * Particular solution (for non-homogeneous ODES)
 * A set of solutions to the associated homogeneous ODE
 * Initial conditions

This document will:
 * automate display
 * calculating the Wronskian determinant to determine if the solutions form a fundamental set
 * fit the general solution to the initial conditions

## Environment Setup

```python
from sympy import *
from IPython.display import display, Markdown
from utils import fit_to_initial_conditions, fit_undetermined_coefficients, wronskian_matrix
from utils import constant_coeff_characteristic_roots, roots_to_homogenous_solutions

init_printing(use_latex = True)
```

## Problem Specification

```python
# Define independent variable, by default t
t = symbols('t')

# define solution symbol, by default y. Y symbol is used for
# display and denotation, while y will hold the solution values
y_symbol = symbols('y')
y_func = Function(y_symbol)(t)

# TODO: Define ODE
ode = diff(y_func, t, 3) - 2*diff(y_func, t, 2) + 2*diff(y_func, t) - y_func

# TODO: Define f(t) for non-homoegeneous ODEs, i.e. the e^x in y''+2y'+y=e^x
f = 0

# TODO: define the initial conditions. Like ode coefficients, the index
# indicates the derivative order
t0 = 0
y0 = [0 ,1 ,2]

```

## Problem Summary
A linear ODE defined as

```python
display(ode)
```

has constant coefficients, so we can find homogenous solutions using the characteristic equation:

```python
root_list = constant_coeff_characteristic_roots(ode, t, y_func)
yh_solutions = roots_to_homogenous_solutions(root_list, t)
```

$r^{3} - 2 r^{2} + 2 r - 1 = 0$

The roots of the characteristic equation are:

$r_{1} = 1$ with multiplicity $1$

$r_{2} = 1 / 2 - \sqrt{3} i / 2$ with multiplicity $1$

$r_{3} = 1 / 2 + \sqrt{3} i / 2$ with multiplicity $1$

Root 1.0 is real with multiplicity 1, resulting in solutions $e^{t}$

Root (0.5-0.8660254037844386j) has multiplicity 1, resulting in solutions

Root (0.5+0.8660254037844386j) is complex with multiplicity 1, and along with its conjugate results in solutions $e^{t / 2} \cos{\left(\sqrt{3} t / 2 \right)}$, $e^{t / 2} \sin{\left(\sqrt{3} t / 2 \right)}$

These solutions form a fundamental set if their wronskian is not identical to 0:

```python
# Display
w_symbol = symbols("W")
display(Markdown("The wronskian determinant " + latex(w_symbol, mode = "inline") + " is calculated as:"))

w_matrix = wronskian_matrix(t, *yh_solutions)
display(Eq(w_symbol, Determinant(w_matrix), evaluate = False))
w = wronskian(yh_solutions, t)
display(Eq(w_symbol, w, evaluate = False))
```

Since the wronskian is not zero, we have a fundamental set and can write our solution in the form

```python
c_symbols = [symbols("c_%i" % (i+1)) for i in range(len(yh_solutions))]
y = sum([c_symbols[i]*yh_solutions[i] for i in range(len(yh_solutions))])
display(Eq(y_symbol, y, evaluate = False))
```

Since we know our initial conditions, we can determine the values of our constants:

```python
c_values = fit_to_initial_conditions(y, t, c_symbols, t0, y0)
```

Evaluating expression $y = c_{1} e^{t} + c_{2} e^{t / 2} \cos{\left(\sqrt{3} t / 2 \right)} + c_{3} e^{t / 2} \sin{\left(\sqrt{3} t / 2 \right)}$ at $t = 0$ results in the following system of equations:

$y^{(0)}(0)$ = $0 = c_{1} + c_{2}$

$y^{(1)}(0)$ = $1 = c_{1} + c_{2} / 2 + \sqrt{3} c_{3} / 2$

$y^{(2)}(0)$ = $2 = c_{1} - c_{2} / 2 + \sqrt{3} c_{3} / 2$

Solving this linear system of equations gives us $c_{1} = 1$, $c_{2} = -1$, $c_{3} = \sqrt{3} / 3$

We can substitute these values into our equation to find our solution:

```python
y = sum([c_values[i]*yh_solutions[i] for i in range(len(yh_solutions))])
display(Eq(y_symbol, y, evaluate = False))
```

```python

```


# Analytical mapping

```python
from sympy.core.containers import Tuple
from sympy import Matrix
from sympy.tensor import IndexedBase
from sympy import symbols, simplify, Symbol
from sympy import expand
from sympy import cos, sin, sqrt, pi
from sympy import lambdify

from numpy import linspace, meshgrid
from matplotlib import pyplot as plt

from sympde.topology import Mapping, DetJacobian
from sympde.topology import Domain

from sympde.core import Constant
from sympde.calculus import grad, dot, inner, cross, rot, curl, div
from sympde.calculus import laplace, hessian, bracket, convect
from sympde.topology import (dx, dy, dz)
from sympde.topology import (dx1, dx2, dx3)
from sympde.topology import ScalarFunctionSpace, VectorFunctionSpace
from sympde.topology import ScalarTestFunction
from sympde.topology import VectorTestFunction
from sympde.topology import LogicalExpr
from sympde.topology import SymbolicExpr
from sympde.topology import element_of
from sympde.topology import IdentityMapping
from sympde.topology import PolarMapping
from sympde.topology import TargetMapping
from sympde.topology import CzarnyMapping
from sympde.topology import CollelaMapping
from sympde.topology import TorusMapping
from sympde.topology import TwistedTargetMapping
```

## Collela mapping

```python
DIM = 2
M = CollelaMapping('M', DIM)
```

```python
x1, x2 = symbols('x1, x2')

constants = ['eps', 'k1', 'k2']
eps, k1, k2 = [Constant(i) for i in constants]
```

```python
x = LogicalExpr(M, M[0])
y = LogicalExpr(M, M[1])
```

```python
fx = lambdify((x1,x2,eps,k1,k2), x, 'numpy')
fy = lambdify((x1,x2,eps,k1,k2), y, 'numpy')
```

```python
eps = 0.1 ; k1 = 1. ; k2 = 1.
```

```python
T1 = linspace(0., 1., 50)
T2 = linspace(0., 1., 50)
```

```python
ax = plt.axes()
ax.set_aspect('equal')

# plot columns
for t1 in T1:
    X = fx(t1, T2, eps, k1, k2)
    Y = fy(t1, T2, eps, k1, k2)
    plt.plot(X, Y, '-b',linewidth=0.7)

# plot lines
for t2 in T2:
    X = fx(T1, t2, eps, k1, k2)
    Y = fy(T1, t2, eps, k1, k2)
    plt.plot(X, Y, '-b', linewidth=0.7)
```

## User mapping

```python
class CustomMapping(Mapping):
    _expressions = {'x': 'A*(x1**2-x2**2)',
                    'y': '2*A*x1*x2'}
```

```python
DIM = 2
M = CustomMapping('M', DIM)
```

```python
x1, x2 = symbols('x1, x2')
A = Constant('A')
```

```python
fx = lambdify((x1,x2,A), LogicalExpr(M, M[0]), 'numpy')
fy = lambdify((x1,x2,A), LogicalExpr(M, M[1]), 'numpy')
```

```python
A = 1.
```

```python
T1 = linspace(-1., 1., 50)
T2 = linspace(-1., 1., 50)
```

```python
ax = plt.axes()
ax.set_aspect('equal')

# plot columns
for t1 in T1:
    X = fx(t1, T2, A)
    Y = fy(t1, T2, A)
    plt.plot(X, Y, '-b',linewidth=0.7)

# plot lines
for t2 in T2:
    X = fx(T1, t2, A)
    Y = fy(T1, t2, A)
    plt.plot(X, Y, '-b', linewidth=0.7)
```

```python

```

```python

```


Computers, Waves, Simulations

Finite-Difference Method - High-Order Taylor Operators

#### This exercise covers the following aspects

* Learn how to define high-order central finite-difference operators
* Investigate the behaviour of the operators with increasing length

#### Basic Equations

The Taylor expansion of $f(x + dx)$ around $x$ is defined as

$$
f(x+dx)=\sum_{n=0}^\infty \frac{f^{(n)}(x)}{n!}dx^{n}
$$

Finite-difference operators can be calculated by seeking weights (here: $a$, $b$, $c$) with which function values have to be multiplied to obtain an interpolation or a derivative. Example:

$$
\begin{align}
a ~ f(x + dx) & \ = \ a ~ \left[ ~  f(x) +  f^{'} (x) dx +  \frac{1}{2!}  f^{''} (x) dx^2   + \dotsc  ~ \right] \\
b ~ f(x) & \  = \ b ~ \left[ ~  f(x)  ~ \right] \\
c ~ f(x - dx) & \ = \ c ~ \left[ ~  f(x) -  f^{'} (x) dx +  \frac{1}{2!}  f^{''} (x) dx^2   - \dotsc  ~ \right]
\end{align}
$$

This can  be expressed in matrix form by comparing coefficients, here seeking a 2nd derivative

$$
\begin{align}
&a  ~~+~~            ~~~~b           &+~~  c & = & 0 \\
&a  ~~\phantom{+}~~ \phantom{b}  &-~~  c & = & 0 \\
&a  ~~\phantom{+}~~ \phantom{b}  &+~~  c & = & \frac{2!}{\mathrm{d}x^2}
\end{align}
$$

which leads to

$$
\begin{pmatrix}
  1 & 1 & 1 \\
  1 & 0 & -1 \\
  1 & 0 & 1
 \end{pmatrix}
\begin{pmatrix}
  a\\
 b \\
 c
 \end{pmatrix}
 =
 \begin{pmatrix}
  0\\
 0 \\
 \frac{2!}{dx^2}
 \end{pmatrix}
$$

and using matrix inversion we obtain

$$
\begin{pmatrix}
a \\
b\\
c
\end{pmatrix}
=
\begin{pmatrix}
\frac{1}{2 \mathrm{d}x^2} \\
 - \frac{2}{2 \mathrm{d}x^2} \\
  \frac{1}{2 \mathrm{d}x^2}
\end{pmatrix}
$$

This is the the well known 3-point operator for the 2nd derivative. This can easily be generalized to higher point operators and higher order derivatives. Below you will find a routine that initializes the system matrix and solves for the Taylor operator.

#### Calculating the Taylor operator

The subroutine `central_difference_coefficients()` initializes the system matrix and solves for the difference weights assuming $dx=1$. It calculates the centered differences using an arbitrary number of coefficients, also for higher derivatives. The weights are defined at $x\pm i dx$ and $i=0,..,(nop-1)/2$, where $nop$ is the length of the operator. Careful! Because it is centered $nop$ has to be an odd number (3,5,...)!

It returns a central finite difference stencil (a vector of length $nop$)  for the `n`th derivative.

```python
# Import libaries
import math
import numpy as np
import matplotlib.pyplot as plt
```

```python
# Define function to calculate Taylor operators
def central_difference_coefficients(nop, n):
    """
    Calculate the central finite difference stencil for an arbitrary number
    of points and an arbitrary order derivative.

    :param nop: The number of points for the stencil. Must be
        an odd number.
    :param n: The derivative order. Must be a positive number.
    """
    m = np.zeros((nop, nop))
    for i in range(nop):
        for j in range(nop):
            dx = j - nop // 2
            m[i, j] = dx ** i

    s = np.zeros(nop)
    s[n] = math.factorial(n)

    # The following statement return oper = inv(m) s
    oper = np.linalg.solve(m, s)
    # Calculate operator
    return oper
```

#### Plot Taylor operators

Investigate graphically the Taylor operators. Increase $nop$ for the first $n=1$ or higher order derivatives. Discuss the results and try to understand the interpolation operator (derivative order $n=0$).

```python
# Calculate and plot Taylor operator

# Give length of operator (odd)
nop = 25
# Give order of derivative (0 - interpolation, 1 - first derivative, 2 - second derivative)
n = 1

# Get operator from routine 'central_difference_coefficients'
oper = central_difference_coefficients(nop, n)
```

```python
# Plot operator
x = np.linspace(-(nop - 1) / 2, (nop - 1) / 2, nop)

# Simple plot with operator
plt.figure(figsize=(10, 4))
plt.plot(x, oper,lw=2,color='blue')
plt.plot(x, oper,lw=2,marker='o',color='blue')
plt.plot(0, 0,lw=2,marker='o',color='red')
#plt.plot (x, nder5-ader, label="Difference", lw=2, ls=":")
plt.title("Taylor Operator with nop =  %i " % nop )
plt.xlabel('x')
plt.ylabel('Operator')
plt.grid()
plt.show()
```

#### Conclusions
* The Taylor operator weights decrease rapidly with distance from the central point
* In practice often 4th order operators are used to calculate space derivatives


Quantum Computing has the potential to revolutionize computing, as it can solve problems that are not possible to solve on a classical computer. This extra ability that quantum computers have is called quantum advantage. To achieve this goal, the world needs passionate and competent end-users: those who know how to apply the technology to their field.

In this challenge you will be exposed, at a high-level, to quantum computing through Qiskit. As current or future users of quantum computers, you need to know what problems are appropriate for quantum computation, how to structure the problem model/inputs so that they are compatible with your chosen algorithm, and how to execute a given algorithm and quantum solution to solve the problem.

This is the first notebook for the IBM Quantum Challenge Africa. Before starting here, ensure you have completed the Week 0 content in preparation for the following exercises.

## Initialization

To ensure the demonstrations and exercises have the required Python modules and libraries, run the following cell before continuing.

```python
# Import auxiliary libraries
import numpy as np

# Import Qiskit
from qiskit import IBMQ, Aer
from qiskit.algorithms import QAOA, VQE, NumPyMinimumEigensolver
from qiskit.algorithms.optimizers import COBYLA
from qiskit.utils import QuantumInstance, algorithm_globals
from qiskit.providers.aer.noise.noise_model import NoiseModel

from qiskit_optimization import QuadraticProgram
from qiskit_optimization.algorithms import MinimumEigenOptimizer
from qiskit_optimization.converters import QuadraticProgramToQubo

import qiskit.test.mock as Fake
```

## Table of Contents

The notebook is structured as follows:

1. Initialization
2. Table of Contents
3. Qiskit and its parts
4. Setting up the Qiskit Environment
5. Quadratic Problems
6. Crop-Yield Problem as a Quadratic Problem
7. Solving the Crop-Yield Problem using Quantum Computing
8. Simulating a Real Quantum Computer for the Crop-Yield Problem

## Qiskit and its parts

Qiskit is divided into multiple modules for different purposes, with Terra at the core of the Qiskit ecosystem. It helps to be familiar with what each module can do and whether you will need to utilize the software contained within them for your specific problem. There are four modules that deal with the application of quantum computing; with tools and algorithms built specifically for their fields.

Have a quick look at the [Qiskit documentation](https://qiskit.org/overview) and this IBM Research [blog post](https://www.ibm.com/blogs/research/2021/04/qiskit-application-modules/) to see what these modules are called. Once you have done so, you can complete the first exercise of the challenge: replace the ficticious module names, in the python cell below, with the correct module names. Though it would be fun to have a Qiskit Gaming module, it has not yet been developed. If you are interested in contributing to the open-source Qiskit community, have a look at the [contribution guide](https://qiskit.org/documentation/contributing_to_qiskit.html).

### Exercise 1a: Qiskit Applications Modules

```python
# Definitely real Qiskit module names
qiskit_module_names = [
    "Qiskit Nature",
    "Qiskit Finance",
    "Qiskit Optimization",
    "Qiskit Machine Learning",
]
```

Run the following python cell to check if you have the correct module names.

```python
from qc_grader import grade_ex1a

grade_ex1a(qiskit_module_names)
```

    /opt/conda/lib/python3.8/site-packages/pyscf/lib/misc.py:47: H5pyDeprecationWarning: Using default_file_mode other than 'r' is deprecated. Pass the mode to h5py.File() instead.
      h5py.get_config().default_file_mode = 'a'

    Submitting your answer for ex1/partA. Please wait...
    Congratulations 🎉! Your answer is correct and has been submitted.

### Qiskit Applications Modules

In this notebook, we will use the `Qiskit Optimization` applications module for a specific problem to illustrate how

1. classical and mathematical problem definitions are represented in Qiskit,
2. some quantum algorithms are defined,
3. to execute a quantum algorithm for a given problem definition,
4. and algorithms and Qiskit problems are executed on real and simulated quantum computers.

We will also be using Qiskit Terra and Aer as they provide the foundation of Qiskit and high-performance quantum simulators respectively.

## Quadratic Problems

Some computational problems can be formulated into quadratic equations such that the minimum of the quadratic equations is the optimal solution, if any exist. These problems are encountered in finance, agriculture, operations and production management, and economics.

Quadratic programming is also used to identify an optimal financial portfolio with minimum risk and optimizing the layout of production components in a factory to minimize the travel distance of resources. This notebook focuses on agriculture as it is a relevant application of quantum computing to problems facing the African continent. However, all of these applications share two common characteristics: the system can be modelled as a quadratic equation and the system variables may be constrained, with their values limited to within a given range.

---

Quadratic problems take on the following structure. Given a vector of $n$ variables $x\in\mathbb{R}^n$, the quadratic function to minimize is as follows.

$$
\begin{align}
\text{minimize}\quad & f\left(x\right)=\frac{1}{2}x^\top{}\mathbf{Q}x + c^\top{}x &\\
\text{subject to}\quad & \mathbf{A}x\leq{}b&\\
& x^\top{}\mathbf{Q}_ix + c_{i}^\top{}x\leq{}r_i,\quad&\forall{}i\in[1,k_q]\\
& l_i\leq{}x_i\leq{}u_i,\quad&\forall{}i\in[1,k_l]\\
\end{align}
$$

$\mathbf{Q}$, $\mathbf{Q}_i$, and $\mathbf{A}$ are $n\times{}n$ symmetric matrices. $c$ and $c_i$ are $n\times{}1$ column vectors. $\mathbf{Q}_i$, $\mathbf{A}$, $c_i$, $l_i$, and $u_i$ define constraints on the variables in $x$. The quadratic equation at the core of the quadratic problem is found by multiplying out the matrices in the minimization function. Though '$\leq{}$' is used in the constraint equations above, any identity relationship may be used for any number of constraints: i.e. "$<$", "$=$", "$>$", "$\geq$", or "$\leq$".

A valid solution to the quadratic must satisfy all conditions for the problem. Examples of some constraints are given below. The first two are linear constraints whereas the third example is a quadratic constraint.

$$ x_1 + x_4 \leq{} 10$$

$$ x_2 - 3x_6 = 10$$

$$x_1x_2 - 4x_3x_4 + x_5 \leq{} 15 $$

Qiskit has Python code that allows you to implement a quadratic problem as a `QuadraticProgram` instance. Though our definition above used matrices to define the coefficients, `QuadraticProgram` allows you to define the objective (function overwhich to minimize) directly. To illustrate how to use `QuadraticProgram`, we will use the following quadratic problem definition, with three integer variables.

$$\begin{align}
\text{minimize}\quad{} & f(x)=(x_1)^2 + (x_2)^2 - x_1x_2 - 6x_3 \\
\text{subject to}\quad{} & x_1 + x_2 = 2             \\
                         & x_2x_3 \geq{} 1           \\
                         & -2 \leq{} x_2 \leq{} 2    \\
                         & -2 \leq{} x_3 \leq{} 4    \\
\end{align}$$

The figure below shows the constraints on $x_1$ and $x_3$, with some simplifcation. The shaded area denotes valid values for $x_1$ and $x_3$, within which $f(x)$ must be minimized.

In the following code, the above quadratic problem is defined as a `QuadraticProgram` instance. Have a look at the [Qiskit documentation for `QuadraticProgram`](https://qiskit.org/documentation/stubs/qiskit.optimization.QuadraticProgram.html), as it can be very useful in helping your understanding of its interface.

The quadratic to minimize, called an objective, is implemented using dictionaries. This allows you, the developer, to explicitly define coefficients for specific variables and terms. The keys in the dictionaries are the variable names identifying a term in $f(x)$. For example, `("x_1","x_2")` is for $x_1x_2$. The values for each item are the coefficients for said terms. Terms that are subtracted in $f(x)$ must have a negative coefficient.

```python
quadprog = QuadraticProgram(name="example 1")
quadprog.integer_var(name="x_1", lowerbound=0, upperbound=4)
quadprog.integer_var(name="x_2", lowerbound=-2, upperbound=2)
quadprog.integer_var(name="x_3", lowerbound=-2, upperbound=4)
quadprog.minimize(
    linear={"x_3": -6},
    quadratic={("x_1", "x_1"): 1, ("x_2", "x_2"): 1, ("x_1", "x_2"): -1},
)
quadprog.linear_constraint(linear={"x_1": 1, "x_2": 1}, sense="=", rhs=2)
quadprog.quadratic_constraint(quadratic={("x_2", "x_3"): 1}, sense=">=", rhs=1)
```

    <qiskit_optimization.problems.quadratic_constraint.QuadraticConstraint at 0x7f1a71272100>

A `QuadraticProgram` can have three types of variables: binary, integer, and continuous. The Qiskit implementation of the algorithms we are going to use currently only support binary and integer variables. There are other algorithms that allow for the simulation of continuous variables, but they are not covered in this notebook. If you want to know more about them though, have a look at this Qiskit tutorial on [algorithms to solve mixed-variable quadratic problems](https://qiskit.org/documentation/tutorials/optimization/5_admm_optimizer.html).

We can visualize the `QuadraticProgram` as an LP string, a portable text-based format used representating the model as a **L**inear **P**rogramming problem.

```python
print(quadprog.export_as_lp_string())
```

    \ This file has been generated by DOcplex
    \ ENCODING=ISO-8859-1
    \Problem name: example 1

    Minimize
     obj: - 6 x_3 + [ 2 x_1^2 - 2 x_1*x_2 + 2 x_2^2 ]/2
    Subject To
     c0: x_1 + x_2 = 2
     q0: [ x_2*x_3 ] >= 1

    Bounds
           x_1 <= 4
     -2 <= x_2 <= 2
     -2 <= x_3 <= 4

    Generals
     x_1 x_2 x_3
    End

Any optimization problem that can be represented as a single second-order equation, in that the greatest exponent of any term is 2, can be transformed into a quadratic problem or program of the form given above. The above example is arbitrary and does not necessarily represent a given real-world problem. The main problem of this notebook focuses on optimizing the yield of a farm, though only the problem definition need be changed to apply this technique to other quadratic problem applications.

## Crop-Yield Problem as a Quadratic Problem

To show how to solve your quadratic program using a quantum computer, we will use two algorithms to solve the crop-yield problem. It is a common need to optimize the crops and management of a farm to reduce risk while increasing profits. One of the big challenges facing Africa and the whole world is how to produce enough food for everyone. The problem here focuses not on profits but on the tonnage of crops harvested. Imagine you have a farm with three hectares of land suitable for farming. You need to choose which crops to plant from a selection of four. Furthermore, you also need to determine how many hectares of each you should plant. The four crops you can plant are wheat, soybeans, maize, and a push-pull crop. The fourth cannot be sold once harvested but it can help increase the yield of the other crops.

            Our beautiful three hectare farm

            Wheat

            Soybeans

            Maize

            Push-Pull

There are three types of farming methods we can use: monocropping, intercropping, and push-pull farming. These are shown below. Monocropping is where only one crop is farmed. This is can make the farm susceptible to disease and pests as the entire yield would be affected. In some instances, growing two different plants nearby each other will increase the yield of both, though sometimes it can decrease the yield. Intercropping is the process where different plants are chosen to _increase_ the yield. Push-Pull crops are pairs of plants that repel pests and attract pests respectively. Integrating them into a larger farm increases the yield of harvested food but with the cost of not necessarily being able to use the harvest of Push-Pull crops as part of the total yield. This is because the Push-Pull crop may not be usable or even edible.

            Monocropping

            Intercropping

            Push-Pull farming

---
Only in certain cases can quadratic programming problems be solved easily using classical problems. In their general sense, they are NP-Hard; a class of problems that is difficult to solve using classical computational methods. In fact, the best classical method to solve these problems involves heuristics, a technique that finds an approximate solution. Quantum Computers have been shown to provide significant speed-up and better scaling for some heuristic problems. The crop-yield problem is a combinatorial problem, in that the solution is a specific combination of input parameters. Though the problem shown here is small enough to solve classically, larger problems become intractable on a classical computer owing to the number of combinations of which to optimize.

Solving the above problem using quantum computing involves three components:

1. Defining the problem
2. Defining the algorithm
3. Executing the algorithm on a backend

Many problems in Qiskit follow this structure as the algorithm you use can typically be swapped for another without significantly redefining your problem. Execution on different backends is the easiest, as long as the device has sufficient resource. The first component is given below, with the second and third in sections 1.5 and 1.6.

### Define the Crop-Yield problem

The following problem is defined for you but the `QuadraticProgram` is not implemented. Your task at the end of this section is to implement the `QuadraticProgram` for the given crop-yield model.

Your farm has three hectares available, $3~ha$, with each crop taking up $0~ha$ or $1~ha$. We define the yield of the farm as a quadratic function where the influence of each crop on eachother is represented by the quadratic coefficients. The variables in this quadratic are the number of hectares of the crop to be planted and the objective function to maximize is the yield of usable crops in tons. Here is the mathematical model for the problem. In this scenario, all crops increase the yield of other crops. However, the problem to solve is which crops to use to achieve the maximum yield.

The farm yield, in tons, is modelled as a quadratic equation, given below, with constraints on the hectares used by each crop and the total hectares available. Each crop is shown using a different symbol, as shown above, representing the number of hectares to be planted of said plant. Note that we can only plant up to 1 hectare of each crop and that our farm is constrained to 3 hectares.

----
#### Non-graphical notation
Here is a non-graphical representation of the above model, if you are struggling to interpret the above graphic.

$$
\begin{align}
    \text{maximize} \quad & 2(\operatorname{Wheat}) + \operatorname{Soybeans} + 4(\operatorname{Maize}) \\
    & + 2.4(\operatorname{Wheat}\times\operatorname{Soybeans}) \\ & + 4(\operatorname{Wheat}\times\operatorname{Maize})\\
    &+ 4(\operatorname{Wheat}\times\operatorname{PushPull}) \\ & + 2(\operatorname{Soybeans}\times\operatorname{Maize}) \\
                          & + (\operatorname{Soybeans}\times\operatorname{PushPull}) \\ & + 5(\operatorname{Maize}\times\operatorname{PushPull})
\end{align}
$$

$$
\begin{align}
\text{subject to} \quad & \operatorname{Wheat} + \operatorname{Soybeans} + \operatorname{Maize} + \operatorname{PushPull} \leq{} 3\\
& 0\leq{}\operatorname{Wheat}\leq{}1\\
& 0\leq{}\operatorname{Soybeans}\leq{}1\\
& 0\leq{}\operatorname{Maize}\leq{}1\\
& 0\leq{}\operatorname{PushPull}\leq{}1
\end{align}
$$

### Exercise 1b: Create Quadratic Program from crop-yield variables

Your first exercise is to create a `QuadraticProgram` that represents the above model. Write your implementation in the `cropyield_quadratic_program` function below. Remember to use the example as a guide, and to look at the [QuadraticProgram documentation](https://qiskit.org/documentation/tutorials/optimization/1_quadratic_program.html?highlight=quadraticprogram) and [Qiskit reference](https://qiskit.org/documentation/stubs/qiskit.optimization.QuadraticProgram.html?highlight=quadraticprogram#qiskit.optimization.QuadraticProgram).

**Note:** Ensure your variables are named `Wheat`, `Soybeans`, `Maize,` and `PushPull`. This is necessary for the grader to work.

```python
def cropyield_quadratic_program():
    cropyield = QuadraticProgram(name="Crop Yield")
    ##############################
    # Put your implementation here
    cropyield.integer_var(name="Wheat",lowerbound=0,upperbound=1)
    cropyield.integer_var(name="Soybeans",lowerbound=0,upperbound=1)
    cropyield.integer_var(name="Maize",lowerbound=0,upperbound=1)
    cropyield.integer_var(name="PushPull",lowerbound=0,upperbound=1)
    cropyield.maximize(
        linear={"Wheat":2,"Soybeans":1,"Maize":4},
        quadratic={("Wheat","Soybeans"):2.4,("Wheat","Maize"):4,("Wheat","PushPull"):4,("Soybeans","Maize"):2,("Soybeans","PushPull"):1,("Maize","PushPull"):5},
    )
    cropyield.linear_constraint(linear={"Wheat":1,"Soybeans":1,"Maize":1,"PushPull":1},sense="<=",rhs=3)
    #
    ##############################
    return cropyield
```

Once you feel your implementation is correct, you can grade your solution in the following cell.

```python
# Execute this cell to grade your solution
from qc_grader import grade_ex1b

cropyield = cropyield_quadratic_program()
grade_ex1b(cropyield)
```

    Submitting your answer for ex1/partB. Please wait...
    Congratulations 🎉! Your answer is correct and has been submitted.

### Converting QuadraticPrograms

If we want to estimate how many qubits this quadratic program requires, we can convert it to an Ising Model and print the `num_qubits` parameter. An [ising model](https://qiskit.org/documentation/apidoc/qiskit.optimization.applications.ising.html?highlight=ising) is a special system model type that is well suited for quantum computing. Though we will not be using an ising model explicitly, the algorithms and Qiskit classes we are using do this conversion internally.

```python
# Estimate the number of qubits required
ising_operations, _ = (
    QuadraticProgramToQubo()
    .convert(
        cropyield,
    )
    .to_ising()
)
print(f"Number of qubits required is {ising_operations.num_qubits}")
```

    Number of qubits required is 6

Even though quadratic programs are widely used in Qiskit, the algorithms we are going to use require binary variables. Qiskit provides an automated method for converting our integer variables into binary variables. The binary-only form is called a _Quadratic Unconstrained Binary Optimization_ problem, or `QUBO`. The conversion is done using `QuadraticProgramToQUBO` from the Qiskit optimization module. Every integer variable, and their associated constraints, are transformed into binary variables.

Run the following code to see how the QUBO version of the cropyield problem looks. Notice how the quadratic becomes longer and more variables are added. This is to account for the bits in each variable, including the constraints. When we run our quantum algorithm to solve this QuadraticProgram, it is converted to a QUBO instance within the Qiskit algorith implementation, implicitly.

```python
QuadraticProgramToQubo().convert(cropyield)
```

    \ This file has been generated by DOcplex
    \ ENCODING=ISO-8859-1
    \Problem name: Crop Yield

    Minimize
     obj: - 160.400000000000 Wheat@0 - 159.400000000000 Soybeans@0
          - 162.400000000000 Maize@0 - 158.400000000000 PushPull@0
          - 158.400000000000 c0@int_slack@0 - 316.800000000000 c0@int_slack@1 + [
          52.800000000000 Wheat@0^2 + 100.800000000000 Wheat@0*Soybeans@0
          + 97.600000000000 Wheat@0*Maize@0 + 97.600000000000 Wheat@0*PushPull@0
          + 105.600000000000 Wheat@0*c0@int_slack@0
          + 211.200000000000 Wheat@0*c0@int_slack@1 + 52.800000000000 Soybeans@0^2
          + 101.600000000000 Soybeans@0*Maize@0
          + 103.600000000000 Soybeans@0*PushPull@0
          + 105.600000000000 Soybeans@0*c0@int_slack@0
          + 211.200000000000 Soybeans@0*c0@int_slack@1 + 52.800000000000 Maize@0^2
          + 95.600000000000 Maize@0*PushPull@0
          + 105.600000000000 Maize@0*c0@int_slack@0
          + 211.200000000000 Maize@0*c0@int_slack@1 + 52.800000000000 PushPull@0^2
          + 105.600000000000 PushPull@0*c0@int_slack@0
          + 211.200000000000 PushPull@0*c0@int_slack@1
          + 52.800000000000 c0@int_slack@0^2
          + 211.200000000000 c0@int_slack@0*c0@int_slack@1
          + 211.200000000000 c0@int_slack@1^2 ]/2 + 237.600000000000
    Subject To

    Bounds
     0 <= Wheat@0 <= 1
     0 <= Soybeans@0 <= 1
     0 <= Maize@0 <= 1
     0 <= PushPull@0 <= 1
     0 <= c0@int_slack@0 <= 1
     0 <= c0@int_slack@1 <= 1

    Binaries
     Wheat@0 Soybeans@0 Maize@0 PushPull@0 c0@int_slack@0 c0@int_slack@1
    End

## Solving the Crop-Yield Problem using a Quantum Computer

There are three ways to _run_ a quantum algorithm using Qiskit:
1. on a simulator locally on your own machine
2. on a simulator hosted in the cloud by IBM
3. on an actual quantum computer accessible through IBM Quantum.

All of these are called backends. In all cases, the _backend_ can easily be swapped for another as long as the simulator or device has appropriate resources (number of qubits etc.). In the code below, we show how to access different backends. We demonstrate this using the local Aer QASM simulator from Qiskit. The Aer QASM simulator models the physical properties of a real quantum computer so you, researchers, and developers can test their quantum computing code and algorithms before running on real devices.

```python
# We will use the Aer provided QASM simulator
backend = Aer.get_backend("qasm_simulator")

# Given we are using a simulator, we will fix the algorithm seed to ensure our results are reproducible
algorithm_globals.random_seed = 271828
```

We would like to compare our quantum solution to that obtained classically. Secondly, we also want to try different algorithms. The following three subsections show how these different methods for solving the Crop-Yield problem are implemented in Qiskit. The two algorithms used are the [_Quantum Approximate Optimization Algorithm_](https://qiskit.org/documentation/stubs/qiskit.algorithms.QAOA.html?highlight=qaoa#qiskit.algorithms.QAOA) `QAOA` and the [_Variational Quantum Eigensolver_](https://qiskit.org/documentation/stubs/qiskit.algorithms.VQE.html?highlight=vqe#qiskit.algorithms.VQE) `VQE`.

Both of these algorithms are hybrid, in that they use a classical _optimizer_ to alter parameters that affect the quantum computation. The VQE algorithm is used to find the lowest eigenvalue of a matrix, which can describe a system to optimize. The QAOA also finds the lowest eigenvalue, but achieves this is a different way to VQE. Both are very popular algorithms, with varying applications and strengths.

### Classical Solution

The classical solution to the crop-yield problem can easily be found using Numpy and Qiskit. The QUBO problem can be solved by finding the minimum eigenvalue of its underlying matrix representation. Fortunately, we don't have to know what this matrix looks like. We only need to pass it to a `MinimumEigensolver` and `MinimumEigenOptimizer`.

The optimizer translates the provided problem into a parameterised representation which is then passed to the solver. By optimizing the paramters, the solver will eventually give the minimum eigenvalue for the parameterized representation and thus the solution to the original problem. Here we use a classical solver from NumPy, the `NumPyMinimumEigensolver`.

```python
def get_classical_solution_for(quadprog: QuadraticProgram):
    # Create solver
    solver = NumPyMinimumEigensolver()

    # Create optimizer for solver
    optimizer = MinimumEigenOptimizer(solver)

    # Return result from optimizer
    return optimizer.solve(quadprog)
```

If we execute the classical method for our crop-yield problem, we get a valid solution that maximises the yield.

```python
# Get classical result
classical_result = get_classical_solution_for(cropyield)

# Format and print result
print("Solution found using the classical method:\n")
print(f"Maximum crop-yield is {classical_result.fval} tons")
print(f"Crops used are: ")

_crops = [v.name for v in cropyield.variables]
for cropIndex, cropHectares in enumerate(classical_result.x):
    print(f"\t{cropHectares} ha of {_crops[cropIndex]}")
```

    Solution found using the classical method:

    Maximum crop-yield is 19.0 tons
    Crops used are:
    	1.0 ha of Wheat
    	0.0 ha of Soybeans
    	1.0 ha of Maize
    	1.0 ha of PushPull

### QAOA Solution

To solve our problem using QAOA, we need only replace the classical_solver with a `QAOA` class instance. Now that we are running a quantum algorithm, we need to tell the solver where to execute the quantum component. We use a `QuantumInstance` to store the backend information. The QAOA is an iterative algorithm, and thus is run multiple times with different internal parameters. The parameters are tuned classically during the optimization step of the computation by `optimizer`. If we leave `optimizer` as `None`, our algorithms will use the default optimization algorithm. To determine how many iterations there are, we define a callback function that runs for each iteration and stores the number of evaluations thus far. At the end of our algorithm execution, we return the result and the number of iterations.

```python
def get_QAOA_solution_for(
    quadprog: QuadraticProgram, quantumInstance: QuantumInstance, optimizer=None,
):
    _eval_count = 0

    def callback(eval_count, parameters, mean, std):
        nonlocal _eval_count
        _eval_count = eval_count

    # Create solver
    solver = QAOA(
        optimizer=optimizer, quantum_instance=quantumInstance, callback=callback,
    )

    # Create optimizer for solver
    optimizer = MinimumEigenOptimizer(solver)

    # Get result from optimizer
    result = optimizer.solve(quadprog)
    return result, _eval_count
```

If we execute the QAOA method for our crop-yield problem, we get the same result as the classical method, showing that 1) the quantum solution is correct and 2) that you now know how to use a quantum algorithm! 🌟

```python
# Create a QuantumInstance
simulator_instance = QuantumInstance(
    backend=backend,
    seed_simulator=algorithm_globals.random_seed,
    seed_transpiler=algorithm_globals.random_seed,
)

# Get QAOA result
qaoa_result, qaoa_eval_count = get_QAOA_solution_for(cropyield, simulator_instance)

# Format and print result
print("Solution found using the QAOA method:\n")
print(f"Maximum crop-yield is {qaoa_result.fval} tons")
print(f"Crops used are: ")
for cropHectares, cropName in zip(qaoa_result.x, qaoa_result.variable_names):
    print(f"\t{cropHectares} ha of {cropName}")

print(f"\nThe solution was found within {qaoa_eval_count} evaluations of QAOA.")
```

    Solution found using the QAOA method:

    Maximum crop-yield is 19.0 tons
    Crops used are:
    	1.0 ha of Wheat
    	0.0 ha of Soybeans
    	1.0 ha of Maize
    	1.0 ha of PushPull

    The solution was found within 3 evaluations of QAOA.

### VQE Solution

The `VQE` algorithm works in a very similar way to the `QAOA`. Not only in a mathematical modelling and algorithmic perspective, but also programmatically. There is a quantum solver and a classical optimizer. The `VQE` instance is also iterative, and so we can measure how many iterations are needed to find a solution to the Crop-Yield problem.

```python
def get_VQE_solution_for(
    quadprog: QuadraticProgram, quantumInstance: QuantumInstance, optimizer=None,
):
    _eval_count = 0

    def callback(eval_count, parameters, mean, std):
        nonlocal _eval_count
        _eval_count = eval_count

    # Create solver and optimizer
    solver = VQE(
        optimizer=optimizer, quantum_instance=quantumInstance, callback=callback
    )

    # Create optimizer for solver
    optimizer = MinimumEigenOptimizer(solver)

    # Get result from optimizer
    result = optimizer.solve(quadprog)
    return result, _eval_count
```

And we should get the exact same answer as before.

```python
# Create a QuantumInstance
simulator_instance = QuantumInstance(
    backend=backend,
    seed_simulator=algorithm_globals.random_seed,
    seed_transpiler=algorithm_globals.random_seed,
)

# Get VQE result
vqe_result, vqe_eval_count = get_VQE_solution_for(cropyield, simulator_instance)

# Format and print result
print("Solution found using the VQE method:\n")
print(f"Maximum crop-yield is {vqe_result.fval} tons")
print(f"Crops used are: ")
for cropHectares, cropName in zip(vqe_result.x, vqe_result.variable_names):
    print(f"\t{cropHectares} ha of {cropName}")

print(f"\nThe solution was found within {vqe_eval_count} evaluations of VQE")
```

    Solution found using the VQE method:

    Maximum crop-yield is 19.0 tons
    Crops used are:
    	1.0 ha of Wheat
    	0.0 ha of Soybeans
    	1.0 ha of Maize
    	1.0 ha of PushPull

    The solution was found within 25 evaluations of VQE

### Exercise 1c: Classical and Quantum Computational Results

From the above computations you received six results, the maximum crop-yield and the number of evaluations for three different methods. The maximum yield values should be the same. If your yield values aren't all the same, rerun the algorithms. Sometimes the optimization process can miss the correct answer because of the randomness used to initialize the algorithm parameters.

Run the code cell below to see if the maximum yields you computed are correct.

```python
from qc_grader import grade_ex1c

max_yield_qaoa = qaoa_result.fval
max_yield_vqe = vqe_result.fval

grade_ex1c(tonnage_qaoa=max_yield_qaoa, tonnage_vqe=max_yield_vqe)
```

    Submitting your answer for ex1/partC. Please wait...
    Congratulations 🎉! Your answer is correct and has been submitted.

_You could always verify your result with the classical method, though this is only possible here given the size of the problem. Larger problems become more difficult to verify._

## Simulating a Real Quantum Computer for the Crop-Yield Problem

Sometimes one would want to _simulate_ a real quantum computer to see how the actual hardware may impact the performance of the algorithm. All quantum computers have an underlying architecture, different noise characeristics, and error rates. These three aspects impact how well the algorithm can perform on a given deivce. To test the impact a given quantum computer has on the QAOA instance, we can utilize a _fake_ instance of the device in Qiskit to tell our simulator what parameters to use. In this example we will be simulating `ibmq_johannesburg`, a device named after the city of Johannesburg in South Africa.

```python
fake_device = Fake.FakeJohannesburg()
```

We can inspect what this device _looks_ like using the Qiskit Jupyter tools, shown below. You do not need to know about this structure to execute quantum programs on a device, but it is useful to visualize the parameters.

```python
import qiskit.tools.jupyter

fake_device
```

    VBox(children=(HTML(value="<h1 style='color:#ffffff;background-color:#000000;padding-top: 1%;padding-bottom: 1…

    <FakeJohannesburg('fake_johannesburg')>

The three aforementioned components of a quantum computer are represented as a noise model, coupling map, and the basis gate set. The noise model is a representation of how the noise and errors in the computer behave. The coupling map and basis gate set are core to the architecture of the device. The coupling map represents how the physical qubits can interact whereas the basis gate set is analogous to the set of fundamental computatonal instructions we can use. You can see the coupling map in the above widget as the lines connecting each qubit in the architecture diagram.

To simulate `ibmq_johannesburg`, we must pass these three components to our Aer simulator.

```python
# Create the noise model, which contains the basis gate set
noise_model = NoiseModel.from_backend(fake_device)

# Get the coupling map
coupling_map = fake_device.configuration().coupling_map
```

Next we create a new `QuantumInstance` with these parameters

```python
fake_instance = QuantumInstance(
    backend=backend,
    basis_gates=noise_model.basis_gates,
    coupling_map=coupling_map,
    noise_model=noise_model,
    seed_simulator=algorithm_globals.random_seed,
    seed_transpiler=algorithm_globals.random_seed,
)
```

We can then execute the `QAOA` from before on this new _fake_ quantum device.

```python
# Get QAOA result
qaoa_result, qaoa_eval_count = get_QAOA_solution_for(cropyield, fake_instance)

# Format and print result
print("Solution found using the QAOA method:\n")
print(f"Maximum crop-yield is {qaoa_result.fval} tons")
print(f"Crops used are: ")
for cropHectares, cropName in zip(qaoa_result.x, qaoa_result.variable_names):
    print(f"\t{cropHectares} ha of {cropName}")

print(f"\nThe solution was found within {qaoa_eval_count} evaluations of QAOA.")
```

    Solution found using the QAOA method:

    Maximum crop-yield is 19.0 tons
    Crops used are:
    	1.0 ha of Wheat
    	0.0 ha of Soybeans
    	1.0 ha of Maize
    	1.0 ha of PushPull

    The solution was found within 3 evaluations of QAOA.

### Scaling of the Quantum Solution vs Classical

When we created our quadratic program for the crop-yield problem, we saw that the Ising Model required 6 qubits. We had constrained our problem such that we could only plant up to 1 hectare per crop. However, we could change the model so that we can plot 3 hectares per crop, upto our maximum available farm area of 3 hectares.

How many qubits would this ising model require?

---

Furthermore, what if we had more land to farm? We know that this problem is NP-Hard and thus classical solutions are mostly found using heuristics. This is the core reason why quantum computers are promising to solve these kinds of problems. But what are the resource requirements for the quantum solution, with a larger farm and crops that can be planted in more hectares?

To illustrate this, we've provided a function that returns the number of qubits required by the underlying Ising Model for the Crop-Yield Problem. We then see the estimated number of qubits needed for different problem parameters. Feel free to modify the variables being used to see how the qubit resource requirements change.

```python
# Function to estimate the number of qubits required
def estimate_number_of_qubits_required_for(max_hectares_per_crop, hectares_available):
    return int(
        4 * np.ceil(np.log2(max_hectares_per_crop + 1))
        + np.ceil(np.log2(hectares_available + 1))
    )

# Our new problem parameters
hectares_available = 10
max_hectares_per_crop = 10

# Retrieving the number of qubits required
number_of_qubits_required = estimate_number_of_qubits_required_for(
    max_hectares_per_crop=max_hectares_per_crop, hectares_available=hectares_available
)

print(
    f"Optimizing a {hectares_available} ha farm with each crop taking up to {max_hectares_per_crop} ha each,",
    f"the computation is estimated to require {number_of_qubits_required} qubits.",
)
```

    Optimizing a 10 ha farm with each crop taking up to 10 ha each, the computation is estimated to require 20 qubits.

The number of qubits required is related to the constraints in the quadratic program and how the integer variables are converted to binary variables. In fact, the scaling of the number of qubits, as a function of the hectares available, is logarithmic in nature; owing to this conversion.

## Running on real quantum hardware

To use the IBM Quantum platform is easy. First you need to load the account you enabled in the week 0 content. If you didn't complete this, follow this [quick guide](https://quantum-computing.ibm.com/lab/docs/iql/manage/account/ibmq) on connecting your IBM Quantum account with Qiskit in python and Jupyter.

```python
IBMQ.load_account()
```

    <AccountProvider for IBMQ(hub='ibm-q', group='open', project='main')>

IBM Quantum backends are accessed through a provider, which manages the devices to which you have access. For this challenge, you have access to the new `ibm_perth` quantum computer! Typically, you would find your provider details under your [IBM Quantum account details](https://quantum-computing.ibm.com/account). Under your account you can see the different hubs, groups, and projects you are a part of. Qiskit allows us to retrieve a provider using just the hub, group, and project as follows:

```python
provider = IBMQ.get_provider(hub="ibm-q", group="open", project="main")
```

However, because we have given you special access for this challenge, we are going to retrive the provider using a different method. Execute the code cell below to retrieve the correct provider.

```python
provider = None
for prov in IBMQ.providers():
    if (
        "iqc-africa-21" in prov.credentials.hub
        and "q-challenge" in prov.credentials.group
        and "ex1" in prov.credentials.project
    ):
        # Correct provider found
        provider = prov

if provider == None:
    print("ERROR: The expected provider was not found!")
else:
    print("Yay! The expected provider was found!")
```

    Yay! The expected provider was found!

If the above code cell returned an error, you may not yet have access to the real quantum computer. The list of participants is updated daily, so you may have to wait some time before the correct provider appears. If you need assistance, send a message to the challenge Slack channel [#challenge-africa-2021](https://qiskit.slack.com/archives/C02C8MKP153) and make sure to tag the admin team with [@africa_admin](#).

------

To retrieve a backend from the provider, one needs only request it by name. For example, we can request `ibm_perth` as follows.

```python
backend_real = provider.get_backend("ibm_perth")
```

We can also list all backends available through a given backend. In this example we use the _open_ provider as it has access to all open devices and simulators, instead of the limited few for the challenge.

```python
for _backend in IBMQ.get_provider(hub='ibm-q', group='open', project='main').backends():
    print(_backend.name())
```

    ibmq_qasm_simulator
    ibmq_armonk
    ibmq_santiago
    ibmq_bogota
    ibmq_lima
    ibmq_belem
    ibmq_quito
    simulator_statevector
    simulator_mps
    simulator_extended_stabilizer
    simulator_stabilizer
    ibmq_manila

Qiskit provides visual tools to view backend information in a jupyter notebook. To accomplish this, one needs to import the `jupyter` submodule and call the appropriate _magic comment_. With `qiskit_backend_overview` you can view all devices accessible by the current IBMQ account. Notice how it does not include simulators. Furthermore, you should see that all devices available through the _open group_ have at most 5 qubits. This is a problem for solving the crop-yield problem we created earlier, as we showed it requires 6 qubits.

To demonstrate how a real quantum device is used, a smaller `QuadraticProgram` is provided, requiring a maximum of 4 qubits.

```python
%qiskit_backend_overview
```

    VBox(children=(HTML(value="<h2 style ='color:#ffffff; background-color:#000000;padding-top: 1%; padding-bottom…

If you want access to larger and more sophisticated quantum computers through IBM, see if your university or company is part of the [IBM Quantum Network](https://www.ibm.com/quantum-computing/network/members/). Researchers are institutions that are part of the [African Research Universities Alliance (ARUA)](https://arua.org.za/) can also apply for access through the University of the Witwatersrand, in South Africa; which is a member of the IBM Quantum Network. If you are a researcher, you can also apply for access through the [IBM Quantum Researchers Program](https://www.ibm.com/quantum-computing/researchers-program/). If you're a student at a highschool or university, you can ask your teachers or lecturers to apply for access through the [IBM Quantum Educators Program](https://www.ibm.com/quantum-computing/educators-program/).

Given that we have imported `qiskit.tools.jupyter`, Jupyter will now display a helpful widget when an IBMQ backend is displayed. We do not have to use a _magic comment_ here as the jupyter submodule defines how some variables are displayed in a jupyter notebook, without requiring a _magic comment_.

```python
backend_real
```

    VBox(children=(HTML(value="<h1 style='color:#ffffff;background-color:#000000;padding-top: 1%;padding-bottom: 1…

    <IBMQBackend('ibm_perth') from IBMQ(hub='iqc-africa-21-8', group='q-challenge-2', project='ex1-recaP1M0oevDjXeQf')>

We can create a new `QuantumInstance` object to contain our real quantum computer backend, similar to how we created one to manage our simulator. For real devices, there is an extra parameter we can set: `shots`. The output of a quantum computing algorithm is probabilistic. Therefore, we must execute the quantum computation multiple times, sampling the outputs to estimate their probabilities. The number of `shots` is the number of executions of the quantum computation. Here we set out `QuantumInstance` to use the least busy backend with 2048 shots.

```python
quantum_instance_real = QuantumInstance(backend_real, shots=2048)
```

The VQE algorithm and QAOA are iterative, meaning that they incorporate a classical-quantum loop which repeats certain computations, _hopefully_ converging to a valid solution. In each iteration, or evaluation, the quantum backend will execute the quantum operations 2048 times. Each shot is quite fast, so we do not have to worry about a significant increase in processing time by using more shots.

----
We now define our small crop-yield problem, which only requires 4 qubits. In this example, only Wheat and Maize are used. The model is altered to illustrate the impact of growing too much of a single crop, with the yield decreasing as the number of hectares of a single crop is increased. However, utilizing both wheat and maize increases yield, showing the benefits of intercropping.

**NB:** The maximum number of hectares available is 4, but given that the model would never exceed this limit, the linear constraint defining the maximum number of hectares is not included. This reduces the number of qubits required from 6 to 4.

```python
# Create a small crop-yield example quadratic program
cropyield_small = QuadraticProgram(name="Small Crop-Yield")

# Add two variables, indicating whether we grow 0, 1, or 2 hectares for two different crops
cropyield_small.integer_var(lowerbound=0, upperbound=2, name="Wheat")
cropyield_small.integer_var(lowerbound=0, upperbound=2, name="Maize")

# Add the objective function defining the yield in tonnes
cropyield_small.maximize(
    linear={"Wheat": 3, "Maize": 3},
    quadratic={("Maize", "Wheat"): 1, ("Maize", "Maize"): -2, ("Wheat", "Wheat"): -2},
)

# This linear constraint is not used as the model never reaches this. This is because the
# sum of the upperbounds on both variables is 4 already. If this constraint is applied, the
# model would require 6 qubits instead of 4.
# cropyield_small.linear_constraint(linear={"Wheat": 1, "Maize": 1}, sense="<=", rhs=4)

print(cropyield_small)
```

    \ This file has been generated by DOcplex
    \ ENCODING=ISO-8859-1
    \Problem name: Small Crop-Yield

    Maximize
     obj: 3 Wheat + 3 Maize + [ - 4 Wheat^2 + 2 Wheat*Maize - 4 Maize^2 ]/2
    Subject To

    Bounds
           Wheat <= 2
           Maize <= 2

    Generals
     Wheat Maize
    End

Here we verify that our small crop-yield problem requires only 4 qubits.

```python
# Estimate the number of qubits required
ising_operations_small, _ = (
    QuadraticProgramToQubo()
    .convert(
        cropyield_small,
    )
    .to_ising()
)
print(f"Number of qubits required is {ising_operations_small.num_qubits}")
```

    Number of qubits required is 4

### Exercise 1d: Submitting a job to a real quantum computer

Now that we know the problem can be run on our chosen device, we can execute the VQE algorithm. In this case we will set the optimizer, with a maximum number of iterations of 1, so that we do not occupy the device for too long. Our answer will be incorrect, but we only want to see how to send a quantum program to a real quantum computer.

```python
# Create our optimizer
optimizer = COBYLA(maxiter=1)

## Get result from real device with VQE
vqe_result_real, vqe_eval_count_real = get_VQE_solution_for(
    cropyield_small, quantum_instance_real, optimizer=optimizer
)
```

    /opt/conda/lib/python3.8/site-packages/qiskit/utils/run_circuits.py:695: UserWarning: max_credits is not a recognized runtime option and may be ignored by the backend.
      return backend.run(circuits, **run_kwargs)

Qiskit uses `jobs` to track computations and their results on remote devices and simulators. We can query the backend object for the jobs it received, which would be those created by the VQE algorithm.

```python
# Retrieve the VQE job sent
job_real = backend_real.jobs()[0]

print(f"VQE job created at {job_real.creation_date()} and has a job id of {job_real.job_id()}")
```

Put the job id for your the above job into the cell below and execute the code cell.

```python
from qc_grader import grade_ex1d

job_id = '613f173001ea110574c30e73'

grade_ex1d(job_id)
```

You have now completed the first lab of the IBM Quantum Challenge Africa 2021! Make sure that you are on the [Qiskit Slack channel](https://ibm.co/Africa_Slack) so you can ask questions and talk to other participants. There are two more labs left in the challenge, which are more difficult than this introductory lab, covering quantum computing for finance and HIV.

## References

[1] A. A. Nel, ‘Crop rotation in the summer rainfall area of South Africa’, South African Journal of Plant and Soil, vol. 22, no. 4, pp. 274–278, Jan. 2005, doi: 10.1080/02571862.2005.10634721.

[2] H. Ritchie and M. Roser, ‘Crop yields’, Our World in Data, 2013, [Online]. Available: https://ourworldindata.org/crop-yields.

[3] G. Brion, ‘Controlling Pests with Plants: The power of intercropping’, UVM Food Feed, Jan. 09, 2014. https://learn.uvm.edu/foodsystemsblog/2014/01/09/controlling-pests-with-plants-the-power-of-intercropping/ (accessed Feb. 15, 2021).

[4] N. O. Ogot, J. O. Pittchar, C. A. O. Midega, and Z. R. Khan, ‘Attributes of push-pull technology in enhancing food and nutrition security’, African Journal of Agriculture and Food Security, vol. 6, pp. 229–242, Mar. 2018.

```python
import qiskit.tools.jupyter

%qiskit_version_table
%qiskit_copyright
```


# 16 PDEs: Solution with Time Stepping

## Heat Equation
The **heat equation** can be derived from Fourier's law and energy conservation (see the [lecture notes on the heat equation (PDF)](https://github.com/ASU-CompMethodsPhysics-PHY494/PHY494-resources/blob/master/16_PDEs/16_PDEs_LectureNotes_HeatEquation.pdf))

$$
\frac{\partial T(\mathbf{x}, t)}{\partial t} = \frac{K}{C\rho} \nabla^2 T(\mathbf{x}, t),
$$

## Problem: insulated metal bar (1D heat equation)
A metal bar of length $L$ is insulated along it lengths and held at 0ºC at its ends. Initially, the whole bar is at 100ºC. Calculate $T(x, t)$ for $t>0$.

### Analytic solution
Solve by separation of variables and power series: The general solution that obeys the boundary conditions $T(0, t) = T(L, t) = 0$ is

$$
T(x, t) = \sum_{n=1}^{+\infty} A_n \sin(k_n x)\, \exp\left(-\frac{k_n^2 K t}{C\rho}\right), \quad k_n = \frac{n\pi}{L}
$$

The specific solution that satisfies $T(x, 0) = T_0 = 100^\circ\text{C}$ leads to $A_n = 4 T_0/n\pi$ for $n$ odd:

$$
T(x, t) = \sum_{n=1,3,5,\dots}^{+\infty} \frac{4 T_0}{n \pi} \sin(k_n x)\, \exp\left(-\frac{k_n^2 K t}{C\rho}\right)
$$

```python
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
plt.style.use('ggplot')
```

```python
def T_bar(x, t, T0, L, K=237, C=900, rho=2700, nmax=1000):
    T = np.zeros_like(x)
    eta = K / (C*rho)
    for n in range(1, nmax, 2):
        kn = n*np.pi/L
        T += 4*T0/(np.pi * n) * np.sin(kn*x) * np.exp(-kn*kn * eta * t)
    return T
```

```python
T0 = 100.
L = 1.0
X = np.linspace(0, L, 100)
for t in np.linspace(0, 3000, 50):
    plt.plot(X, T_bar(X, t, T0, L))
plt.xlabel(r"$x$ (m)")
plt.ylabel(r"$T$ ($^\circ$C)");
```

### Numerical solution: Leap frog
Discretize (finite difference):

For the time domain we only have the initial values so we use a simple forward difference for the time derivative:

$$
\frac{\partial T(x,t)}{\partial t} \approx \frac{T(x, t+\Delta t) - T(x, t)}{\Delta t}
$$

For the spatial derivative we have initially all values so we can use the more accurate central difference approximation:

$$
\frac{\partial^2 T(x, t)}{\partial x^2} \approx \frac{T(x+\Delta x, t) + T(x-\Delta x, t) - 2 T(x, t)}{\Delta x^2}
$$

Thus, the heat equation can be written as the finite difference equation

$$
\frac{T(x, t+\Delta t) - T(x, t)}{\Delta t} = \frac{K}{C\rho} \frac{T(x+\Delta x, t) + T(x-\Delta x, t) - 2 T(x, t)}{\Delta x^2}
$$

which can be reordered so that the RHS contains only known terms and the LHS future terms. Index $i$ is the spatial index, and $j$ the time index: $x = x_0 + i \Delta x$, $t = t_0 + j \Delta t$.

$$
T_{i, j+1} = (1 - 2\eta) T_{i,j} + \eta(T_{i+1,j} + T_{i-1, j}), \quad \eta := \frac{K \Delta t}{C \rho \Delta x^2}
$$

Thus we can step forward in time ("leap frog"), using only known values.

### Solve the 1D heat equation numerically for an iron bar
* $K = 237$ W/mK
* $C = 900$ J/K
* $\rho = 2700$ kg/m3
* $L = 1$ m
* $T_0 = 373$ K and $T_b = 273$ K
* $T(x, 0) = T_0$ and $T(0, t) = T(L, t) = T_b$

#### Key considerations

The key line is the computation of the new temperature field at time step $j+1$ from the temperature distribution at time step $j$. It can be written purely with numpy array operations (see last lecture!):

```python
T[1:-1] = (1 - 2*eta) * T[1:-1] + eta * (T[2:] + T[:-2])
```

Note that the range operator `T[start:end]` *excludes* `end`, so in order to include `T[1], T[2], ..., T[-2]` (but not the rightmost `T[-1]`) we have to use `T[1:-1]`.

The *boundary conditions* are fixed for all times:
```python
T[0] = T[-1] = Tb
```

The *initial conditions* (at time step `j=0`)
```python
T[1:-1] = T0
```
are only used to compute the distribution of temperatures at the next step `j=1`.

#### Solution

```python
import numpy as np

import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
%matplotlib notebook
```

For HTML/nbviewer output, use inline:

```python
%matplotlib inline
```

```python
L_rod = 1.    # m
t_max = 3000. # s

Dx = 0.02   # m
Dt = 2    # s

Nx = int(L_rod // Dx)
Nt = int(t_max // Dt)

Kappa = 237 # W/(m K)
CHeat = 900 # J/K
rho = 2700  # kg/m^3

T0 = 373 # K
Tb = 273 # K

eta = Kappa * Dt / (CHeat * rho * Dx**2)
eta2 = 1 - 2*eta

step = 20  # plot solution every n steps

print("Nx = {0}, Nt = {1}".format(Nx, Nt))
print("eta = {0}".format(eta))

T = np.zeros(Nx)
T_plot = np.zeros((Nt//step + 1, Nx))

# initial conditions
T[1:-1] = T0
# boundary conditions
T[0] = T[-1] = Tb

t_index = 0
T_plot[t_index, :] = T
for jt in range(1, Nt):
    T[1:-1] = eta2 * T[1:-1] + eta*(T[2:] + T[:-2])
    if jt % step == 0 or jt == Nt-1:
        t_index += 1
        T_plot[t_index, :] = T
        print("Iteration {0:5d}".format(jt), end="\r")
else:
    print("Completed {0:5d} iterations: t={1} s".format(jt, jt*Dt))
```

    Nx = 49, Nt = 1500
    eta = 0.4876543209876543
    Completed  1499 iterations: t=2998 s

#### Visualization
Visualize (you can use the code as is).

Note how we are making the plot use proper units by mutiplying with `Dt * step` and `Dx`.

```python
X, Y = np.meshgrid(range(T_plot.shape[0]), range(T_plot.shape[1]))
Z = T_plot[X, Y]
fig = plt.figure()
ax = fig.add_subplot(111, projection="3d")
ax.plot_wireframe(X*Dt*step, Y*Dx, Z)
ax.set_xlabel(r"time $t$ (s)")
ax.set_ylabel(r"position $x$ (m)")
ax.set_zlabel(r"temperature $T$ (K)")
fig.tight_layout()
```

2D as above for the analytical solution…

```python
X = Dx * np.arange(T_plot.shape[1])
plt.plot(X, T_plot.T)
plt.xlabel(r"$x$ (m)")
plt.ylabel(r"$T$ (K)");
```

#### Slower solution

I benchmarked this slow solution at 89.7 ms and the fast solution at 14.8 ms (commented out all `print`) so the explicit loop is not that much worse (probably because the overhead on array copying etc is high).

```python
L_rod = 1.    # m
t_max = 3000. # s

Dx = 0.02   # m
Dt = 2    # s

Nx = int(L_rod // Dx)
Nt = int(t_max // Dt)

Kappa = 237 # W/(m K)
CHeat = 900 # J/K
rho = 2700  # kg/m^3

T0 = 373 # K
Tb = 273 # K

eta = Kappa * Dt / (CHeat * rho * Dx**2)
eta2 = 1 - 2*eta

step = 20  # plot solution every n steps

print("Nx = {0}, Nt = {1}".format(Nx, Nt))
print("eta = {0}".format(eta))

T = np.zeros(Nx)
T_new = np.zeros_like(T)
T_plot = np.zeros((int(np.ceil(Nt/step)) + 1, Nx))

# initial conditions
T[1:-1] = T0
# boundary conditions
T[0] = T[-1] = Tb

T_new[:] = T

t_index = 0
T_plot[t_index, :] = T
for jt in range(1, Nt):
    # T[1:-1] = eta2 * T[1:-1] + eta*(T[2:] + T[:-2])
    for ix in range(1, Nx-1):
        T_new[ix] = eta2 * T[ix] + eta*(T[ix+1] + T[ix-1])
    T[:] = T_new
    if jt % step == 0 or jt == Nt-1:
        t_index += 1
        T_plot[t_index, :] = T
        print("Iteration {0:5d}".format(jt), end="\r")
else:
    print("Completed {0:5d} iterations: t={1} s".format(jt, jt*Dt))
```

    Nx = 49, Nt = 1500
    eta = 0.4876543209876543
    Completed  1499 iterations: t=2998 s

```python
X, Y = np.meshgrid(range(T_plot.shape[0]), range(T_plot.shape[1]))
Z = T_plot[X, Y]
fig = plt.figure()
ax = fig.add_subplot(111, projection="3d")
ax.plot_wireframe(X*Dt*step, Y*Dx, Z)
ax.set_xlabel(r"time $t$ (s)")
ax.set_ylabel(r"position $x$ (m)")
ax.set_zlabel(r"temperature $T$ (K)")
fig.tight_layout()
```

## Stability of the solution

### Empirical investigation of the stability
Investigate the solution for different values of `Dt` and `Dx`. Can you discern patters for stable/unstable solutions?

Report `Dt`, `Dx`, and `eta`
* for 3 stable solutions
* for 3 unstable solutions

```python
def calculate_T(L_rod=1, t_max=3000, Dx=0.02, Dt=2, T0=373, Tb=273,
               step=20):
    Nx = int(L_rod // Dx)
    Nt = int(t_max // Dt)

    Kappa = 237 # W/(m K)
    CHeat = 900 # J/K
    rho = 2700  # kg/m^3

    eta = Kappa * Dt / (CHeat * rho * Dx**2)
    eta2 = 1 - 2*eta

    print("Nx = {0}, Nt = {1}".format(Nx, Nt))
    print("eta = {0}".format(eta))

    T = np.zeros(Nx)
    T_plot = np.zeros((int(np.ceil(Nt/step)) + 1, Nx))

    # initial conditions
    T[1:-1] = T0
    # boundary conditions
    T[0] = T[-1] = Tb

    t_index = 0
    T_plot[t_index, :] = T
    for jt in range(1, Nt):
        T[1:-1] = eta2 * T[1:-1] + eta*(T[2:] + T[:-2])
        if jt % step == 0 or jt == Nt-1:
            t_index += 1
            T_plot[t_index, :] = T
            print("Iteration {0:5d}".format(jt), end="\r")
    else:
        print("Completed {0:5d} iterations: t={1} s".format(jt, jt*Dt))
    return T_plot

def plot_T(T_plot, Dx, Dt, step):
    X, Y = np.meshgrid(range(T_plot.shape[0]), range(T_plot.shape[1]))
    Z = T_plot[X, Y]
    fig = plt.figure()
    ax = fig.add_subplot(111, projection="3d")
    ax.plot_wireframe(X*Dt*step, Y*Dx, Z)
    ax.set_xlabel(r"time $t$ (s)")
    ax.set_ylabel(r"position $x$ (m)")
    ax.set_zlabel(r"temperature $T$ (K)")
    fig.tight_layout()
    return ax
```

```python
T_plot = calculate_T(Dx=0.01, Dt=2, step=20)
plot_T(T_plot, 0.01, 2, 20)
```

Note that *decreasing* the value of $\Delta x$ made the solution *unstable*. This is strange, we have gotten used to the idea that working on a finer mesh will increase the detail (until we hit round-off error) and just become computationally more expensive. But here the algorithm suddenly becomes unstable (and it is not just round-off).

For certain combination of values of $\Delta t$ and $\Delta x$ the solution become unstable. Empirically, bigger $\eta$ leads to instability. (In fact, $\eta \geq \frac{1}{2}$ is unstable for the leapfrog algorithm as we will see.)

### Von Neumann stability analysis

If the difference equation solution diverges then we *know* that we have a bad approximation to the original PDE.

Von Neumann stability analysis starts from the assumption that *eigenmodes* of the difference equation can be written as

$$
T_{m,j} = \xi(k)^j e^{ikm\Delta x}, \quad t=j\Delta t,\ x=m\Delta x
$$

with the unknown wave vectors $k=2\pi/\lambda$ and unknown complex functions – the *amplification factors* – $\xi(k)$.

Solutions of the difference equation can be written as linear superpositions of these basis functions. But they are only stable if the eigenmodes are stable, i.e., will not grow in time (with $j$). This is the case when

$$
|\xi(k)| < 1
$$

for all $k$.

Insert the eigenmodes into the finite difference equation

$$
T_{m, j+1} = (1 - 2\eta) T_{m,j} + \eta(T_{m+1,j} + T_{m-1, j})
$$

to obtain

\begin{align}
\xi(k)^{j+1} e^{ikm\Delta x} &= (1 - 2\eta) \xi(k)^{j} e^{ikm\Delta x}
    + \eta(\xi(k)^{j} e^{ik(m+1)\Delta x} + \xi(k)^{j} e^{ik(m-1)\Delta x})\\
\xi(k) &= (1 - 2\eta) + \eta(e^{ik\Delta x} + e^{-ik\Delta x})\\
\xi(k) &= 1 - 2\eta + 2\eta \cos k\Delta x\\
\xi(k) &= 1 + 2\eta\big(\cos k\Delta x - 1\big)
\end{align}

For $|\xi(k)| < 1$ (and all possible $k$):

\begin{align}
|\xi(k)| < 1 \quad &\Leftrightarrow \quad \xi^2(k) < 1\\
(1 + 2y)^2 = 1 + 4y + 4y^2 &< 1 \quad \text{with}\ \  y = \eta(\cos k\Delta x - 1)\\
y(1 + y) &< 0 \quad \Leftrightarrow \quad -1 < y < 0\\
\eta(\cos k\Delta x - 1) &\leq 0 \quad \forall k \quad (\eta > 0, -1 \leq \cos x \leq 1)\\
\eta(\cos k\Delta x - 1) &> -1\\
\eta &< \frac{1}{1 - \cos k\Delta x}\\
\eta = \frac{K \Delta t}{C \rho \Delta x^2} &< \frac{1}{2} \le \frac{1}{1 - \cos k\Delta x}
\end{align}

Thus, solutions are only stable for $\eta < 1/2$. In particular, decreasing $\Delta t$ will always improve stability, But decreasing $\Delta x$ requires an quadratic *increase* in $\Delta t$!

Note
* Perform von Neumann stability analysis when possible (depends on PDE and the specific discretization).
* Test different combinations of $\Delta t$ and $\Delta x$.
* Not guarantee that decreasing both will lead to more stable solutions!

```python

```


# Spectral Estimation of Random 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).*

## The Welch Method

In the previous section it has been shown that the [periodogram](periodogram.ipynb) as a non-parametric estimator of the power spectral density (PSD) $\Phi_{xx}(\mathrm{e}^{\,\mathrm{j}\,\Omega})$ of a random signal $x[k]$ is not consistent. This is due to the fact that its variance does not converge towards zero even when the length of the random signal is increased towards infinity. In order to overcome this problem, the [Bartlett method](https://en.wikipedia.org/wiki/Bartlett's_method) and [Welch method](https://en.wikipedia.org/wiki/Welch's_method)

1. split the random signal into segments,
2. estimate the PSD for each segment, and
3. average over these local estimates.

The averaging reduces the variance of the estimated PSD. While Barlett's method uses non-overlapping segments, Welch's is a generalization using windowed overlapping segments. For the discussion of Welch's method we assume a wide-sense ergodic real-valued random process.

### Derivation

The random signal $x[k]$ is split into into $L$ overlapping segments of length $N$, starting at multiples of the step size $M \in {1,2, \dots, N}$. These segments are windowed by the window $w[k]$ of length $N$, resulting in a windowed $l$-th segment $x_l[k]$ with $0\leq l\leq L-1$. The discrete-time Fourier transformation (DTFT) $X_l(\mathrm{e}^{\,\mathrm{j}\,\Omega})$ of the windowed $l$-th segment is then given as

\begin{equation}
X_l(\mathrm{e}^{\,\mathrm{j}\,\Omega}) = \sum_{k = 0}^{N-1} x[k + l \cdot M] \, w[k] \; \mathrm{e}^{\,-\mathrm{j}\,\Omega\,k}
\end{equation}

where the window $w[k]$ defined within $0\leq k\leq N-1$ should be normalized as $\frac{1}{N} \sum\limits_{k=0}^{N-1} | w[k] |^2 = 1$. The latter condition ensures that the power of the signal is maintained in the estimate. The stepsize $M$ determines the overlap between the segments. In general, $N-M$ number of samples overlap between adjacent segments. For $M = N$ no overlap occurs. The overlap is sometimes given as ratio $\frac{N-M}{N}\cdot 100\%$.

Introducing $X_l(\mathrm{e}^{\,\mathrm{j}\,\Omega})$ into the definition of the periodogram yields the periodogram of the $l$-th segment

\begin{equation}
\hat{\Phi}_{xx,l}(\mathrm{e}^{\,\mathrm{j}\,\Omega}) = \frac{1}{N} \,| X_l(\mathrm{e}^{\,\mathrm{j}\,\Omega}) |^2
\end{equation}

The estimated PSD is then given by averaging over the segment's periodograms $\hat{\Phi}_{xx,l}(\mathrm{e}^{\,\mathrm{j}\,\Omega})$

\begin{equation}
\hat{\Phi}_{xx}(\mathrm{e}^{\,\mathrm{j}\,\Omega}) = \frac{1}{L} \sum_{l = 0}^{L-1} \hat{\Phi}_{xx,l}(\mathrm{e}^{\,\mathrm{j}\,\Omega})
\end{equation}

Note, that the total number $L$ of segments has to be chosen such that the last required sample $(L-1)\cdot M + N - 1$ does not exceed the total length of the random signal. Otherwise the last segment $x_{L-1}[k]$ may be zeropadded towards length $N$.

The Bartlett method uses a rectangular window $w[k] = \text{rect}_N[k]$ and non-overlapping segments $M=N$. The Welch method uses overlapping segments and a window that must be chosen according to the intended spectral analysis task.

### Example

The following example is equivalent to the previous [periodogram example](periodogram.ipynb#Example---Periodogram). We aim at estimating the PSD of a random process which draws samples from normally distributed white noise with zero-mean and unit variance. The true PSD is consequently given as $\Phi_{xx}(\mathrm{e}^{\,\mathrm{j}\,\Omega}) = 1$.

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

N = 128  # length of segment
M = 64  # stepsize
L = 100  # total number of segments

# generate random signal
np.random.seed(5)
x = np.random.normal(size=L*M)

# estimate PSD by Welch's method
nf, Pxx = sig.welch(x, window='hamming', nperseg=N, noverlap=(N-M))
Pxx = .5*Pxx  # due to normalization in scipy.signal
Om = 2*np.pi*nf

# plot results
plt.figure(figsize=(10, 4))
plt.stem(Om, Pxx, 'C0',
         label=r'$\hat{\Phi}_{xx}(e^{j \Omega})$', basefmt=' ', use_line_collection=True)
plt.plot(Om, np.ones_like(Pxx), 'C1', label=r'$\Phi_{xx}(e^{j \Omega})$')
plt.title('Estimated and true PSD')
plt.xlabel(r'$\Omega$')
plt.axis([0, np.pi, 0, 2])
plt.legend()

# compute bias/variance of the estimator
print('Bias of the Welch estimate: \t\t {0:1.4f}'.format(np.mean(Pxx-1)))
print('Variance of the Welch estimate: \t {0:1.4f}'.format(np.var(Pxx)))
```

    Bias of the Welch estimate: 		 -0.0114
    Variance of the Welch estimate: 	 0.0255

**Exercise**

* Compare the results to the periodogram example. Is the variance of the estimator lower?
* Change the number of segments `L`. What changes?
* Change the segment length `N` and stepsize `M`. What changes?

Solution: When comparing both the estimates of the PSD in the previous periodogram and above example, it is obvious that the variance of the Welch estimator is lower. Increasing the number of segments `L` lowers the variance further. Increasing the segment length `N` increases the total number of discrete frequencies in the estimated PSD. Since in above example the total number of segments is kept constant, the variance increases. Lowering the stepsize `M` has the same result, since the total number of samples is reduced for a fixed number of segments.

### Evaluation

It is shown in [[Stoica et al.](../index.ipynb#Literature)] that Welch's method is asymptotically unbiased. Under the assumption of a wide-sense stationary (WSS) random process, the periodograms $\hat{\Phi}_{xx,l}(e^{j \Omega})$ of the segments can be assumed to be approximately uncorrelated. Hence, averaging over these reduces the overall variance of the estimator. It can be shown formally that in the limiting case of an infinite number of segments (infintely long signal) the variance tends towards zero. As a result Welch's method is an asymptotically consistent estimator of the PSD.

Note, that for a finite segment length $N$ the properties of the estimated PSD $\hat{\Phi}_{xx}(e^{j \Omega})$ depend on the length $N$ of the segments and the window function $w[k]$ due to the [leakage effect](../spectral_analysis_deterministic_signals/leakage_effect.ipynb).

**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*.