```python
from IPython.core.display import HTML
def css_styling():
    styles = open("./styles/custom.css", "r").read()
    return HTML(styles)
css_styling()
```

### BEFORE YOU DO ANYTHING...
In the terminal:
1. Navigate to __inside__ your ILAS_Python repository.
2. __COMMIT__ any un-commited work on your personal computer.
3. __PULL__ any changes *you* have made using another computer.
4. __PULL__ textbook updates (including homework answers).

1. __Open Jupyter notebook:__   Start >> Programs (すべてのプログラム) >> Programming >> Anaconda3 >> JupyterNotebook
1. __Navigate to the ILAS_Python folder__.
1. __Open today's seminar__  by clicking on 6_Library_Functions.

# Library Functions
# Lesson Goal

Source and incorporate appropriate functions from external libraries to optimise your code.

# Objectives

- Review standard library functions
- Importing and using modules
- Understanding module documentation
- Using imported functions to optimise the code you have written so far.
- Determining optimal solutions by timing your code.

Why we are studying this:

- To optimise your code (faster, more concise) by incorporating library functions.
- To learn to independently source functions using the internet.
- To learn how and when to replace user defined code with appropriate library functions.

__Lesson structure:__ Today we will learn about functions that we import from external files or “libraries”.
 - Libraries
 - The standard library
 - Reading function documentation
 - Namespaces
 - Importing a function
 - Using package functions
 - Using package functions to optimise your code
 - Importing algorithms as functions
 - Review excercises
 - Update GitHub repository.
 - Summary

## Libraries

Python, like other modern programming languages, has an extensive *library* of built-in functions.

These functions are designed, tested and optimised by the developers of the Python langauge.

We can use these functions to make our code shorter, faster and more reliable.

You are already familiar with some *built in* Python functions:

   - `print()` takes the __input__ in the parentheses and __outputs__ a visible representation.
   - `len()` takes a data structure as __input__ in the parentheses and __outputs__ the number of items in the data structure (in one direction).
   - `sorted()` takes a data structure as __input__ in the parentheses and __outputs__ the data structure sorted by a rule determined by the data type.
   - `abs()` takes a numeric variable as  __input__ in the parentheses and __outputs__ the mathematical absolute value of the input.

These functions belong to Python's __standard library__...

## The Standard Library

Python has a large standard library.

It is simply a collection of Python files called 'modules'.

These files are installed on the computer you are using.

Each module contains code very much like the code that you have been writing, defining various variables, functions and methods.

There are multiple modules to keep the code sorted and well organised.

__Function:__

A piece of code that is called by name.

It can be *passed* data to operate on (i.e., the parameters) and can optionally *return* data (the return value).

__Example__
```Python
sorted([5, 2, 3, 1, 4])
```

__Method:__

A method is a piece of code that is called by name.

It is already associated with an object type (e.g. a list) so it is expressed after a . dot at the end of the object name.

It mostly behaves the same as a function  except:
- It is automatically passed for the object which it is attached to.
- (It can only operate on objects that contain the method. It can operate on data insde of that class.)

__Example__
```Python
a = [1, 5, 2, 7, 5]
a.sort()
```

The standard libary contains many useful functions.

They are listed on the Python website:
https://docs.python.org/3/library/functions.html

If you want to do something, for example a mathematical operation, it worth trying an internet search for a built-in function already exists.

For example, a quick google search for "python function to sum all the numbers in a list"...

https://www.google.co.jp/search?q=python+function+to+sum+all+the+numbers+in+a+list&rlz=1C5CHFA_enJP751JP751&oq=python+function+to+sum+&aqs=chrome.0.0j69i57j0l4.7962j0j7&sourceid=chrome&ie=UTF-8

...returns the function `sum()`.

`sum()` finds the sum of the values in a data strcuture.

```python
print(sum([1,2,3,4,5]))

print(sum((1,2,3,4,5)))

a = [1,2,3,4,5]
print(sum(a))
```

    15
    15
    15

The function `max()` finds the maximum value in data structure.

```python
print(max([4,61,12,9,2]))

print(max((3,6,9,12,15)))

a = [1,2,3,4,5]
print(max(a))
```

    61
    15
    5

## Packages

The standard library tools are available in any Python environment.

More specialised libraries are available.

We call these packages.

Two widely used packages for mathematics, science and engineeirng are `NumPy` and `SciPy`.

These are already installed as part of Anaconda.

A package is a collection of Python modules:
- a __module__ is a single Python file
- a __package__ is a directory of Python modules.
(It contains an __init__.py file, to distinguish it from folders that are not libraries).

### Importing a Package

To use an installed package, we  simply `import` it.

Packages contain functions and constants for more specific tasks e.g. solving trigonometric functions.

We simply install the modules on the computer where we want to use them.

```python
import numpy

x = 1

y = numpy.cos(x)

print(y)

print(numpy.pi)
```

    0.540302305868
    3.141592653589793

The `import` statement must appear before the use of the package in the code.

        import numpy

After this, any function in `numpy` can be called as:

        `numpy.function()`

and, any constant in `numpy` can be called as:

        `numpy.constant`.

There are a many mathematical functions available.

https://docs.scipy.org/doc/numpy-1.13.0/reference/routines.math.html

We can change the name of a package e.g. to keep our code short and neat.

Using the __`as`__ keyword:

```python
import numpy as np
print(np.pi)
```

    3.141592653589793

We only need to import a package once, at the start of the program or notebook.

## Reading function documentation

To check how to use a function e.g.:
- what arguments to include in the () parentheses
- allowable data types to use as arguments
- the order in which arguments should be given

you can:

- search for the documentation online e.g. https://docs.scipy.org/doc/numpy-1.13.0/reference/routines.math.html
- press "Shift" + "Tab" when the curser is between the () parentheses of a function call (Jupyter notebook only).

For example, the documentation for the function numpy.cos:
 https://docs.scipy.org/doc/numpy-1.13.0/reference/routines.math.html includes:

##### What the function does.
"Cosine element-wise."

##### All possible function arguments (parameters)

>numpy.cos(x, /, out=None, *, where=True, casting='same_kind', order='K', dtype=None, subok=True [, signature, extobj ])

In the () parentheses following the function name are:
- *positional* arguments (required)
- *keyword* arguments (with a default value, optionally set). Listed after the `/` slash.
- arguments that must be explicitly named. Listed after the `*` star.

(including arguments without a default value.  Listed in `[]` brackets.)

##### Function argument definitions and acceptable forms.

x : array_like *(it can be an `int`, `float`, `list` or `tuple`)*

out : ndarray, None, or tuple of ndarray and None, optional

where : array_like, optional

##### What the function returns
__y__ : ndarray

        The corresponding cosine values.

```python
import numpy as np

x = 0.5
z = np.ones((1,1))

# cos with posistional argument
print(np.cos(x))

# cos with positional and default arguments
print(np.cos(x), z)

# cos with positional argument, default argument and keyword argument
print(np.cos(x, z, where=True))
```

    0.87758256189
    0.87758256189 [[ 1.]]
    [[ 0.87758256]]

Let's look at the function numpy.degrees:
https://docs.scipy.org/doc/numpy-1.13.0/reference/generated/numpy.degrees.html

What does the function do?

What __arguments__ does it take (and are there any default arguments)?

How would we __write__ the function when __calling__ it (accept defaults)?

What __data type__ should our input be?

## `args*` and `kwargs**`
You may have noticed the word `**kwargs` listed in the function documentation.

`*args` and `**kwargs` can be used when we don't know the exact number of arguments we want to pass to the function.

`*args` lets us pass any number of *arguments*.

`**kwargs` lets us pass any number of *keyword arguments*.

(Actually, `*` and `**` are the only required code. The names `args` and `kwargs` are widely accepted convention).

### Packing: `*args` and `**kwargs in function definitions`
In a function definition, `*args` must appear before `**kwargs`.

### Packing `*args`
Sometimes we want the number of arguments we can pass to a function to be flexible.

Consider the example below which we studied in the last seminar

This simple function to express x, (y and z) inputs as a list.
(e.g. coordinates to define a position vector).

```python
def vector_3D(x, y=0.0, z=0.0):
    """
    Expresses 1D, 2D or 3D vector in 3D coordinates, as a list.
    """
    return [x, y, z]

vector_3D(1, 2, 3)
```

    [1, 2, 3]

However, it is still limited to a maximum of *three* inputs.

Sometimes it can be convenient to allow any number of inputs:
 - enter __any number__ or arguments.
 - have the function store the arguments as a single tuple (behaves like any other tuple).

This is called *packing*.

By allowing the function to take any number of `*args`, we make it more __flexible__.

```python
def var_to_list(*args):
    """
    Expresses any numbe of inputs as a list.
    """
    var_list = []

    for a in args:
        var_list.append(a)

    return var_list

var_to_list(1, 2, 3, 4)
```

    [1, 2, 3, 4]

Arguments must be listed before keyword arguments as usual.

```python
def var_to_list(*args, extra=2):
    """
    Expresses a vector of any length as a list.
    """
    var_list = []

    for a in args:
        var_list.append(a)

    return var_list
```

```python
print(var_to_list(1, 2, 3, 4, 5))

print(var_to_list(1, 2, 3, 4, extra=5))

#print(var_to_list(extra=5, 1, 2, 3, 4))
```

### Packing `**kwargs`
`**` allows multiple keyword-variable pairs to be entered which it stores in the form of a dictionary.

This is useful as we can access the keyword and the variables seperately.

```python
def table_things(**kwargs):
    "Prints key-value pairs seperated by a colon"
    for name, value in kwargs.items():
        print(f"{name} : {value}")

table_things(thing1 = 'robot', thing2 = 'melon')
```

    thing1 : robot
    thing2 : melon

Packing can make your code more efficient by skipping unecessary sections.

```python
x = 1
y = 1

def position(**kwargs):
    "Prints x, y coordinates of current position"

    if "new_pos" in kwargs:
        global x, y
        x = kwargs["new_pos"][1]
        y = kwargs["new_pos"][0]

    print(f"({x},{y})")

pos_update = position()

pos_update = position(new_pos=[3,4])
```

    (1,1)
    (4,3)

So in function documentation, for example, __`**kwargs`__refers to a series of keyword arguments of which you may enter as few or as many as you wish to set.

### Unpacking: `*args` and `**kwargs when calling functions`

Sometimes it can be convenient to do the opposite:
 - enter a __single data structure__; all the functin arguments packed up somewhere earlier in the program.
 - have the function unpack the list for us into __multiple containers__; the individual function arguments.

This reverse process to packing is known as *unpacking*.

By packing the arguments as a single input, calling the function can become more __efficient__.

### Unpacking `*args`
A single data structure e.g. a list, unpacks as the function arguments in the order they appear.

Therefore the data structure should be the *same* length as the number of arguments it is being used to input.

In other words, unlike packing, the number of arguments is now fixed.

```python
def test_args_func(first, second, third, fourth):
    "Prints each argument of the function"
    print(first, second, third, fourth)

to_unpack = [4, 5, 6]

test_args_func(1, *to_unpack)
test_args_func(1, 2, *to_unpack[:2])
```

    1 4 5 6
    1 2 4 5

### Unpacking `**kwargs`
Values in the dictionary are adddressed using the function arguments as keywords.

This means that the arguments can appear in the dictinary in any order.

The dictionary should be the *same* length as the number of arguments it is being used to input *and* the dictionary entried should have the same names.

```python
def test_args_func(first, second, third, fourth):
    "Prints each argument of the function"
    print(first, second, third, fourth)

dictionary = {"second": 12, "fourth": 14, "third": 13}

test_args_func(1, **dictionary)
```

    1 12 13 14

## Namespaces

By prefixing `cos` with `np`, we are using a *namespace* (which in this case is `np`).

The namespace shows we want to use the `cos` function from the Numpy package.

If `cos` appears in more than one package we import, then there will be more than one `cos` function available.

We must make it clear which `cos` we want to use.

Often, functions with the same name, from different packages, will use a different algorithms for performing the same or similar operation.

They may vary in speed and accuracy.

In some applications we might need an accurate method for computing the square root, for example, and the speed of the program may not be important. For other applications we might need speed with an allowable compromise on accuracy.

Below are two functions, both named `sqrt`.

Both functions compute the square root of the input.

 - `math.sqrt`, from the package, `math`, gives an error if the input is a negative number. It does not support complex numbers.
 - `cmath.sqrt`, from the package, `cmath`, supports complex numbers.

```python
import math
import cmath
print(math.sqrt(4))
#print(math.sqrt-5)
#print(cmath.sqrt(-5))
```

    2.0

Two developers collaborating on the same program might choose the same name for two functions that perform similar tasks.

If these functions are in different modules, there will be no name clash since the module name provides a 'namespace'.

## Importing a Function
Single functions can be imported without importing the entire package e.g. use:

        from numpy import cos

instead of:

        import numpy

After this you call the function without the numpy prefix:

```python
from numpy import cos

cos(x)
```

    -0.65364362086361194

Be careful when doing this as there can be only one definition of each function.
In the case that a function name is already defined, it will be overwritten by a more recent definition.

```python
from cmath import sqrt
print(sqrt(-1))
from math import sqrt
#print(sqrt(-1))
```

    1j

A potential solution to this is to rename individual functions or constants when we import them:

```python
from numpy import cos as cosine

cosine(x)
```

    -0.65364362086361194

```python
from numpy import pi as pi
pi
```

    3.141592653589793

This can be useful when importing functions from different modules:

```python
from math import sqrt as square_root
from cmath import sqrt as complex_square_root

print(square_root(4))
print(complex_square_root(-1))
```

    2.0
    1j

Function names should be chosen wisely.
 - relevant
 - concise

## Using Package Functions.

Let's learn to use `numpy` functions in our programs.

To check how to use a function e.g.:
 - what arguments to include in the () parentheses
 - allowable data types to use as arguments
 - the order in which arguments should be given

look at the Numpy documentation.

A google search for 'numpy functions' returns:

https://docs.scipy.org/doc/numpy-1.13.0/reference/routines.math.html

(this list is not exhaustive).

```python
# Some Numpy functions with their definitions as given in the documentation

x = 1
y = 2
z = 3

# Trigonometric sine, element-wise.
print(np.sin(x))

# Compute tangent element-wise.
print(np.tan(x))

# Trigonometric inverse tangent
print(np.arctan(x))

# Convert angles from radians to degrees
degrees = np.degrees(x)
print(degrees)

# Convert angles from degrees to radians
radians = np.radians(degrees)
print(radians)
```

    0.841470984808
    1.55740772465
    0.785398163397
    57.2957795131
    1.0

__Try it yourself:__

 Find a function in the Python Numpy documentation that matches the function definition and use it to solve the following problem:

Given the “legs” of a right angle triangle, return its hypotenuse.
 If  the lengths of the two shorter sides of a right angle triangle are 6 units  and 3 units, what is the length of the hypotenuse?

```python
# The “legs” of a right angle triangle are 6 units and 3 units,
# Return its hypotenuse in units.
```

Numpy functions often appear within user defined functions e.g.:

$f(x)= \cos(x) \qquad x <0$

$f(x) = \exp(-x) \qquad x \ge 0$

```python
def f(x):
    if x < 0:
        f = np.cos(x)
    else:
        f = np.exp(-x)
    return f

print(f(np.pi))
print(f(np.pi/6))
```

    0.0432139182638
    0.592384847188

Package functions can be passed to other functions as arguments.

Recall __Seminar 4, What can be passed as a function argument?__

Example: the function `is_positive` checks if the value of a function $f$, evaluated at $x$, is positive.

 The arguments are:
 - the function $f$
 - the value of $x$,in $f(x)$

```python
def is_positive(f, x):

    return f(x) > 0

def f0(x):
    """
    Computes x^2 - 1
    """
    return x*x - 1

# Value of x to test
x = 0.5

# Test sign of function f0 (user defined)
print(is_positive(f0, x))

# Test sign of function np.cos (numpy function)
print(is_positive(np.cos, x))
```

    False
    True

__Try it yourself:__

 Search online for the numpy function for each of the following mathematical functions:
- $f = \arcsin(x)$
- $f = \sqrt x$

 In the cell below: use the function `is_positive` to test the sign of output of the functions.

```python
# Test sign of numpy function for arcsin(x)

# Test sign of numpy function for square root of x

```

##### Try it yourself
In the cell below, copy and paste the `bisection` function you wrote for __Seminar 4: Review Excercise: Using Functions as Function Arguments.__

Demonstrate that your `bisection` function works correctly by finding the zero of the Numpy $\cos(x)$ function that lies in the interval $x_1=0$ to $x_2=3$.

```python
# Bisection
```

## Using Package Functions to Optimise your Code

We will take previous excercises that you have completed  and look at how we can optimise them using Numpy functions.

 If you *have not* completed the excercises mentioned in previous seminars  you can:
1. complete the exercise from the previous seminar (without Numpy functions) then optimise using Numpy.
1. complete the excercise using Numpy functions (without completing the preceding excercise).

Consider the function min_max_mean from __Seminar 5: Functions: Return Arguments__.

This is obviously a very limited way to compute the minimum, maximum and mean.

The algorithm relies on there being only 3 arguments.

```python
# Using Pure Python only
def max_min_mean(x0, x1, x2):
    "Return maximum, minimum and mean values"

    x_min = x0
    if x1 < x_min:
        x_min = x1
    if x2 < x_min:
        x_min = x2

    x_max = x0
    if x1 > x_max:
        x_max = x1
    if x2 > x_max:
        x_max = x2

    x_mean = (x0 + x1 + x2)/3

    return x_min, x_max, x_mean

xmin, xmax, xmean = max_min_mean(0.5, 0.1, -20)
print(xmin, xmax, xmean)
```

    -20 0.5 -6.466666666666666

We are going to re-write the function `compute_max_min_mean` using Numpy.

```python
def np_max_min_mean(x0, x1, x2):
    "Return maximum, minimum and mean values"

    x_min = np.min([x0, x1, x2])
    x_max = np.max([x0, x1, x2])
    x_mean = np.mean([x0, x1, x2])

    return x_min, x_max, x_mean

xmin, xmax, xmean = np_max_min_mean(0.5, 0.1, -20)
print(xmin, xmax, xmean)
```

    -20.0 0.5 -6.46666666667

### Data Structures as Function Arguments.
Notice that the Numpy functions `amin`, `amax` and `amean` take lists as argumets.

Therefore a single list (of any length) is a more convenient way to input the function arguments.

```python
import numpy as np
def np_max_min_mean(x_list):
    "Return maximum, minimum and mean values"

    x_min = np.amin(x_list)
    x_max = np.amax(x_list)
    x_mean = np.mean(x_list)

    return x_min, x_max, x_mean

numbers = [0.5, 0.1, -20]
print(np_max_min_mean(numbers))

numbers = [-2, -1, 3, 5, 12]
print(np_max_min_mean(numbers))
```

    (-20.0, 0.5, -6.4666666666666659)
    (-2, 12, 3.3999999999999999)

### Elementwise Functions
Numpy functions often operate *elementwise*.

 This means if the argument is a list, they will perform the same function on each element of the list.

For example, to find the square root of each number in a list, we can use:

```python
a = [9, 25, 36]
print(np.sqrt(a))
```

    [ 3.  5.  6.]

### Magic Functions
We can use *magic function* (http://ipython.readthedocs.io/en/stable/interactive/magics.html), `%timeit`, to compare the time the user-defined function takes to execute compared to the Numpy function.

It is important to optimise code for the most desirable parameter. In this example, the user defined code is significantly faster, but the function using numpy applies to a far wider range of input cases.

```python
%timeit max_min_mean(0.5, 0.1, -20)
print()
%timeit np_max_min_mean([0.5, 0.1, -20])
```

    406 ns ± 155 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

    24.2 µs ± 11.7 µs per loop (mean ± std. dev. of 7 runs, 100000 loops each)

##### Try it yourself
In the cell below, find a Numpy function that provides the same solution as the function you wrote as your answer to __Seminar 3, Review Exercise: Indexing, part (A)__:

Add two vectors, $\mathbf{A}$ and $\mathbf{B}$ such that:
$ \mathbf{A} + \mathbf{B} = [(A_1 + B_1),
                              (A_2 + B_2),
                              ...
                              (A_n + B_n)]$

__(A)__ Use the Numpy function to add vectors:

$\mathbf{A} = [-2, 1, 3]$

$\mathbf{B} = [6, 2, 2]$

Check that your answer is the same as your answer to __Seminar 3, Review Exercise: Indexing__.

__(B)__ Using your answer to  __Seminar 3, Review Exercise: Indexing__ write a function `vector_add` that takes vectors A and B as inputs and returns the sum of the two vectors by calling:

```python
vector_add(A, B)
```

__(C)__ Use *magic function* `%timeit`, to compare the speed of the Numpy function to the user defined function `vector_add`.

 Which is fastest?

```python
# Vector Addition
```

## Importing Algorithms as Functions (e.g. Root finding)

So far we have mostly looked at library functions that perform single mathematical operations such as trigonomtric or algebraic functions.

Library functions also include those that can be used for complete multi-stage tasks.

For example, in place of the `bisection` function you wrote to find the root of a function, a number of root-finding functions from imported modules can be used.

The package `scipy.optimize` contains a number of functions for estimating the roots of a function given an initial estimate or interval:
 - `scipy.optimize.bisect`
 - `scipy.optimize.fsolve`
 - `scipy.optimize.brentq`

These are useful when we want to find the root of a function in the vicinity of a particular point or interval.

The documentation for `fsolve` https://docs.scipy.org/doc/scipy-0.14.0/reference/generated/scipy.optimize.fsolve.html:

>scipy.optimize.fsolve(func, x0, args=(), fprime=None, full_output=0, col_deriv=0, xtol=1.49012e-08, maxfev=0, band=None, epsfcn=None, factor=100, diag=None)[source]

>Return the roots of the (non-linear) equations defined by func(x) = 0 given a starting estimate.

>__func__ : callable f(x, *args)

       A function that takes at least one (possibly vector) argument.

__x0__ : ndarray

      The starting estimate for the roots of func(x) = 0.

What does the function do?

What __arguments__ does it take (and are there any default arguments)?

How would we __write__ the function when __calling__ it (accept defaults)?

What __data type__ should our input be?

Root finding methods

__Bisection method, Brent method:__ the user selects the interval in which to look for the root.

__`solve` method:__ the user selects an *initial estimate* for the root.

```python
import scipy
from scipy.optimize import fsolve
from scipy.optimize import brentq

root = scipy.optimize.fsolve(np.cos, -5)
print(root, root/pi)

root = scipy.optimize.brentq(np.cos, 0, 2.5)
print(root, root/pi)
```

    [-4.71238898] [-1.5]
    1.5707963267948966 0.5

##### Non-repeating functions.

__Example.__

$f(x) = x^3 + 4x^2 + x - 6$

Three real roots; -3, -2, and 1.

We can use the function `np.roots()` to find all roots.

The function arguments are the coefficients of the polynomial.

```python
print(np.roots([1, 4, 1, -6]))
```

    [-3. -2.  1.]

Check your solution.

```python
# The equation as a Python function
def func(x):
    return x**3 + 4*x**2 + x - 6

# A range of 600 x values (-4 to 2)
X = [x/100 for x in list(range(-400, 200))]

# The value of y for each x
Y = [func(x) for x in X]

# List comprehension: return X where Y = 0
check_roots = [x for x,y in zip(X,Y) if y==0]
print(check_roots)
```

    [-3.0, -2.0, 1.0]

Next week we will check solution by plotting curves.

```python
# plot to check solution
from matplotlib import pyplot as plt
%matplotlib inline
plt.plot(X,Y)
plt.grid(True, which='both')

```

__Try it yourself:__

In the cell below use `numpy.roots()` to find the roots of the function:

$y = x^3 - 2x^2 - 11x + 12$

```python
# root finding using numpy.roots()
```

## Stacking Functions
If performing multiple functions on a variable or data structure, operations can be stacked to produce shorter code.

```python
a = range(10)
a = list(a)
a = np.cos(a)
a = np.sum(a)
print(a)

a = np.sum(np.cos(list(range(10))))
print(a)
```

    0.421623782621
    0.421623782621

```python

```

## Review Exercises
The following excercises will help you to practise finding useful functions from external packages and applying them when solving engineering problems.

### Review Exercise: Numpy Package Functions.

 Find a function in the Python Numpy documentation that matches the function definition and use it to solve the problems below:

__(A)__ Definition: *Calculates the exponential function, $y= e^x$ for all elements in the input array.*

Print a list where each element is the exponential function of the corresponding element in list `a = [0.1, 0, 10]`

```python
# Print a list where each element is the exponential of the corresponding element in list a
```

```python
# Example solution
print(np.exp(a))
```

__(B)__ Definition: *Converts angles from degrees to radians.*

Convert angle `theta`, expressed in degrees, to radians:

`theta` = 47

```python
# convert angle `theta`, expressed in degrees, to radians
```

```python
# Example solution

print(np.deg2rad(theta))
print(np.radians(theta))
```

### Review Exercise: Searching Package Functions - Magnitude

Refer to your answer to __Seminar 4, Review Exercise: Default Arguments.__

Copy and paste your code in the cell below.

__(A)__ *Elementwise* functions perform an operation on each element of a data structure.

Within the function create a list to store the values x, y and z:
```python
def magnitude(x, y, z = 0):
    """
    Returns the magnitude of a 2D or 3D vector
    """
    vector = [x, y, z]
```

 Within your function, replace the operations to square each item of the list, `vector`, with an elementwise numpy function.

 Jump to Elementwise Functions

__(B)__ Find an Numpy functions to the replace operation for:

 - summation $\sum$
 - square root $\sqrt x$
and include these in your function.

__(C)__ Use *magic function*  `%timeit`, to compare the speed of your user-defined function (from Seminar 4) to the speed when using Numpy functions.

 Which is fastest?

 Jump to Magic Functions

__(D)__ Search online for a single numpy function that takes a vector as input and returns the magnitide of a vector.

 Use it calculate the magnitude of the vector $x$.

 Check the answer against the value generated in __A__

__(E)__ Use *magic function* `%timeit`, to compare the time for:
 - the Numpy function to return the magnitude
 - the function you used in parts __(A)-(C)__
for 2D and 3D vectors.

```python
# Searching for Appropriate Package Functions
```

```python
# Example solution

def magnitude(x, y, z = 0):
    """
    Returns the magnitude of a 2D or 3D vector
    """
    vector = [x, y, z]

    return(np.sqrt(np.sum(np.square(vector))))

print(magnitude(3, 4))
print(magnitude(12, 7, -9))

%timeit magnitude(3, 4)
%timeit magnitude(12, 7, -9)

print(np.linalg.norm([3, 4]))
print(np.linalg.norm([12, 7, -9]))

%timeit np.linalg.norm([3, 4])
%timeit np.linalg.norm([12, 7, -9])
```

### Review Exercise: Searching Package Functions - Dot Product
Recall __Seminar 4: Daat Structures - Iterating with Zip

We saw that the __dot product__ of two vectors can be experssed both geometrically and algebraically.

__GEOMETRIC REPRESENTATION__

\begin{align}
\mathbf{A} \cdot \mathbf{B} = |\mathbf{A}| |\mathbf{B}| \cos(\theta)
\end{align}

__ALGEBRAIC REPRESENTATION__

>So the dot product of two 3D vectors:
>
 $ \mathbf{A} = [A_x, A_y, A_z]$
>
 $ \mathbf{B} = [B_x, B_y, B_z]$
>
 is:

\begin{align}
\mathbf{A} \cdot \mathbf{B} &= \sum_{i=1}^n A_i B_i \\
&= A_x B_x + A_y B_y + A_z B_z.
\end{align}

We looked at a program that comuted the dot product of two 3D vectors using two lists.

$\mathbf{C} = [2, 4, 3.5]$

$\mathbf{D} = [1, 2, -6]$

__(A)__ Search for a Numpy function to find the dot product of two lists.
Use it to find the dot product of $C$ and $D$.

__(B)__ Search for a Numpy function for $\cos^{-1}$ and use it to compute $\theta$) using the geometric expresssion for the dot product.

```python
# Find the dot product of A and B
# Compute theta
```

```python
# Example Solution

CDdot = np.dot(C,D)

Cmag = np.linalg.norm(C)

Dmag = np.linalg.norm(D)

theta = np.arccos(CDdot / (Cmag * Dmag))

print(f"dot product = {CDdot}, theta = {theta}")
```

### Review Exercise: Using Package Functions to Optimise your Code

Search for a Numpy function that has a __similar__ function to the `is_positive` function from Section: Using Package Functions; the answer it returns should show if an input value is positive or not.

In the cell below:
 - copy and paste the `is_positive` function
 - use the magic function `%timeit` to compare the speed of the `is_positive` function with the Numpy function for analysing the sign of a numerical input.

Jump to function:`is_positive`

```python
# Numpy function to test if number is positive
```

```python
# Example Solution
np.sign(-2)
```

### Review Exercise: Importing Algorithms as Functions

In Importing Algorithms as Functions (e.g. Root finding) we learnt that the package scipy.optimize contains a number of functions for estimating the roots of a function, including `scipy.optimize.bisect`.

This function performs the same/a similar function to the `bisection` function that you have been developing.

__(A)__ Find the documentation for the function `scipy.optimize.bisect` to learn how to use it.

__(B)__ Use `scipy.optimize.bisect` to estimate the root of the function $f(x) = 2\sin^2 x - 3\sin x + 1$:

       (i) between 0 and $\frac{\pi}{6}$

       (ii) between 1.5 and 2

       (iii) between $\frac{3}{4}\pi$ and $\pi$

__NOTE:__   $\sin^2(x) = (\sin(x))^2$

__(C)__ Use the magic function %timeit to compare the speed of your user-defined function `bisection`, with the speed of `scipy.optimize.bisect`.

```python
# Scipy bisection algorithm
```

```python
# Example solution

import scipy
from scipy.optimize import bisect

def func(x):
    return x**3 + 4*x**2 + x - 6

def q(x):
    return (2 * (np.sin(x))**2) - (3 * np.sin(x)) + 1

scipy.optimize.bisect(func, 0, 3)
scipy.optimize.bisect(q, 0, 1.5)
```

# Updating your git repository

You have made several changes to your interactive textbook.

 > Save your work.
 >
 `git add -A`
 >
`git commit -m "A short message describing changes"`

# Summary

- Python has an extensive __standard library__ of built-in functions.
- More specialised libraries of functions and constants are available. We call these __packages__.
- Packages are imported using the keyword `import`
- The function documentation tells is what it does and how to use it.
- When calling a library function it must be prefixed with a __namespace__ is used to show from which package it should be called.
- The magic function `%timeit` can be used to time the execution of a function.

# Homework

1. __PULL__ the changes you made in-class today to your personal computer.
1. __COMPLETE__ any unfinished Review Exercises.
1. __PUSH__ the changes you make at home to your online repository.

```python

```


```python
#remove cell visibility
from IPython.display import HTML
tag = HTML('''
Toggle cell visibility <a href="javascript:code_toggle()">here</a>.''')
display(tag)
```

Toggle cell visibility here.

```python
%matplotlib inline
import control
import numpy
import sympy as sym
from IPython.display import display, Markdown
import ipywidgets as widgets
import matplotlib.pyplot as plt

#print a matrix latex-like
def bmatrix(a):
     """Returns a LaTeX bmatrix - by Damir Arbula (ICCT project)

     :a: numpy array
     :returns: LaTeX bmatrix as a string
     """
     if len(a.shape) > 2:
         raise ValueError('bmatrix can at most display two dimensions')
     lines = str(a).replace('[', '').replace(']', '').splitlines()
     rv = [r'\begin{bmatrix}']
     rv += ['  ' + ' & '.join(l.split()) + r'\\' for l in lines]
     rv +=  [r'\end{bmatrix}']
     return '\n'.join(rv)

# Display formatted matrix:
def vmatrix(a):
    if len(a.shape) > 2:
         raise ValueError('bmatrix can at most display two dimensions')
    lines = str(a).replace('[', '').replace(']', '').splitlines()
    rv = [r'\begin{vmatrix}']
    rv += ['  ' + ' & '.join(l.split()) + r'\\' for l in lines]
    rv +=  [r'\end{vmatrix}']
    return '\n'.join(rv)

#matrixWidget is a matrix looking widget built with a VBox of HBox(es) that returns a numPy array as value !
class matrixWidget(widgets.VBox):
    def updateM(self,change):
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.M_[irow,icol] = self.children[irow].children[icol].value
                #print(self.M_[irow,icol])
        self.value = self.M_

    def dummychangecallback(self,change):
        pass

    def __init__(self,n,m):
        self.n = n
        self.m = m
        self.M_ = numpy.matrix(numpy.zeros((self.n,self.m)))
        self.value = self.M_
        widgets.VBox.__init__(self,
                             children = [
                                 widgets.HBox(children =
                                              [widgets.FloatText(value=0.0, layout=widgets.Layout(width='90px')) for i in range(m)]
                                             )
                                 for j in range(n)
                             ])

        #fill in widgets and tell interact to call updateM each time a children changes value
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].value = self.M_[irow,icol]
                self.children[irow].children[icol].observe(self.updateM, names='value')
        #value = Unicode('example@example.com', help="The email value.").tag(sync=True)
        self.observe(self.updateM, names='value', type= 'All')

    def setM(self, newM):
        #disable callbacks, change values, and reenable
        self.unobserve(self.updateM, names='value', type= 'All')
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].unobserve(self.updateM, names='value')
        self.M_ = newM
        self.value = self.M_
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].value = self.M_[irow,icol]
        for irow in range(0,self.n):
            for icol in range(0,self.m):
                self.children[irow].children[icol].observe(self.updateM, names='value')
        self.observe(self.updateM, names='value', type= 'All')

                #self.children[irow].children[icol].observe(self.updateM, names='value')

#overlaod class for state space systems that DO NOT remove "useless" states (what "professor" of automatic control would do this?)
class sss(control.StateSpace):
    def __init__(self,*args):
        #call base class init constructor
        control.StateSpace.__init__(self,*args)
    #disable function below in base class
    def _remove_useless_states(self):
        pass
```

## Controllo dell'orbita di un satellite

La dinamica di un satellite in orbita circolare attorno alla Terra può essere descritta dalla seguente equazione dinamica non lineare:
$$
    \ddot{r} = f(r,\tau,d) = r\dot{\theta}^2 - \frac{K}{r^2} + \tau + d \, ,
$$

dove $r$ è il raggio dell'orbita, $\theta$ è l'angolo che descrive la posizione del satellite nell'orbita circolare, $K = 398600$ km^3/s^2 è una costante, $\tau$ è la spinta radiale del satellite prodotta da un attuatore appropriato (limite: $|\tau|\leq 100 \mu\text{N}$) e $d$ è il disturbo della gravità.

Si vuole progettare un regolatore per il raggio dell'orbita secondo le seguenti specifiche:
- tempo di assestamento al 5% inferiore ad 1 ora;
- overshoot inferiore al 40%;
- errore nullo a regime in risposta a una richiesta di posizione a gradino.

Per avere un sistema dinamico lineare si linearizza l'equazione all'equilibrio ($r_{eq} = 16763$ km, $\dot{r}_{eq}=0$, $\tau_{eq}=0$, $d_{eq}=0$) considerando $\omega_{eq}=\dot{\theta}_{eq}= \frac{2\pi}{6\times3600}$. Definendo $x=\begin{bmatrix} x_1 & x_2 \end{bmatrix}^T=\begin{bmatrix} \dot{r} & r \end{bmatrix}^T$ e $u=\begin{bmatrix} \tau & d \end{bmatrix}^T$ le equazioni nello spazio degli stati diventano:

\begin{cases}
    \dot{\delta x} = \frac{\partial f(x,u)}{\partial x}\Bigr|_{\substack{x=x_{eq}\\u=u_{eq}}}(x-x_{eq}) + \frac{\partial f(x,u)}{\partial u}\Bigr|_{\substack{x=x_{eq}\\u=u_{eq}}}(u-u_{eq}) = A\delta x+ B\delta u = \begin{bmatrix} 0 & \omega_{eq}^2 + 2\frac{K}{r_{eq}^3} \\ 1 & 0 \end{bmatrix}\delta x + \begin{bmatrix} 1 & 1 \\ 0 & 0 \end{bmatrix}\delta u\\
    y = C\delta x = \begin{bmatrix} 0 & 1 \end{bmatrix}\delta x \, .
\end{cases}

I poli del sistema sono $\pm0.000504$.

### Sviluppo del regolatore
#### Design del controller

Si assicura l'errore nullo a regime aggiungendo un nuovo stato $\dot{\delta x_3} = \delta x_2 - y_d$

\begin{cases}
    \dot{x}_a = \begin{bmatrix} 0 & \omega_{eq}^2 + 2\frac{K}{r_{eq}^3} & 0 \\ 1 & 0 & 0 \\ 0 & 1 & 0 \end{bmatrix}x_a + \begin{bmatrix} 1 & 1 & 0 \\ 0 & 0 & 0 \\ 0 & 0 & -1 \end{bmatrix}\begin{bmatrix} \tau \\ d \\ y_d\end{bmatrix}\\
    y_a = \begin{bmatrix} 0 & 1 & 0 \\ 0 & 0 & 1\end{bmatrix}x_a \, .
\end{cases}

Una possibile posizione per i poli del sistema a ciclo chiuso è $-0.0504$ e $-0.001\pm0.003i$.

#### Design dell'osservatore

Poiché si misura direttamente $\delta x_2$ e $\delta x_3$ (vedere la matrice di output), è possibile progettare un osservatore solo per $\delta x_1$. Lavorando con il sottosistema ($\delta x_1$, $\delta x_2$) e definendo $L=\begin{bmatrix} l_1 & l_2 \end{bmatrix}^T$ si può scrivere:

$$
    \dot{\hat{\delta x}} = \left(A-LC\right)\hat{\delta x} + \begin{bmatrix} 1 \\ 0 \end{bmatrix}\tau + Ly \, ,
$$

utilizzando la trasformata di Laplace e risolvendo per $\hat{\delta x}_1(s)$ si ottiene:

$$
    \hat{\delta x}_1(s) = \frac{l_2  + s}{\left(l_1 - \frac{2K}{r_{eq}^3}- \omega_{eq}^2\right) + l_2s + s^2}\tau + \frac{l_2\left(\frac{2K}{r_{eq}^3} + \omega_{eq}^2\right) + l_1s}{\left(l_1 - \frac{2K}{r_{eq}^3}- \omega_{eq}^2\right) + l_2s + s^2}y \, .
$$

Si posizionano entrambi i poli dell'osservatore in $-1$.

### Come utilizzare questo notebook?
- Prova a modificare la risposta in modo da ottenere 0% di overshoot con un requisito rilassato per il tempo di assestamento (2 ore).
- Verifica le prestazioni del sistema controllato con un gradino e disturbo sinusoidale.

```python
# Preparatory cell
om = 2*numpy.pi/3600/6
K = 398600
r = 16763
Aa = numpy.matrix([[0, om**2+2*K/r**3, 0],
                   [1, 0, 0],
                   [0, 1, 0]])
Ba = numpy.matrix([[1, 1, 0],
                   [0, 0, 0],
                   [0, 0, -1]])
Ca = numpy.matrix([[0, 1, 0],
                   [0, 0, 1]])

X0 = numpy.matrix('0.0')
K = numpy.matrix([8/15,-4.4,-4])
L = numpy.matrix([[23],[66],[107/3]])

X0w = matrixWidget(1,1)
X0w.setM(X0)
Kw = matrixWidget(1,3)
Kw.setM(K)
Lw = matrixWidget(2,1)
Lw.setM(L)

eig1c = matrixWidget(1,1)
eig2c = matrixWidget(2,1)
eig3c = matrixWidget(1,1)
eig1c.setM(numpy.matrix([-0.0504]))
eig2c.setM(numpy.matrix([[-0.001],[-0.003]]))
eig3c.setM(numpy.matrix([-1.0]))

eig1o = matrixWidget(1,1)
eig2o = matrixWidget(2,1)
eig1o.setM(numpy.matrix([-1.]))
eig2o.setM(numpy.matrix([[-1.],[0.]]))
```

```python
# Misc

#create dummy widget
DW = widgets.FloatText(layout=widgets.Layout(width='0px', height='0px'))

#create button widget
START = widgets.Button(
    description='Test',
    disabled=False,
    button_style='', # 'success', 'info', 'warning', 'danger' or ''
    tooltip='Test',
    icon='check'
)

def on_start_button_clicked(b):
    #This is a workaround to have intreactive_output call the callback:
    #   force the value of the dummy widget to change
    if DW.value> 0 :
        DW.value = -1
    else:
        DW.value = 1
    pass
START.on_click(on_start_button_clicked)

# Define type of method
selm = widgets.Dropdown(
    options= [('Imposta K','Set K'), ('Imposta gli autovalori','Set the eigenvalues')],
    value= 'Set the eigenvalues',
    description='',
    disabled=False
)

# Define the number of complex eigenvalues
sele = widgets.Dropdown(
    options= [('0 autovalori complessi','0 complex eigenvalues'), ('2 autovalori complessi','2 complex eigenvalues')],
    value= '2 complex eigenvalues',
    description='Autovalori complessi:',
    style = {'description_width': 'initial'},
    disabled=False
)

#define type of ipout
selu = widgets.Dropdown(
    options=[('impulso','impulse'), ('gradino','step'), ('sinusoide','sinusoid'), ('onda quadra','square wave')],
    value='step',
    description='Disturbo:',
    style = {'description_width': 'initial'},
    disabled=False
)
# Define the values of the input
u = widgets.FloatSlider(
    value=10,
    min=0,
    max=30,
    step=1,
    description='Riferimento [m]:',
    style = {'description_width': 'initial'},
    disabled=False,
    continuous_update=False,
    orientation='horizontal',
    readout=True,
    readout_format='.1f',
)
d = widgets.FloatSlider(
    value=0,
    min=-3,
    max=3,
    step=0.1,
    description='Disturbo [µm/s^2]:',
    style = {'description_width': 'initial'},
    disabled=False,
    continuous_update=False,
    orientation='horizontal',
    readout=True,
    readout_format='.1f',
)
period = widgets.FloatSlider(
    value=100,
    min=1,
    max=300,
    step=1,
    description='Periodo [µs]: ',
    disabled=False,
    continuous_update=False,
    orientation='horizontal',
    readout=True,
    readout_format='.1f',
)
simTime = widgets.FloatText(
    value=3600*2,
    description='',
    disabled=False
)
```

```python
# Support functions

def eigen_choice(sele):
    if sele == '0 complex eigenvalues':
        eig1c.children[0].children[0].disabled = False
        eig2c.children[1].children[0].disabled = True
        eig1o.children[0].children[0].disabled = False
        eig2o.children[1].children[0].disabled = True
        eig = 0
    if sele == '2 complex eigenvalues':
        eig1c.children[0].children[0].disabled = True
        eig2c.children[1].children[0].disabled = False
        eig1o.children[0].children[0].disabled = True
        eig2o.children[1].children[0].disabled = False
        eig = 2
    return eig

def method_choice(selm):
    if selm == 'Set K and L':
        method = 1
        sele.disabled = True
    if selm == 'Set the eigenvalues':
        method = 2
        sele.disabled = False
    return method
```

```python
A = Aa[0:2,0:2]
B = Ba[0:2,0]
C = Ca[0,0:2]

def main_callback2(d, X0w, K, L, eig1c, eig2c, eig3c, eig1o, eig2o, u, period, selm, sele, selu, simTime, DW):
    eige = eigen_choice(sele)
    method = method_choice(selm)

    if method == 1:
        solc = numpy.linalg.eig(Aa-Ba[:,0]*K)
        solo = numpy.linalg.eig(A-L*C)
    if method == 2:
        if eige == 0:
            K = control.acker(Aa, Ba[:,0], [eig1c[0,0], eig2c[0,0], eig3c[0,0]])
            Kw.setM(K)
            L = control.acker(A.T, C.T, [eig1o[0,0], eig2o[0,0]]).T
            Lw.setM(L)
        if eige == 2:
            K = control.acker(Aa, Ba[:,0], [eig3c[0,0],
                                     numpy.complex(eig2c[0,0],eig2c[1,0]),
                                     numpy.complex(eig2c[0,0],-eig2c[1,0])])
            Kw.setM(K)
            L = control.acker(A.T, C.T, [numpy.complex(eig2o[0,0],eig2o[1,0]),
                                         numpy.complex(eig2o[0,0],-eig2o[1,0])]).T
            Lw.setM(L)

    sys = control.ss(Aa,Ba,numpy.vstack((Ca,[0,0,0])),[[0,0,0],[0,0,0],[1,0,0]])
    sysE = control.ss(A-L*C,
                      numpy.hstack((B,L)),
                      [1,0],
                      [0,0])
    sysC = control.ss(numpy.zeros((1,1)),
                      [0, 0, 0],
                      0,
                      -K)

    sys_append = control.append(sys, sysE, sysC)
    sys_CL = control.connect(sys_append,
                             [[1,5],[4,3],[5,1],[6,4],[7,1],[8,2]],
                             [3,2], # in
                             [1,3]) # out

    X0w1 = numpy.zeros((5,1))
    X0w1[3,0] = X0w
    if simTime != 0:
        T = numpy.linspace(0, simTime, 10000)
    else:
        T = numpy.linspace(0, 1, 10000)

    d = d/1E6
    period=period/1E6
    U1 = [u for t in range(0,len(T))]
    if selu == 'impulse': #selu
        U = [0 for t in range(0,len(T))]
        U[0] = d
        T, yout, xout = control.forced_response(sys_CL,T,[U1,U],X0w1)
    if selu == 'step':
        U = [d for t in range(0,len(T))]
        T, yout, xout = control.forced_response(sys_CL,T,[U1,U],X0w1)
    if selu == 'sinusoid':
        U = d*numpy.sin(2*numpy.pi/period*T)
        T, yout, xout = control.forced_response(sys_CL,T,[U1,U],X0w1)
    if selu == 'square wave':
        U = d*numpy.sign(numpy.sin(2*numpy.pi/period*T))
        T, yout, xout = control.forced_response(sys_CL,T,[U1,U],X0w1)

    step_info_dict = control.step_info(sys_CL[0,0],SettlingTimeThreshold=0.05,T=T)
    print('Step info: \n\tTempo di assestamento (5%) [ore]=',step_info_dict['SettlingTime']/3600,'\n\tOvershoot (%)=',step_info_dict['Overshoot'])
    print('Valore massimo di u (% di 100µN)=', max(abs(yout[1]))/(100E-06)*100)

    fig = plt.figure(num='Simulation1', figsize=(16,12))

    fig.add_subplot(221)
    plt.title('Risposta dell\'uscita')
    plt.ylabel('Uscita [m]')
    plt.plot(T,yout[0])
    plt.xlabel('$t$ [s]')
    plt.legend(['$y$'])
    plt.axvline(x=0,color='black',linewidth=0.8)
    plt.axhline(y=0,color='black',linewidth=0.8)
    plt.grid()

    fig.add_subplot(222)
    plt.title('Ingresso')
    plt.ylabel('$u$ [N]')
    plt.plot(T,yout[1])
    plt.plot(T,[100/1E6 for t in range(len(T))],'r--')
    plt.plot(T,[-100/1E6 for t in range(len(T))],'r--')
    plt.xlabel('$t$ [s]')
    plt.axvline(x=0,color='black',linewidth=0.8)
    plt.axhline(y=0,color='black',linewidth=0.8)
    plt.grid()

    fig.add_subplot(223)
    plt.title('Risposta degli stati')
    plt.ylabel('Stati')
    plt.plot(T,xout[0],
             T,xout[1],
             T,xout[2])
    plt.xlabel('$t$ [s]')
    plt.legend(['$\delta x_{1}$','$\delta x_{2}$','$\delta x_{3}$'])
    plt.axvline(x=0,color='black',linewidth=0.8)
    plt.axhline(y=0,color='black',linewidth=0.8)
    plt.grid()

    fig.add_subplot(224)
    plt.title('Errore di stima')
    plt.ylabel('Errore')
    plt.plot(T,xout[0]-xout[3])
    plt.xlabel('$t$ [s]')
    plt.legend(['$e_{1}$'])
    plt.axvline(x=0,color='black',linewidth=0.8)
    plt.axhline(y=0,color='black',linewidth=0.8)
    plt.grid()
    #plt.tight_layout()

    fig.tight_layout()

alltogether2 = widgets.VBox([widgets.HBox([selm,
                                          sele,
                                          selu]),
                            widgets.Label(' ',border=3),
                            widgets.HBox([widgets.Label('K:',border=3), Kw,
                                          widgets.Label(' ',border=3),
                                          widgets.Label(' ',border=3),
                                          widgets.Label('Autovalori:',border=3),
                                          eig1c,
                                          eig2c,
                                          eig3c,
                                          widgets.Label(' ',border=3),
                                          widgets.Label(' ',border=3),
                                          widgets.Label('X0 stim.:',border=3), X0w]),
                            widgets.Label(' ',border=3),
                            widgets.HBox([widgets.Label('L:',border=3), Lw,
                                          widgets.Label(' ',border=3),
                                          widgets.Label(' ',border=3),
                                          widgets.Label('Autovalori:',border=3),
                                          eig1o,
                                          eig2o,
                                          widgets.Label(' ',border=3),
                                          widgets.VBox([widgets.Label('Tempo di simulazione (s):',border=3)]),
                                          widgets.VBox([simTime])]),
                            widgets.Label(' ',border=3),
                            widgets.HBox([u,
                                          d,
                                          period,
                                          START])])
out2 = widgets.interactive_output(main_callback2, {'d':d, 'X0w':X0w, 'K':Kw, 'L':Lw,
                                                 'eig1c':eig1c, 'eig2c':eig2c, 'eig3c':eig3c, 'eig1o':eig1o, 'eig2o':eig2o,
                                                 'u':u, 'period':period, 'selm':selm, 'sele':sele, 'selu':selu, 'simTime':simTime, 'DW':DW})
out2.layout.height = '850px'
display(out2, alltogether2)
```

    Output(layout=Layout(height='850px'))

    VBox(children=(HBox(children=(Dropdown(index=1, options=(('Imposta K', 'Set K'), ('Imposta gli autovalori', 'S…


```python
! pip install qeds
import pandas as pd
%matplotlib inline
import qeds
qeds.themes.mpl_style();
```

    Requirement already satisfied: qeds in c:\users\asus\anaconda3\lib\site-packages (0.6.2)
    Requirement already satisfied: numpy in c:\users\asus\anaconda3\lib\site-packages (from qeds) (1.16.5)
    Requirement already satisfied: pandas in c:\users\asus\anaconda3\lib\site-packages (from qeds) (0.25.1)
    Requirement already satisfied: openpyxl in c:\users\asus\anaconda3\lib\site-packages (from qeds) (3.0.0)
    Requirement already satisfied: plotly in c:\users\asus\anaconda3\lib\site-packages (from qeds) (4.5.4)
    Requirement already satisfied: requests in c:\users\asus\anaconda3\lib\site-packages (from qeds) (2.22.0)
    Requirement already satisfied: quantecon in c:\users\asus\anaconda3\lib\site-packages (from qeds) (0.4.6)
    Requirement already satisfied: scikit-learn in c:\users\asus\anaconda3\lib\site-packages (from qeds) (0.21.3)
    Requirement already satisfied: seaborn in c:\users\asus\anaconda3\lib\site-packages (from qeds) (0.9.0)
    Requirement already satisfied: statsmodels in c:\users\asus\anaconda3\lib\site-packages (from qeds) (0.10.1)
    Requirement already satisfied: pandas-datareader in c:\users\asus\anaconda3\lib\site-packages (from qeds) (0.8.1)
    Requirement already satisfied: matplotlib in c:\users\asus\anaconda3\lib\site-packages (from qeds) (3.1.1)
    Requirement already satisfied: pyarrow in c:\users\asus\anaconda3\lib\site-packages (from qeds) (0.16.0)
    Requirement already satisfied: scipy in c:\users\asus\anaconda3\lib\site-packages (from qeds) (1.3.1)
    Requirement already satisfied: quandl in c:\users\asus\anaconda3\lib\site-packages (from qeds) (3.5.0)
    Requirement already satisfied: pytz>=2017.2 in c:\users\asus\anaconda3\lib\site-packages (from pandas->qeds) (2019.3)
    Requirement already satisfied: python-dateutil>=2.6.1 in c:\users\asus\anaconda3\lib\site-packages (from pandas->qeds) (2.8.0)
    Requirement already satisfied: jdcal in c:\users\asus\anaconda3\lib\site-packages (from openpyxl->qeds) (1.4.1)
    Requirement already satisfied: et-xmlfile in c:\users\asus\anaconda3\lib\site-packages (from openpyxl->qeds) (1.0.1)
    Requirement already satisfied: retrying>=1.3.3 in c:\users\asus\anaconda3\lib\site-packages (from plotly->qeds) (1.3.3)
    Requirement already satisfied: six in c:\users\asus\anaconda3\lib\site-packages (from plotly->qeds) (1.12.0)
    Requirement already satisfied: urllib3!=1.25.0,!=1.25.1,<1.26,>=1.21.1 in c:\users\asus\anaconda3\lib\site-packages (from requests->qeds) (1.24.2)
    Requirement already satisfied: idna<2.9,>=2.5 in c:\users\asus\anaconda3\lib\site-packages (from requests->qeds) (2.8)
    Requirement already satisfied: certifi>=2017.4.17 in c:\users\asus\anaconda3\lib\site-packages (from requests->qeds) (2019.9.11)
    Requirement already satisfied: chardet<3.1.0,>=3.0.2 in c:\users\asus\anaconda3\lib\site-packages (from requests->qeds) (3.0.4)
    Requirement already satisfied: sympy in c:\users\asus\anaconda3\lib\site-packages (from quantecon->qeds) (1.4)
    Requirement already satisfied: numba>=0.38 in c:\users\asus\anaconda3\lib\site-packages (from quantecon->qeds) (0.45.1)
    Requirement already satisfied: joblib>=0.11 in c:\users\asus\anaconda3\lib\site-packages (from scikit-learn->qeds) (0.13.2)
    Requirement already satisfied: patsy>=0.4.0 in c:\users\asus\anaconda3\lib\site-packages (from statsmodels->qeds) (0.5.1)
    Requirement already satisfied: lxml in c:\users\asus\anaconda3\lib\site-packages (from pandas-datareader->qeds) (4.4.1)
    Requirement already satisfied: cycler>=0.10 in c:\users\asus\anaconda3\lib\site-packages (from matplotlib->qeds) (0.10.0)
    Requirement already satisfied: kiwisolver>=1.0.1 in c:\users\asus\anaconda3\lib\site-packages (from matplotlib->qeds) (1.1.0)
    Requirement already satisfied: pyparsing!=2.0.4,!=2.1.2,!=2.1.6,>=2.0.1 in c:\users\asus\anaconda3\lib\site-packages (from matplotlib->qeds) (2.4.2)
    Requirement already satisfied: more-itertools in c:\users\asus\anaconda3\lib\site-packages (from quandl->qeds) (7.2.0)
    Requirement already satisfied: inflection>=0.3.1 in c:\users\asus\anaconda3\lib\site-packages (from quandl->qeds) (0.3.1)
    Requirement already satisfied: mpmath>=0.19 in c:\users\asus\anaconda3\lib\site-packages (from sympy->quantecon->qeds) (1.1.0)
    Requirement already satisfied: llvmlite>=0.29.0dev0 in c:\users\asus\anaconda3\lib\site-packages (from numba>=0.38->quantecon->qeds) (0.29.0)
    Requirement already satisfied: setuptools in c:\users\asus\anaconda3\lib\site-packages (from kiwisolver>=1.0.1->matplotlib->qeds) (41.4.0)

# Basic Functionality - Exercises

**For these exercises we use unemployment data by state at a monthly frequency.**

- **We download the data directly from a url and read it into a pandas DataFrame.**

- **Since we want to look at the unemployment rate across different states over time, we proceed to a transformation of the data similar to an Excel pivot-table.**

```python
#Download the data directly from a url and read it into a pandas DataFrame.
url = "https://datascience.quantecon.org/assets/data/state_unemployment.csv"
unemp_raw = pd.read_csv(url, parse_dates=["Date"])
```

```python
#Transformation of the data similar to an Excel pivot-table
unemp_all = (
    unemp_raw
    .reset_index()
    .pivot_table(index="Date", columns="state", values="UnemploymentRate")
)
unemp_all.head()
```

      state
      Alabama
      Alaska
      Arizona
      Arkansas
      California
      Colorado
      Connecticut
      Delaware
      Florida
      Georgia
      ...
      South Dakota
      Tennessee
      Texas
      Utah
      Vermont
      Virginia
      Washington
      West Virginia
      Wisconsin
      Wyoming

      Date

      2000-01-01
      4.7
      6.3
      4.1
      4.4
      5.0
      2.8
      2.8
      3.5
      3.7
      3.7
      ...
      2.4
      3.7
      4.6
      3.1
      2.7
      2.6
      4.9
      5.8
      3.2
      4.1

      2000-02-01
      4.7
      6.3
      4.1
      4.3
      5.0
      2.8
      2.7
      3.6
      3.7
      3.6
      ...
      2.4
      3.7
      4.6
      3.1
      2.6
      2.5
      4.9
      5.6
      3.2
      3.9

      2000-03-01
      4.6
      6.3
      4.0
      4.3
      5.0
      2.7
      2.6
      3.6
      3.7
      3.6
      ...
      2.4
      3.8
      4.5
      3.1
      2.6
      2.4
      5.0
      5.5
      3.3
      3.9

      2000-04-01
      4.6
      6.3
      4.0
      4.3
      5.1
      2.7
      2.5
      3.7
      3.7
      3.7
      ...
      2.4
      3.8
      4.4
      3.1
      2.7
      2.4
      5.0
      5.4
      3.4
      3.8

      2000-05-01
      4.5
      6.3
      4.0
      4.2
      5.1
      2.7
      2.4
      3.7
      3.7
      3.7
      ...
      2.4
      3.9
      4.3
      3.2
      2.7
      2.3
      5.1
      5.4
      3.5
      3.8

5 rows × 50 columns

```python
#subset of the columns
states = [
    "Arizona", "California", "Florida", "Illinois",
    "Michigan", "New York", "Texas"
]
unemp = unemp_all[states]
unemp.head()
```

      state
      Arizona
      California
      Florida
      Illinois
      Michigan
      New York
      Texas

      Date

      2000-01-01
      4.1
      5.0
      3.7
      4.2
      3.3
      4.7
      4.6

      2000-02-01
      4.1
      5.0
      3.7
      4.2
      3.2
      4.7
      4.6

      2000-03-01
      4.0
      5.0
      3.7
      4.3
      3.2
      4.6
      4.5

      2000-04-01
      4.0
      5.1
      3.7
      4.3
      3.3
      4.6
      4.4

      2000-05-01
      4.0
      5.1
      3.7
      4.3
      3.5
      4.6
      4.3

## Exercise 1

**Looking at the unemp DataFrame above, can you identify the index? The columns? You can use the cell below to verify your visual intuition.**

```python
#index
unemp.index
```

    DatetimeIndex(['2000-01-01', '2000-02-01', '2000-03-01', '2000-04-01',
                   '2000-05-01', '2000-06-01', '2000-07-01', '2000-08-01',
                   '2000-09-01', '2000-10-01',
                   ...
                   '2017-03-01', '2017-04-01', '2017-05-01', '2017-06-01',
                   '2017-07-01', '2017-08-01', '2017-09-01', '2017-10-01',
                   '2017-11-01', '2017-12-01'],
                  dtype='datetime64[ns]', name='Date', length=216, freq=None)

```python
#columns
unemp.columns
```

    Index(['Arizona', 'California', 'Florida', 'Illinois', 'Michigan', 'New York',
           'Texas'],
          dtype='object', name='state')

## Exercise 2

**Do the following exercises in separate code cells below:**

1. **At each date, what is the minimum unemployment rate across all states in our sample?**

```python
# min unemployment rate by state
unemp.min()

#alternatively, we can use unemp.agg(min)
```

    state
    Arizona       3.6
    California    4.5
    Florida       3.1
    Illinois      4.2
    Michigan      3.2
    New York      4.2
    Texas         3.9
    dtype: float64

```python
# The argument axis=1 allows to search within rows. This gives the min overall for a given date
unemp.min(axis=1)
```

    Date
    2000-01-01    3.3
    2000-02-01    3.2
    2000-03-01    3.2
    2000-04-01    3.3
    2000-05-01    3.5
                 ...
    2017-08-01    4.0
    2017-09-01    3.9
    2017-10-01    3.9
    2017-11-01    3.9
    2017-12-01    3.9
    Length: 216, dtype: float64

2. **What was the median unemployment rate in each state?**

```python
# median unemployment rate by state
unemp.median()
```

    state
    Arizona       5.80
    California    6.50
    Florida       5.35
    Illinois      6.15
    Michigan      7.00
    New York      5.70
    Texas         5.40
    dtype: float64

3. **What was the maximum unemployment rate across the states in our sample? What state did it happen in? In what month/year was this achieved?**
        Hint 1: What Python type (not dtype) is returned by the aggregation?
        Hint 2: Read documentation for the method idxmax

```python
# max unemployment rate across all states and Year
unemp.max().max()
```

    14.6

```python
#type(unemp)
unemp.max().idxmax()

#if we use unemp.max() we get the max value across all states
#but we want to know more:
    #we want the state that had the max of all max unemployment rates and the month/year in which it has been achieved
```

    'Michigan'

```python
unemp.max(axis=1).idxmax()

#we can check whether for this date Michigan registered indeed the highest value by doing unemp.loc["06/01/2009",:]
#we will get True
```

    Timestamp('2009-06-01 00:00:00')

4. **Classify each state as high or low volatility based on whether the variance of their unemployment is above or below 4.**

```python
# high or low volatility

# 1. we create our aggregation function

def high_or_low_vol(v):
    if v.var() > 4:
        out = "High"
    else:
        out = "Low"

    return out

# 2. we apply the function via the agg method

unemp.agg(high_or_low_vol)
```

    state
    Arizona       High
    California    High
    Florida       High
    Illinois       Low
    Michigan      High
    New York       Low
    Texas          Low
    dtype: object

## Exercise 3

**Imagine that we want to determine whether unemployment was high (> 6.5),medium (4.5 < x <= 6.5), or low (<= 4.5) for each state and each month.**

1. **Write a Python function that takes a single number as an input and outputs a single string noting if that number is high, medium, or low.**

```python
# Part 1: Write a Python function to classify unemployment levels.

def unemployment_levels(ul):
    if ul > 6.5:
        return "high"
    elif 4.5 < ul <= 6.5:
    #elif ul < 6.5: #equivalent to the line above
        return "medium"
    else:
        return "low"

    return ul
```

2. **Pass your function to `applymap` (quiz: why `applymap` and not `agg` or `apply`?) and save the result in a new DataFrame called `unemp_bins`.**

```python
# Part 2: Pass your function from part 1 to applymap
#Apply map allows to apply a function that takes a scalar and returns a scalar to a dataframe

unemp_bins = unemp.applymap(unemployment_levels) #replace this comment with your code!!
unemp_bins.head()
```

      state
      Arizona
      California
      Florida
      Illinois
      Michigan
      New York
      Texas

      Date

      2000-01-01
      low
      medium
      low
      low
      low
      medium
      medium

      2000-02-01
      low
      medium
      low
      low
      low
      medium
      medium

      2000-03-01
      low
      medium
      low
      low
      low
      medium
      low

      2000-04-01
      low
      medium
      low
      low
      low
      medium
      low

      2000-05-01
      low
      medium
      low
      low
      low
      medium
      low

   3. **(Challenging) Use another transform on `unemp_bins` to count how many times each state had each of the three classifications.**
    - Hint 1: Will this value counting function be a Series or scalar transform?
    - Hint 2: Try googling "pandas count unique value" or something similar to find the right transform.

```python
# Part 3: Count the number of times each state had each classification.

# we count the number of times each state had each classification.
def count_bins(cb):
    var = cb.value_counts()
    return var

unemp_count = unemp_bins.apply(count_bins)
unemp_count
```

      state
      Arizona
      California
      Florida
      Illinois
      Michigan
      New York
      Texas

      high
      75
      106
      68
      91
      142
      65
      51

      low
      44
      4
      69
      19
      17
      22
      58

      medium
      97
      106
      79
      106
      57
      129
      107

```python
# alternative solution: value_counts is a series function (columns) so I loop over columns to have the frequencies for each states, then append each series
unemp_freq_cat = pd.DataFrame()
for iter in unemp_bins.columns:
    series=unemp_bins[iter].value_counts()
    unemp_freq_cat=unemp_freq_cat.append(series)
unemp_freq_cat=unemp_freq_cat[['low', 'medium', 'high']]
# We can transpose the table to make it more readable, matter of preferences
unemp_freq_catT=unemp_freq_cat.T
unemp_freq_catT
```

      Arizona
      California
      Florida
      Illinois
      Michigan
      New York
      Texas

      low
      44.0
      4.0
      69.0
      19.0
      17.0
      22.0
      58.0

      medium
      97.0
      106.0
      79.0
      106.0
      57.0
      129.0
      107.0

      high
      75.0
      106.0
      68.0
      91.0
      142.0
      65.0
      51.0

4. **Construct a horizontal bar chart of the number of occurrences of each level with one bar per state and classification (21 total bars).**

```python
## then make a horizontal bar chart here
unemp_count.T.plot.barh(figsize=(8,8))
unemp_count.plot.barh(figsize=(8,8))
```

5. **(Challenging) Repeat the previous step, but count how many states had each classification in each month. Which month had the most states with high unemployment? What about medium and low?**

```python
# Part 4: Apply the same transform from part 4, but to each date instead of to each state.

#a solution based on this lecture has not been found :p
#alternative solution: we transpose the previous table in order to be able to apply the same function as before
    #for more informtion on reshaping the DataFrames, check Lecture 6
unemp_binsT = unemp_bins.T
unemp_binsT

#we call the function previously created for the transposed DataFrame
unemp_countT = unemp_binsT.apply(count_bins)
unemp_countT.T.head()

#we create a for loop to answer the question
times = ["high", "medium", "low"]

for x in times:
    m=unemp_countT.T[x].idxmax()

    print(f"The month with {x} unemployment is {m}.")
```

    The month with high unemployment is 2009-04-01 00:00:00.
    The month with medium unemployment is 2001-09-01 00:00:00.
    The month with low unemployment is 2000-08-01 00:00:00.

## Exercise 4

1. **For a single state of your choice, determine what the mean unemployment is during "Low", "Medium", and "High" unemployment times (recall your `unemp_bins` DataFrame from the exercise above).**
    - Think about how you would do this for all the states in our sample and write your thoughts... We will soon learn tools that will *greatly* simplify operations like this that operate on distinct *groups* of data at a time.

```python
#The analysis is performed on Arizona

times = ["high", "medium", "low"]

for x in times:
    Arizona_x  = unemp["Arizona"].loc[unemp_bins["Arizona"]==x].mean()

    print(f"During {x} unemployment times, the mean unemployment in Arizona is {Arizona_x}.")
```

    During high unemployment times, the mean unemployment in Arizona is 8.73333333333333.
    During medium unemployment times, the mean unemployment in Arizona is 5.436082474226807.
    During low unemployment times, the mean unemployment in Arizona is 4.0636363636363635.

```python
#Alternative solution:

# I chose Arizona. I extract both values and bin series, rename them and concatenate it into an new data frame.
Arizona_bins=pd.Series(unemp_bins['Arizona'], name='Arizona_Bins')
Arizona_val=pd.Series(unemp['Arizona'], name='Arizona_Values')
Arizona_valbins = pd.concat([Arizona_bins, Arizona_val], axis=1)
Arizona_valbins.head()
Arizona_valbins.groupby('Arizona_Bins')['Arizona_Values'].mean()
```

    Arizona_Bins
    high      8.733333
    low       4.063636
    medium    5.436082
    Name: Arizona_Values, dtype: float64

```python
#Now, we perform the same analysis on all countries

states = ["Arizona", "California", "Florida", "Illinois",
          "Michigan", "New York", "Texas"]

for y in states:
    for x in times:
        y_x  = unemp[y].loc[unemp_bins[y]==x].mean()

        print(f"{y} - {x}: {y_x}.")
```

    Arizona - high: 8.73333333333333.
    Arizona - medium: 5.436082474226807.
    Arizona - low: 4.0636363636363635.
    California - high: 9.249056603773582.
    California - medium: 5.454716981132074.
    California - low: 4.5.
    Florida - high: 9.155882352941177.
    Florida - medium: 5.331645569620254.
    Florida - low: 3.807246376811594.
    Illinois - high: 8.740659340659343.
    Illinois - medium: 5.618867924528302.
    Illinois - low: 4.35263157894737.
    Michigan - high: 8.76267605633803.
    Michigan - medium: 5.4070175438596495.
    Michigan - low: 3.8764705882352946.
    New York - high: 8.16923076923077.
    New York - medium: 5.3612403100775206.
    New York - low: 4.340909090909091.
    Texas - high: 7.552941176470585.
    Texas - medium: 5.567289719626171.
    Texas - low: 4.298275862068967.

2. **Which states in our sample performs the best during "bad times?" To determine this, compute the mean unemployment for each state only for months in which the mean unemployment rate in our sample is greater than 7.**

```python
# Between bracket, the condition that the value is greater than 7
df=unemp[unemp>7].mean()
df=df.loc[df==min(df)]
df
# There must be a way to be more concise
```

    state
    Texas    7.972727
    dtype: float64

```python
#Alternative solution
def unemployment_levels2(ul2):
    if ul2 > 7:
        return "High"
    else:
        return "Low"

    return ul2

unemp_bins2 = unemp.applymap(unemployment_levels2)

for s in states:
    s_high  = unemp[s].loc[unemp_bins2[s]=="High"].mean()

    print(f"{s}: {s_high}")

#The 3 states that perform the best during high unemployment times (>7) are, in ascending order, Texas, New York, Arizona
```

    Arizona: 9.038461538461535
    California: 10.234210526315788
    Florida: 9.568965517241377
    Illinois: 9.423529411764704
    Michigan: 9.465384615384618
    New York: 8.286666666666667
    Texas: 7.972727272727272