# Please download the new class notes.
### Step 1 : Navigate to the directory where your files are stored.
Open a terminal.

Using `cd`, navigate to *inside* the ILAS_Python_for_engineers folder on your computer.

### Step 3 : Update the course notes by downloading the changes
In the terminal type:

>`git add -A`

>`git commit -m "commit"`

>`git fetch upstream`

>`git merge -X theirs upstream/master`

# Introduction to Data Structures and Imported Libraries (Numpy)

 Data Structures

 Lists

    __Indexing__

    Manipulating Lists

       FindingLengthList

       SortingLists

       Removing an Item from a List

       Adding an Item to a List

       Changing a List Entry

    __Nested Data Structures__

    __Iterating over Lists__

    __Indexing when Iterating over Lists__

    `enumerate`

    __`zip`__

    __Example: The Dot Product__

 Libraries

    __The Standard Library__

    __Packages__

       __Importing a Package__

       __Using Package Functions__

       __Reading Function Documentation__

       __Example: `numpy.cos`__

       Namespaces

       __Importing a Function__

    __Using Package Functions to Optimise your Code__

    __Data Structures as Function Arguments__

 The Numpy Array

    __Multi-Dimensional Arrays__

    __Creating a Numpy Array__

    __Indexing into Multi-Dimensional Arrays__-

    Boolean Array Indexing

     __Iterating over Multi-Dimensional Arrays__

    Manipulating Arrays

       Appending Arrays

       Adding Elements to an Array

       Deleting Items from an Array

       Changing Items in an Array

 Magic Functions

 __Stacking Functions__

 __Importing Data from a .csv File to a Numpy Array__

 Summary

 Test-Yourself Exercises

 Review Exercises

### Lesson Goal

Importing data from a .csv file and perfoming calculations on it using library functions.

### Fundamental programming concepts
 - Working with external files to import:
  - Code
  - Data
 - Storing and representing data e.g. arrays and graphs

## Data Structures

In the last seminar we learnt to generate a range of numbers for use in control flow of  a program, using the function `range()`:

       for j in range(20):
           ...

Often we want to manipulate data that is more meaningful than ranges of numbers.

These collections of variables might include:
 - the results of an experiment
 - a list of names
 - the components of a vector
 - a telephone directory with names and associated numbers.

Python has different __data structures__ that can be used to store and manipulate these values.

Like variable types (`string`, `int`,`float`...) different data structures behave in different ways.

Today we will learn to use `list`s and `array`'s.

Example

If we want to store the names of students in a laboratory group,
rather than representing each students using an individual string variable, we could use a list of names.

```python
lab_group0 = ["Yukari", "Sajid", "Hemma", "Ayako"]
lab_group1 = ["Sara", "Mari", "Quang", "Sam", "Ryo", "Nao", "Takashi"]

print(lab_group0)
print(lab_group1)
```

    ['Yukari', 'Sajid', 'Hemma', 'Ayako']
    ['Sara', 'Mari', 'Quang', 'Sam', 'Ryo', 'Nao', 'Takashi']

This is useful because we can perform operations on lists such as:
 - checking its length (number of students in a lab group)
 - sorting the names in the list into alphabetical order
 - making a list of lists (we call this a *nested list*):

```python
lab_groups = [lab_group0, lab_group1]
```

## Lists

A list is a sequence of data.

We call each item in the sequence an *element*.

A list is constructed using square brackets:

```python
a = [1, 2, 3]
```

A `range` can be converted to a list with the `list` function (casting).

```python
print(list(range(10)))
```

    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

A list can hold a mixture of types (`int`, `string`....).

```python
a = [1, 2.0, "three"]
```

An empty list is created by

```python
my_list = []
```

A list of length 5 with repeated values can be created by

```python
my_list = ["Hello"]*5
print(my_list)
```

    ['Hello', 'Hello', 'Hello', 'Hello', 'Hello']

We can check if an item is in a list using the function `in`:

```python
print("Hello" in my_list)
print("Goodbye" in my_list)
```

    True
    False

### Indexing

Lists store data in order.

We can select a single element or multiple elements of a list using the __index__ of the element(s).

You are familiar with this process; it is the same as selecting individual characters of a string:

```python
word = "string"

letter = word[1]

print(letter)
```

    t

```python
lab_group0 = ["Sara", "Mari", "Quang", "Sam", "Ryo", "Nao", "Takashi"]

first_member = lab_group0[0]

print(first_member)
```

    Sara

If we select multiple elements they are returned as a list:

```python
lab_group0 = ["Sara", "Mari", "Quang", "Sam", "Ryo", "Nao", "Takashi"]

first_members = lab_group0[0:2]

print(first_members)
```

    ['Sara', 'Mari']

We can select the individual characters of a string using a second index.

For example to select the first letter of the second group member's name:

```python
lab_group0 = ["Sara", "Mari", "Quang", "Sam", "Ryo", "Nao", "Takashi"]

letter = lab_group0[1][0]

print(letter)
```

    M

### Manipulating Lists

There are many functions for manipulating lists.

Many of these functions apply to other data structures.

### Finding the Length of a List

We can find the length (number of items) of a list using the function `len()`, by including the name of the list in the brackets.

In the example below, we find the length of the list `lab_group0`.

```python
lab_group0 = ["Sara", "Mari", "Quang"]

size = len(lab_group0)

print("Lab group members:", lab_group0)

print("Size of lab group:", size)

print("Check the Python object type:", type(lab_group0))
```

    Lab group members: ['Sara', 'Mari', 'Quang']
    Size of lab group: 3
    Check the Python object type: <class 'list'>

### Sorting Lists

To sort the list we use the function `sorted()`.

#### Sorting Numerically

If the list contains numerical variables, the numbers is sorted in ascending order.

```python
numbers = [7, 1, 3.0]

print(numbers)

numbers = sorted(numbers)

print(numbers)
```

    [7, 1, 3.0]
    [1, 3.0, 7]

__Note:__ We can sort a list with mixed numeric types (e.g. `float` and `int`).

However, we cannot sort a list with types that cannot be sorted by the same ordering rule.

(e.g. `numbers = sorted(["7", 1, 3.0])` causes an error.)

```python

```

#### Sorting Alphabetically

If the list contains strings of alphabet characters, the list is sorted by alphabetical order.

```python
lab_group0 = ["Sara", "Mari", "Quang"]

print(lab_group0)

lab_group0 = sorted(lab_group0)

print(lab_group0)
```

    ['Sara', 'Mari', 'Quang']
    ['Mari', 'Quang', 'Sara']

As with `len()` we include the name of the list we want to sort in the brackets.

`sort` is known as a 'method' of a `list`.

If we suffix a list with `.sort()`, it performs an *in-place* sort.

```python
lab_group0 = ["Sara", "Mari", "Quang"]

print(lab_group0)

#lab_group0 = sorted(lab_group0)
lab_group0.sort()

print(lab_group0)
```

    ['Sara', 'Mari', 'Quang']
    ['Mari', 'Quang', 'Sara']

__Try it yourself__

In the cell provided in your textbook create a list of __numeric__ or __string__ values.

Sort the list using `sorted()` __or__ `.sort()`.

Print the sorted list.

Print the length of the list using `len()`.

```python
# Sorting a list
```

### Removing an Item from a List

We can remove items from a list using the method `pop`.

We place the index of the element we wish to remove in brackets.

```python
lab_group0 = ["Sara", "Mari", "Quang", "Sam", "Ryo"]
print(lab_group0)

# Remove the second student from the list: lab_group (remember indexing starts from 0 so 1 is the second element)

lab_group0.pop(1)
print(lab_group0)
```

    ['Sara', 'Mari', 'Quang', 'Sam', 'Ryo']
    ['Sara', 'Quang', 'Sam', 'Ryo']

```python
# By default, pop removes the last element

lab_group0.pop()
print(lab_group0)
```

    ['Sara', 'Quang', 'Sam']

```python
# Pop can by used to assign the removed value to a variable name

group_member = lab_group0.pop(1)
print(lab_group0)
print(group_member)
```

    ['Sara', 'Sam']
    Quang

### Adding an Item to a List

We can add items to a list using the method `insert`.

We place the desired index of new element in brackets.

```python
# Add new student "Mark" to the list
lab_group0.insert(2, "Mark")
print(lab_group0)
```

    ['Sara', 'Sam', 'Mark']

We can add items at the end of a list using the method `append`.

We place the element we want to add to the end of the list in brackets.

```python
# Add new student "Lia" at the end of the list
lab_group0.append("Lia")
print(lab_group0)
```

    ['Sara', 'Sam', 'Mark', 'Lia']

### Changing a List Entry.
We can change the entry of a list using indexing.

```python
lab_group0[3] = "Am"
print(lab_group0)

# Adding and removing items from a list.
```

    ['Sara', 'Sam', 'Mark', 'Am']

__Try it yourself__

In the cell provided in your textbook.

Remove "Sara" from the list.

Print the new list.

Add a new lab group member, Tom, to the list.

Print the new list.

```python
lab_group0 = ["Sara", "Mari", "Quang", "Sam", "Ryo"]
print(lab_group0)

# Adding and removing items from a list.
```

    ['Sara', 'Mari', 'Quang', 'Sam', 'Ryo']

### Nested Data Structures: Lists of Lists

A *nested list* is a list within a list.

(Recall *nested loops* from the last seminar 1; Control Flow).

To access a __single element__ we need as many indices as there are levels of nested list.

This is more easily explained with an example:

`lab_groups` is a nested list containing the lists:
 - `lab_group0`
 - `lab_group1`
 - `lab_group2`

```python
lab_group0 = ["Sara", "Mika", "Ryo", "Am"]
lab_group1 = ["Hemma", "Miri", "Quy", "Sajid"]
lab_group2 = ["Adam", "Yukari", "Farad", "Fumitoshi"]

lab_groups = [lab_group0, lab_group1, lab_group2]
```

To select and element from `lab_group1` we:
- first give the index of `lab_group1` in th list `lab_groups`
- second give the index of the element within `lab_group1`

```python
group = lab_groups[1]
print(group)

name = lab_groups[1][2]
print(name)
```

    ['Hemma', 'Miri', 'Quy', 'Sajid']
    Quy

### Iterating over Lists

Looping over each item in a list is called *iterating*.

To iterate over a list of the lab group we  can use a `for` loop.

In the following example, each iteration, variable `d` takes the value of the next item in the list:

```python
for d in [1, 2.0, "three"]:
    print("the value of d is:", d)
```

    the value of d is: 1
    the value of d is: 2.0
    the value of d is: three

We could also express this as:

```python
data = [1, 2.0, "three"]

for d in data:
    print("the value of d is:", d)
```

    the value of d is: 1
    the value of d is: 2.0
    the value of d is: three

Iterating backwards over a list can be acheived using the built in `reversed` function.

```python
data = [1, 2.0, "three"]

for d in reversed(data):
    print("the value of d is:", d)
```

    the value of d is: three
    the value of d is: 2.0
    the value of d is: 1

__Try it yourself__

In the cell provided in your textbook *iterate* over the list `data = [1, 2.0, "three"]`.

Each time the code loops:
1. print the value of data __cast as a string__ (Seminar 1 Data Types and Operators)
1. print the variable type
(to demonstrate that the variable has been cast. Note that otherwise the variable appears to remain unchanged).

```python
# Iterate over a list and cast each item as a string
data = [1, 2.0, "three"]
```

### Indexing when Iterating over Lists
Indexing can be useful when iterating over a list.

For example, we can select a range of elements to iterate over:

```python
lab_group0 = ["Sara", "Mari", "Quang", "Sam", "Ryo", "Nao", "Takashi"]

for member in lab_group0[2:5]:
    print("name:", member)
```

    name: Quang
    name: Sam
    name: Ryo

A third value can used to choose a step size (similar to `range()`).

For example, if we want to choose every other lab member we use step size, 2:

```python
lab_group0 = ["Sara", "Mari", "Quang", "Sam", "Ryo", "Nao", "Takashi"]

for member in lab_group0[::2]:
    print("name:", member)
```

    name: Sara
    name: Quang
    name: Ryo
    name: Takashi

__Note:__

Some data structures that support *iterating* but do not support *indexing*.

e.g. dictionaries, which we will learn about later.

When possible, it is better to iterate over a list rather than use indexing.

### `enumerate()`
The function `enumerate` can be used to return the index of each element.

This information is cast as a list to allow us to read it.

```python
lab_group0 = ["Sara", "Mari", "Quang", "Sam", "Ryo", "Nao", "Takashi"]
a = enumerate(lab_group0)
b = list(enumerate(lab_group0))
print(a)
print(b)

```

    <enumerate object at 0x7fc8b8810678>
    [(0, 'Sara'), (1, 'Mari'), (2, 'Quang'), (3, 'Sam'), (4, 'Ryo'), (5, 'Nao'), (6, 'Takashi')]

```python
string = "string"
a = list(enumerate(string))
print(a)
```

    [(0, 's'), (1, 't'), (2, 'r'), (3, 'i'), (4, 'n'), (5, 'g')]

### Iterating Over Multiple Lists Using `zip()`
It can be very useful to iterate through multiple lists within the same loop. than one list.

For example if we have a list of group members and a list of their scores for an assignemt, we can print the score that corresponds to each lab member:

```python
lab_group0 =  ["Sara", "Mari", "Quang", "Sam", "Ryo", "Nao", "Takashi"]

assignment1 = [72, 56, 65, 52, 71, 60]

for member, score in zip(lab_group0, assignment1):

    print(member, ": score =", score)
```

    Sara : score = 72
    Mari : score = 56
    Quang : score = 65
    Sam : score = 52
    Ryo : score = 71
    Nao : score = 60

In this example

`member` is the name given to the *current* value from the list `lab_group0`

`score` is the names given to the *current* value from the list `assignment1`.

We can include any number of lists in `zip`.

For example it may be useful to print the combined score a lab member has achieved for all assigments this semester:

```python
lab_group0 =  ["Sara", "Mari", "Quang", "Sam", "Ryo", "Nao", "Takashi"]

assignment1 = [72, 56, 65, 52, 71, 60]
assignment2 = [52, 61, 73, 55, 62, 55]
assignment3 = [71, 71, 70, 66, 61, 71]

for member, score1, score2, score3 in zip(lab_group0,
                                          assignment1,
                                          assignment2,
                                          assignment3):
    print(member, ": score =", (score1 + score2 + score3))
```

    Sara : score = 195
    Mari : score = 188
    Quang : score = 208
    Sam : score = 173
    Ryo : score = 194
    Nao : score = 186

### Example: The Dot Product (Representing Vectors using Lists)

__Vector:__ A quantity with magnitude and direction.

The position vector $\mathbf{r}$ indicates the position of a point in 3D space.
$\mathbf{r}$ can be expressed in terms of x,y, and z-directions.

$$
\mathbf{r} = x\mathbf{i} + y\mathbf{j} + z\mathbf{k}
$$

$\mathbf{i}$ is the displacement one unit in the x-direction

$\mathbf{j}$ is the displacement one unit in the y-direction

$\mathbf{k}$ is the displacement one unit in the z-direction

We can conveniently express $\mathbf{r}$ in matrix (or basis vector) form using the coefficients $x, y$ and $z$:
$$
\mathbf{r} = [x, y, z]
$$

__...which looks a lot like a Python list!__

You will encounter 3D vectors a lot in your engineering studies.

They are used to describe many physical quantities, e.g. force.

The __dot product__ is a really useful algebraic operation.

It takes two equal-length *sequences of numbers* (often coordinate vectors) and returns a single number.

__ALGEBRAIC REPRESENTATION OF THE DOT PRODUCT__

The dot product of two $n$-length-vectors:

 $ \mathbf{A} = [A_1, A_2, ... A_n]$

 $ \mathbf{B} = [B_1, B_2, ... B_n]$

\begin{align}
\mathbf{A} \cdot \mathbf{B} = \sum_{i=1}^n A_i B_i
\end{align}

So the dot product of two 3D vectors:

 $ \mathbf{A} = [A_x, A_y, A_z]$

 $ \mathbf{B} = [B_x, B_y, B_z]$

\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}

__Example : Dot Product__

Let's write a program to solve this using a Python `for` loop.

1. We initailise a variable, `dot_product` with a value = 0.0.

1. With each iteration of the loop:

`dot_product +=` the product of `a` and `b`.

```python
# Example : Dot Product

A = [1.0, 3.0, -5.0]
B = [4.0, -2.0, -1.0]

# Create a variable called dot_product with value, 0.0
dot_product = 0.0

# Update the value each time the code loops
for a , b in zip(A, B):
    dot_product += a * b

# Print the solution
print(dot_product)
```

    3.0

(Solution in 02_DataStructures_LibraryFunctions_SOLS.ipynb)

__Check Your Solution:__

The dot product $\mathbf{A} \cdot \mathbf{B}$:

 $ \mathbf{A} = [1, 3, −5]$

 $ \mathbf{B} = [4, −2, −1]$

\begin{align}
      {\displaystyle {\begin{aligned}\ [1,3,-5]\cdot [4,-2,-1]&=(1)(4)+(3)(-2)+(-5)(-1)\\& = 4 \qquad - 6 \qquad + 5 \\&=3\end{aligned}}}
\end{align}

## Libraries

One of the most important concepts in good programming is to reuse code and avoid repetitions.

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.

## The Standard Library

Python has a large standard library.

e.g. `print()` takes the __input__ in the parentheses and __outputs__ a visible representation.

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

We could write our own code to find the minimum of a group of numbers

```python
x0 = 1
x1 = 2
x2 = 4

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

print(x_min)
```

    1

However, it is much faster to use the build-in function:

```python
print(min(1,2,4))
```

    1

Python built-in functions are simply a collection of Python (.py) files called 'modules'.

These files are stored on the computer you are using.

__Function:__

A function is 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
a = [5, 2, 3, 1, 4]
print(sorted(a))
```

    [1, 2, 3, 4, 5]

__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()
print(a)
```

    [1, 2, 5, 5, 7]

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

```python
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.

## Packages

The standard library tools are available in any Python environment.

More specialised libraries, called packages, are available for more specific tasks

e.g. solving trigonometric functions.

Packages contain functions and constants.

We install the packages to use them.

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

The files that are stored on your computer when Numpy is installed:

https://github.com/numpy/numpy

### Importing a Package

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

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

## Using Package Functions.

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

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

x = 1

# Trigonometric sine
print(np.sin(x))

# Compute tangent
print(np.tan(x))

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

```

    0.841470984808
    1.55740772465
    0.785398163397

```python
x = 1

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

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

    57.2957795131
    1.0

## Reading Function Documentation

Online documentation can be used to find out:
- what to include in the () parentheses
- allowable data types to use as arguments
- the order in which arguments should be given

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

__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.
```

### Example : numpy.cos
Documentation : https://docs.scipy.org/doc/numpy-1.13.0/reference/routines.math.html

The documentation tells us the following information...

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

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?

## 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.54030230586813977

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

```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

##### 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

Let's look at some examples of where Numpy functions can make your code shorter and neater.

The mean of a group of numbers

```python
x_mean = (1 + 2 + 3)/3
```

Using Numpy:

```python
x_mean = np.mean([1, 2, 3])
```

## Data Structures as Function Arguments.

Notice that the Numpy function `mean` take a lists as its argument.

The list data structure is required for the function to work.

```python
ls = [1, 2, 3]
x_mean = np.mean(ls)
```

### 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.]

Elementwise operation can be particularly important when performing basic mathematical operations:

```python
a = [1, 2, 3]
b = [4, 5, 6]
import numpy as np

print(a + b)
print(np.add(a,b))
```

    [1, 2, 3, 4, 5, 6]
    [5 7 9]

Numpy has its own data structure that is more suitable for handling numerical data.

## The Numpy `Array`

A numpy array is a grid of values, *all of the same type*.

### Why do we need another data structure?

Python lists hold 'arrays' of data.

Lists are very flexible. e.g. holding mixed data type.

There is a trade off between flexibility and performance e.g. speed.

Science engineering and mathematics problems often involve large amounts of data and numerous operations.

We therefore use specialised functions and data structures for numerical computation.

To create an array we use the Numpy `np.array()` function.

We can create an array in a number of ways.

For example we can convert a list to an array.

```python
c = [4.0, 5, 6.0]

d = np.array(c)

print(type(c))
print(type(d))
print(d.dtype)

print(c + c)
print(d + d)
```

    <class 'list'>
    <class 'numpy.ndarray'>
    float64
    [4.0, 5, 6.0, 4.0, 5, 6.0]
    [  8.  10.  12.]

The method `dtype` tells us the type of the data contained in the array.

## Multi-Dimensional Arrays.

Unlike the data types we have studied so far, arrays can have multiple dimensions.

__`shape`:__ a *tuple* of *integers* giving the *size* of the array along each *dimension*.

__`tuple`:__ A data structure from which you cannot add or remove elements without creating a new tuple (e.g. connecting two tuples).
You cannot change the value of a single tuple element e.g. by indexing.
A tuple is created by enclosing a set of numbers in () parentheses.

We define the dimensions of an array using square brackets

```python
# 1-dimensional array
a = np.array([1, 2, 3])

# 2-dimensional array
b = np.array([[1, 2, 3], [4, 5, 6]])

b = np.array([[1, 2, 3],
              [4, 5, 6]])

print(a.shape)
print(b.shape)

```

    (3,)
    (2, 3)

```python
# 2-dimensional array
c = np.array([[1, 2, 3]])

# 2-dimensional array
d = np.array([[1],
              [4]])

print(c.shape)
print(d.shape)
```

    (1, 3)
    (2, 1)

```python
# 3-dimensional array

c = np.array(
    [[[1, 1],
      [1, 1]],

     [[1, 1],
      [1, 1]]])

print(c.shape)

c = np.array(
    [[[1, 1],
      [1, 1]],

     [[1, 1],
      [1, 1]],

     [[1, 1],
      [1, 1]]])

print(c.shape)
```

    (2, 2, 2)
    (3, 2, 2)

```python
# 3-dimensional array

c = np.array(
    [[[1, 1],
      [1, 1]],

     [[1, 1],
      [1, 1]]])

# 4-dimensional array
d = np.array(
    [[[[1, 1],
       [1, 1]],

      [[1, 1],
       [1, 1]]],

      [[[1, 1],
       [1, 1]],

      [[1, 1],
       [1, 1]]]])

print(c.shape)
print(d.shape)
```

    (2, 2, 2)
    (2, 2, 2, 2)

## Creating a Numpy Array.

We don't always have to manually create the individual elements of an array.

There are several other ways to do this.

For example, if you don’t know what data you want to put in your array you can initialise it with placeholders and load the data you want to use later.

```python
# Create an array of all zeros
# The zeros() function argument is the shape.
# Shape: tuple of integers giving the size along each dimension.

a = np.zeros(5)
print(a)

print()

a = np.zeros((2,2))
print(a)
```

    [ 0.  0.  0.  0.  0.]

    [[ 0.  0.]
     [ 0.  0.]]

```python
# Create an array of all ones

b = np.ones(5)
print(b)

print()

b = np.ones((1, 4))
print(b)

```

    [ 1.  1.  1.  1.  1.]

    [[ 1.  1.  1.  1.]]

```python
# Create an array of elements with the same value
# The full() function arguments are
# 1) Shape: tuple of integers giving the size along each dimension.
# 2) The constant value

y = np.full((1,1), 3)
print(y)
print(y.shape)

print()

y = np.full((2,2), 4)
print(y)
```

    [[3]]
    (1, 1)

    [[4 4]
     [4 4]]

```python
# Create a 1D array of evenly spaced values
# The arange() function arguments are the same as the range() function.
# Shape: tuple of integers giving the size along each dimension.

z = np.arange(5,10)
print(z)

print()

z = np.arange(5, 10, 2)
print(z)
```

    [5 6 7 8 9]

    [5 7 9]

```python
# Create a 1D array of evenly spaced values
# The linspace() function arguments are
# The lower limit of the range of values
# The upper limit of the range of values (inclusive)
# The desired number of equally spaced values

z = np.linspace(-4, 4, 5)
print(z)
```

    [-4. -2.  0.  2.  4.]

```python
# Create an empty matrix
# The empty() function argument is the shape.
# Shape: tuple of integers giving the size along each dimension.
import numpy as np
x = np.empty((4))
print(x)

print()

x = np.empty((4,4))
print(x)
```

    [ 4.  2.  2.  4.]

    [[  4.94065646e-324   4.94065646e-324   4.94065646e-324   4.94065646e-324]
     [  4.94065646e-324   4.94065646e-324   4.94065646e-324   4.94065646e-324]
     [  4.94065646e-324   4.94065646e-324   4.94065646e-324   4.94065646e-324]
     [  4.94065646e-324   4.94065646e-324   4.94065646e-324   4.94065646e-324]]

```python
# Create a constant array
# The second function argument is the constant value

c = np.full(6, 8)
print(c)

print()

c = np.full((2,2,2), 7)
print(c)

```

    [8 8 8 8 8 8]

    [[[7 7]
      [7 7]]

     [[7 7]
      [7 7]]]

## Indexing into Multi-Dimensional Arrays.

We can index into an array exactly the same way as the other data structures we have studied.

```python
x = np.array([1, 2, 3, 4, 5])

# Select a single element
print(x[4])

# Select elements from 2 to the end
print(x[2:])
```

    5
    [3 4 5]

For an n-dimensional (nD) matrix we need n index values to address an element or range of elements.

Example: The index of a 2D array is specified with two values:
- first the row index
- then the column index.

Note the order in which dimensions are addressed.

```python
# 2 dimensional array

y = np.array([[1, 2, 3],
              [4, 5, 6]])

# Select a single element
print(y[1,2])

# Select elements that are both in rows 1 to the end AND columns 0 to 2
print(y[1:, 0:2])
```

    6
    [[4 5]]

We can address elements by selecting a range with a step:

For example the index:

`z[0, 0:]`

selects every element of row 0 in array, `z`

The index:

`z[0, 0::2]`

selects every *other* element of row 0 in array, `z`

```python
# 2 dimensional array

z = np.zeros((4,8))

# Change every element of row 0
z[0, 0:] = 10

# Change every other element of row 1
z[1, 0::2] = 10

print(z)
```

    [[ 10.  10.  10.  10.  10.  10.  10.  10.]
     [ 10.   0.  10.   0.  10.   0.  10.   0.]
     [  0.   0.   0.   0.   0.   0.   0.   0.]
     [  0.   0.   0.   0.   0.   0.   0.   0.]]

```python
z = np.zeros((4,8))

# Change the last 4 elements of row 2, in negative direction
# You MUST include a step to count in the negative direction
z[2, -1:-5:-1] = 10

# Change every other element of the last 6 elements of row 3
# in negative direction
z[3, -2:-7:-2] = 10

print(z)
```

    [[  0.   0.   0.   0.   0.   0.   0.   0.]
     [  0.   0.   0.   0.   0.   0.   0.   0.]
     [  0.   0.   0.   0.  10.  10.  10.  10.]
     [  0.   0.  10.   0.  10.   0.  10.   0.]]

```python
# 3-dimensional array

c = np.array(
    [[[2, 1, 4],
      [2, 6, 8]],

     [[0, 1, 5],
      [7, 8, 9]]])

print(c[0, 1, 2])

```

    8

Where we want to select all elements in one dimension we can use :

__Exception__: If it is the last element , we can omit it.

```python
print(c[0, 1])

print(c[0, :, 1])
```

    [2 6 8]
    [1 6]

### BooleanArrayIndexing

Recall that we can use *conditional operators* to check the value of a single variable against a condition.

The value returned is a Boolean True or False value.

```python
a = 4
print('a < 2:', a < 2)
print('a > 2:', a > 2)
```

    a < 2: False
    a > 2: True

If we instead use *conditional operators* to check the value of an array against a condition.

The value returned is an *array* of Boolean True or False values.

```python
a = np.array([[1,2],
              [3, 4],
              [5, 6]])

idx = a > 2

print(idx)

```

    [[False False]
     [ True  True]
     [ True  True]]

A particular elements of an array can be are specified by using a boolean array as an index.

Only the values of the array where the boolean array is `True` are selected.

The varaible `idx` can therefore now be used as the index to select all elements greater than 2.

```python
print(a[idx])
```

    [3 4 5 6]

To do the whole process in a single step

```python
print(a[a > 2])
```

    [3 4 5 6]

To apply multiple conditions, use () parentheses to sperate different conditions.

Use `&` for elementwise `and`.

Use `|` for elementwise `or`.

```python
x = np.array([[4, 2, 3, 1],
              [2, 4, 2, 8],
              [2, 3, 3, 27],
              [4, 1, 4, 64]])

# elements of x that are greater then 2 AND less than 10
print(x[(2 < x) & (x < 10)])

# elements of x that are less then 2 OR greater than 10
print(x[(2 < x) & (x < 10)])
```

    [4 3 4 8 3 3 4 4]
    [4 3 4 8 3 3 4 4]

Multiple conditions can also be applied to a subsection of an array.

For example to select elements $>2$ and $<4$ in the first row of `x` only (`x[0]`):

```python
x = np.array([[4, 2, 3, 1],
              [2, 4, 2, 8],
              [2, 3, 3, 27],
              [4, 1, 4, 64]])

print(x[0][(2 < x[0]) & (x[0] < 4)])

```

    [3]

## Iterating over Multi-Dimensional Arrays.
We can iterate over a 1D array in the same way as the data structures we have previously studied.

```python
A = np.array([1, 2, 3, 4, 5])
```

```python
for a in A:
    print(a)
```

    1
    2
    3
    4
    5

To loop through individual elements of a multi-dimensional array, we use a nested loop for each dimension of the array.

```python
B = np.array([[1, 2, 3],
              [4, 5, 6]])

for row in B:
    print("-----")
    for col in row:
        print(col)
```

    -----
    1
    2
    3
    -----
    4
    5
    6

## Manipulating Arrays
We can use many of the same operations to manipulate arrays as we use for lists.

However, it is important to note a few subtle differences in how array manipulations behave.

```python
# Length of an array

a = np.array([1, 3, 4, 17, 3, 21, 2, 12])

b = np.array([[1, 3, 4, 17],
              [3, 21, 2, 12]])

print(len(a))
print(len(b))

```

    8
    2

Note the length is the length of the first dimension (e.g. indexing).

```python
# Sort an array

a = np.array([1, 3, 4, 17, 3, 21, 2, 12])

b = np.array([[1, 3, 4, 17],
              [3, 21, 2, 12]])

# The function sorted applies to 1D data structures only
print(sorted(a))
print(sorted(b[1]))

# The method sort() applies to arrays of any size
a.sort()
b.sort()

print(a)
print(b)
```

    [1, 2, 3, 3, 4, 12, 17, 21]
    [2, 3, 12, 21]
    [ 1  2  3  3  4 12 17 21]
    [[ 1  3  4 17]
     [ 2  3 12 21]]

Arrays are *immutable* (unchangeable).

Technically you cannot add or delete items of an array.

However, you can make a *new* array (which may have the same name as the original array), with the values ammended as required:

#### Appending Arrays
Appending connects array-like (integer, list....) value  to the *end* of the original array.

By default, 2D arrays are appended as if joining lists.
The new array is a 1D array

```python
# 2D array
a = np.array([[0], [1], [2]])
print(a)
print()

# 2D array
b = np.array([[3], [4]])
print(b)
print()

# 1D array
c = np.array([3, 4])
print(b)
print()

# integer
d = 1

print(f"original 2D array shapes: a = {a.shape}, b = {b.shape}")
print()

X = np.append(a, b)
print(X)
print(f"new array shape: {a.shape}")
print()

X = np.append(b, d)
print(X)
print(f"new array shape: {a.shape}")
print()

X = np.append(c, d)
print(X)
print(f"new array shape: {a.shape}")
print()
```

    [[0]
     [1]
     [2]]

    [[3]
     [4]]

    [[3]
     [4]]

    original 2D array shapes: a = (3, 1), b = (2, 1)

    [0 1 2 3 4]
    new array shape: (3, 1)

    [3 4 1]
    new array shape: (3, 1)

    [3 4 1]
    new array shape: (3, 1)

The axis on which to append an array can be optionally specified.

e.g. 2D array:
 - 0: columns
 - 1: rows

The arrays must have the same shape, except in the dimension corresponding to the specified axis

```python
# 2D array
a = np.array([[0], [1], [2]])
print(a)
print()

# 2D array
b = np.array([[3], [4]])
print(b)
print()

new2d = np.append(a, b, axis=0)
print(new2d)
print(f"new array shape: {new2d.shape}")
```

    [[0]
     [1]
     [2]]

    [[3]
     [4]]

    [[0]
     [1]
     [2]
     [3]
     [4]]
    new array shape: (5, 1)

For example, in the cell above, if you change `axis=0` to `axis=1`,

you are trying to connect the side of `a` with length=3 to the side of `b` with length=2.

There are dedicated functions to simplify joining or merging arrays.

If you are interested to expeirment further with joiing arrays you can try out the following functions:
 - `np.concatenate()` : Joins a sequence of arrays along an existing axis.
 - `np.vstack()` or `np.r_[]`: Stacks arrays row-wise
 - `np.hstack()` : Stacks arrays horizontally
 - `np.column_stack()` or `np.c_[]` : Stacks arrays column-wise
Refer to last week's seminar for how to inpterpret the function documentation.

It can also be useful to remove individual (single or multiple) elements.

For example, the following expand the locations within the array that you can change beyond the location at the *end* of the array.

#### Adding Elements to an Array

```python
# Add items to an array
# The insert() function arguments are
# 1) The array to insert to
# 2) The index of the inserted element
# 3) The value of the inserted element

a = ([1, 2, 3])
a = np.insert(a, 1, 4)
print(a)
```

    [1 4 2 3]

Notice that, again, the output is a 1D aray by default

```python
# Add items to an array

b = np.array([[1, 1],
              [2, 2],
              [3, 3]])

print(f"original array shape: {b.shape}")

b = np.insert(b, 1, [4, 4])

print(b)

print(f"new array shape: {b.shape}")
```

    original array shape: (3, 2)
    [1 4 4 1 2 2 3 3]
    new array shape: (8,)

To preserve the multi-dimensional structure of an array, we can specify the axis on which to insert an element or range of elements.

 In the example below, a column is inserted at element 1 of axis 1.

```python
# Add items to an array

b = np.array([[1, 1],
              [2, 2],
              [3, 3]])

b = np.insert(b, 1, [3, 2, 1], axis=1)
print(b)
```

    [[1 3 1]
     [2 2 2]
     [3 1 3]]

Notice what happens when we insert a *single* value on a specified axis

```python
b = np.insert(b, 1, 4, axis=1)
print(b)
```

    [[1 4 3 1]
     [2 4 2 2]
     [3 4 1 3]]

This behaviour is due to a very useful property called *broadcasting*.

We will study the rules governing broadcasting later in this seminar.

#### Deleting Items from an Array

```python
# Items are deleted from their position in a 1D array by default

z = np.array([1, 3, 4, 5, 6, 7, 8, 9])

z = np.delete(z, 3)
print(z)

z = np.delete(z, [0, 1, 2])
print(z)

```

    [1 3 4 6 7 8 9]
    [6 7 8 9]

```python
# Again, axes to delete can be optionally specified:

z = np.array([[1, 3, 4, 5], [6, 7, 8, 9]])
print(z)
print()

z = np.delete(z, 3, axis=1)
print(z)
print()

z = np.delete(z, [0, 1, 2], axis=1)
print(z)
print()
```

    [[1 3 4 5]
     [6 7 8 9]]

    [[1 3 4]
     [6 7 8]]

    []

#### Changing Items in an Array

```python
c = np.array([1, 2, 3])
c[1] = 4
print(c)
```

    [1 4 3]

### 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])
```

##### 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
```

## 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)
```

## Importing Data from  a .csv File to a Numpy Array
Real data is often stored in .csv files (typically viewed in Excel).

csv files are simply comma separated values (although the values can be separated or *delimited* by other things than commas).

A Numpy array can be a very useful way to store and manipulate data e.g. the raw data from an experiment.

Numerical data can be loaded from a .txt or .data. or .csv file using the function; `numpy.loadtxt`.

The file to be loaded must be in the same directory as your python program.

Otherwise you must specify the the full path to the data file using / to seperate directory names.

The filename (or path plus filename) needs to be between "" quotation marks.

For example the file data.dat in the folder sample_data contains:
>0.000 1.053 2.105 3.158 4.211

74.452 48.348 68.733 59.796 54.123

The default delimiter is a space.

The default data type to output is an array of floating point values.

```python
A = np.loadtxt('sample_data/sample_data.dat')
print(A)
print(type(A))
print(A[0][1])
```

    [[  1.053   2.105   3.158   4.211   6.065]
     [ 48.348  68.733  59.796  54.123  74.452]]
    <class 'numpy.ndarray'>
    2.105

The __delimiter__ should (sometimes) be specified.

This tells Python how to separate the data into the individual elements of an array.

The default delimiter is a space.

(Data separated by spaces will be automatically be assigned different indices).

The __data type__ should (sometimes) be specified.

This tells Python how to separate the data into the individual elements of an array.

The default data type is `float`.

If your data contains items that cannot be expressed as a float, importing it will cause an error unless the data-type is specified.

Mixed data types can be imported as `string` values.

For example, the data in sample_student_data.txt:
- is seperated by tabs (`/t`)
- cannot all be converted to float data (in some columns)

```Python
Subject	Sex	DOB	Height	Weight	BP
(ID)	M/F	dd/mm/yy	m	kg	mmHg
JW-1	M	19/12/1995	1.82	92.4	119/76
JW-2	M	11/01/1996	1.77	80.9	114/73
JW-3	F	02/10/1995	1.68	69.7	124/79
JW-6	M	06/07/1995	1.72	75.5	110/60
JW-7	F	28/03/1996	1.66	72.4	-
JW-9	F	11/12/1995	1.78	82.1	115/75
JW-10	F	07/04/1996	1.6	45	-/-
JW-11	M	22/08/1995	1.72	77.2	97/63
JW-12	M	23/05/1996	1.83	88.9	105/70
JW-14	F	12/01/1996	1.56	56.3	108/72
JW-15	F	01/06/1996	1.64	65	99/67
JW-16	M	10/09/1995	1.63	73	131/84
JW-17	M	17/02/1996	1.67	89.8	101/76
JW-18	M	31/07/1996	1.66	75.1	-/-
JW-19	F	30/10/1995	1.59	67.3	103/69
JW-22	F	09/03/1996	1.7	45	119/80
JW-23	M	15/05/1995	1.97	89.2	124/82
JW-24	F	01/12/1995	1.66	63.8	100/78
JW-25	F	25/10/1995	1.63	64.4	-/-
JW-26	M	17/04/1996	1.69	55	121/82
```

```python
np.loadtxt('sample_data/sample_student_data.txt', delimiter="\t", dtype=str)
```

    array([['Student', 'Sex', 'DOB', 'Height', 'Weight', 'BP'],
           ['(ID)', 'M/F', 'dd/mm/yy', 'm', 'kg', 'mmHg'],
           ['JW-1', 'M', '19/12/1995', '1.82', '92.4', '119/76'],
           ['JW-2', 'M', '11/01/1996', '1.77', '80.9', '114/73'],
           ['JW-3', 'F', '02/10/1995', '1.68', '69.7', '124/79'],
           ['JW-4', 'M', '11/01/1996', '1.77', '80.9', '114/73'],
           ['JW-5', 'F', '02/10/1995', '1.68', '69.7', '124/79'],
           ['JW-6', 'M', '06/07/1995', '1.72', '75.5', '110/60'],
           ['JW-7', 'F', '28/03/1996', '1.66', '72.4', '-'],
           ['JW-9', 'F', '11/12/1995', '1.78', '82.1', '115/75'],
           ['JW-10', 'F', '07/04/1996', '1.6', '45', '-/-'],
           ['JW-11', 'M', '22/08/1995', '1.72', '77.2', '97/63'],
           ['JW-12', 'M', '23/05/1996', '1.83', '88.9', '105/70'],
           ['JW-14', 'F', '12/01/1996', '1.56', '56.3', '108/72'],
           ['JW-15', 'F', '01/06/1996', '1.64', '65', '99/67'],
           ['JW-16', 'M', '10/09/1995', '1.63', '73', '131/84'],
           ['JW-17', 'M', '17/02/1996', '1.67', '89.8', '101/76'],
           ['JW-18', 'M', '31/07/1996', '1.66', '75.1', '-/-'],
           ['JW-19', 'F', '30/10/1995', '1.59', '67.3', '103/69'],
           ['JW-22', 'F', '09/03/1996', '1.7', '45', '119/80'],
           ['JW-23', 'M', '15/05/1995', '1.97', '89.2', '124/82'],
           ['JW-24', 'F', '01/12/1995', '1.66', '63.8', '100/78'],
           ['JW-25', 'F', '25/10/1995', '1.63', '64.4', '-/-'],
           ['JW-26', 'M', '17/04/1996', '1.69', '55', '121/82']],
          dtype='<U10')

Regions can be selected, for example to select only numerical data.

`skiprows` skips the first n lines.

`usecols` specifies which columns to read, with 0 being the first.

For example, usecols = (1,4,5) will extract the 2nd, 5th and 6th columns.

```python
import numpy as np
np.loadtxt('sample_data/sample_student_data.txt', dtype=float, skiprows=9, usecols=(3,4))
```

    array([[  1.78,  82.1 ],
           [  1.6 ,  45.  ],
           [  1.72,  77.2 ],
           [  1.83,  88.9 ],
           [  1.56,  56.3 ],
           [  1.64,  65.  ],
           [  1.63,  73.  ],
           [  1.67,  89.8 ],
           [  1.66,  75.1 ],
           [  1.59,  67.3 ],
           [  1.7 ,  45.  ],
           [  1.97,  89.2 ],
           [  1.66,  63.8 ],
           [  1.63,  64.4 ],
           [  1.69,  55.  ]])

You can now use the imported data as a regular Numpy array.

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

 - A data structure is used to assign a collection of values to a single collection name.
 - A Python list can store multiple items of data in sequentially numbered elements (numbering starts at zero)
 - Data stored in a list element can be referenced using the list name can be referenced using the list name followed by an index number in [] square brackets.
 - The `len()` function returns the length of a specified list.

# Test-Yourself Exercises

Compete the Test-Youself exercises below.

Save your answers as .py files and email them to:

philamore.hemma.5s@kyoto-u.ac.jp

## Test-Yourself Exercise : The Dot Product
__(A)__

Earlier, we used the geometric representation of the dot product:

$\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$

to find the sum of two vectors:

$ \mathbf{A} = [A_x, A_y, A_z]$

$ \mathbf{B} = [B_x, B_y, B_z]$

Numpy has a dedicated function to compute the dot product.

Search for the function online and use it to compute the dot product of two 3D position vectors, expressed as lists `C` and `D`.

```python
# Find the dot product of C and D
C = [-1, 2, 6]
D = [4, 3, 3]

```

```python
# Find the dot product of C and D
# Example Solution

import numpy as np
C = [-1, 2, 6]
D = [4, 3, 3]

np.dot(C, D)
```

The dot product has an alternative representation:

__GEOMETRIC REPRESENTATION OF THE DOT PRODUCT__

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

Where:

$\theta$ is the angle between the two vectors.

$|\mathbf{A}|$ and $|\mathbf{B}|$ are the *magnitudes* of $\mathbf{A}$ and $\mathbf{B}$

The magnitude of an $n$-length vector $ \mathbf{A} = [A_1, ..., A_n]$ is:

$|\mathbf{A}| = \sqrt{A_1^2 + ... + A_n^2}$

e.g.

The magnitude of __2D position__ vector, $\mathbf{r} = [x, y]$:

$|\mathbf{r}| = \sqrt{x^2 + y^2}$

The magnitude of __3D position__ vector, $\mathbf{r} = [x, y, z]$:

$|\mathbf{r}| = \sqrt{x^2 + y^2 + z^2}$

__(B)__

If the dot product is known, the angle between two vectors can be found (from its cosine).

Search online to find a numpy function that computes *magnitude*.

Search online to find a numpy function for the *inverse cosine*.

Find the angle between the vectors expressed as lists `C` and `D` in part __(A)__.

```python
# Find the angle between C and D
C = [-1, 2, 6]
D = [4, 3, 3]

```

```python
# Find the angle between C and D
# Example Solution

C = [-1, 2, 6]
D = [4, 3, 3]

dotCD = np.dot(C, D)
magC = np.linalg.norm(C)
magD = np.linalg.norm(D)

theta =  np.arccos(dotCD / (magC * magD))

print(theta)
```

__(C)__

The dot product also indicates if the angle between two vectors is:
   - acute ($\mathbf{C} \cdot \mathbf{D}>0$)
   - obtuse ($\mathbf{C} \cdot \mathbf{D}<0$)
   - right angle ($\mathbf{C} \cdot \mathbf{D}==0$)

Using `if`, `elif` and `else`, classify the angle between `C` and `D` as acute, obtuse or right angle.

```python
# Angle between C and D is obtuse, acute or right angle?

C = [-1, 2, 6]
D = [4, 3, 3]

```

```python
# Angle between C and D is obtuse, acute or right angle?
# Example Solution

C = [-1, 2, 6]
D = [4, 3, 3]

dotCD = np.dot(C, D)

if dotCD > 0:
    print("theta is acute")
elif dotCD < 0:
    print("theta is obtuse")
else:
    print("theta is right angle")
```

## Test-Yourself Exercise : Importing .csv Data and Working with Arrays
__(A) Importing Data__

We can work with data stored in .csv files by importing it to a numpy array.

The file `douglas_data.csv` contains a data set of recorded parameters for a sample of wooden beams.

Import `douglas_data.csv` from the `sample_data` folder in the `ILAS_python_for_engineers` directory.

To import the data without errors, first try setting:
 - delimiter = `"\t"` (tab)
 - data type = `str` (string)

__*What are the delimiters used in the data?*__

__*Which rows and columns contain non-numerical data?*__

Now import the data as a Numpy array of floating point values.

Exclude the rows and columns containing non-numeric data.

*Remember : The use of scientific notation can be surpressed by:*

        np.set_printoptions(suppress=True)

```python
# Importing Data
```

```python
# Importing Data
# Example Solution

import numpy as np

A = np.loadtxt('sample_data/douglas_data.csv', delimiter="\t", dtype=str)

print(A)

A = np.loadtxt('sample_data/douglas_data.csv', delimiter=",", dtype=float, skiprows=2, usecols=(1,2,3,4,5,6,7,8))

print(A)
```

__(B) Manipulating Data__

Select the first 10 rows of the array to create a new array.

The column furthest to the right is the bending strength (`bstrength`), measured in units of $\mathrm{N/mm}^2$.

Convert the data in this column to units $\mathrm{N/m}^2$.

Each beam has the same cross sectional area = $100\mathrm{cm}^2$.

Add a new column to the array that contains mass of the beam (kg) using the columns `density` and `beamheight`.

```python
# Manipulating Data
```

```python
# Manipulating Data
# Example Solution

B = A[:10, :]

B[:,7] *= 1000000
B[:,-1] *= 1000000

area = 0.01 # m2

height = B[:,5] /100 # m

density = B[:,4]  #kg/m3

mass = area * density * height

# Insert column, mass, at horizontal position -1
B = np.insert(B, 0, mass, axis=1)

print(B)
```

    [[6.8175e+00 1.3300e+01 4.0000e-02 3.0000e+00 1.4053e+04 6.7500e+02
      1.0100e+02 1.5452e+04 5.8400e+25]
     [4.7400e+00 1.2000e+01 1.6000e-01 2.5000e+00 2.0611e+04 4.7400e+02
      1.0000e+02 1.7272e+04 7.4350e+25]
     [5.9004e+00 1.2800e+01 1.4000e-01 3.8800e+00 1.8846e+04 5.9600e+02
      9.9000e+01 1.8456e+04 4.9820e+25]
     [5.8200e+00 1.1700e+01 1.3000e-01 2.0200e+00 1.8587e+04 5.8200e+02
      1.0000e+02 1.8940e+04 7.8520e+25]
     [6.7800e+00 1.2000e+01 1.6000e-01 2.1300e+00 1.9299e+04 6.7800e+02
      1.0000e+02 1.6864e+04 7.9310e+25]
     [5.9500e+00 1.2400e+01 4.0000e-02 2.9800e+00 2.1695e+04 5.9500e+02
      1.0000e+02 1.9440e+04 6.4340e+25]
     [5.9200e+00 1.2500e+01 3.2000e-01 3.6700e+00 1.6523e+04 5.9200e+02
      1.0000e+02 1.6152e+04 5.8190e+25]
     [6.4034e+00 1.1500e+01 7.0000e-02 3.6700e+00 1.8333e+04 6.3400e+02
      1.0100e+02 1.8480e+04 8.8390e+25]
     [5.9792e+00 1.3100e+01 1.9000e-01 2.4400e+00 1.8628e+04 5.9200e+02
      1.0100e+02 1.4604e+04 3.3020e+25]
     [5.4540e+00 1.1700e+01 2.5000e-01 3.0000e+00 1.5683e+04 5.4000e+02
      1.0100e+02 1.6628e+04 6.0280e+25]]

__(C) Displaying Data__

Print the mass of the 1st beam in the array.

Print a string to indicate what this value means e.g.

       The mass of beam 1 is ...

Print the data in odd numbered columns of row 5.

```python
# Displaying Data
```

    The mass of beam 1 is {B[0,0]} kg

```python
# Displaying Data
# Example Solution

print("The mass of beam 1 is {B[0,0]} kg")
print(B[5,1::2])
```

    The mass of beam 1 is {B[0,0]} kg
    [1.240e+01 2.980e+00 5.950e+02 1.944e+04]

# Review Exercises
Here are a series of short problems for you to practise each of the new Python skills that you have learnt today.

### 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
```

__(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
```

__(C)__ Definition: *Return the positive square-root of an array, element-wise.*

Print a list where each element is the square root of the corresponding element in list `a = [4, 16, 81]`

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

### Review Exercise:  Data structures.

__(A)__ In the cell below, use Python to identify the type of data structure, C?

__(B)__ Write a line of code that checks whether 3 exists within the data structure.

__(C)__ Write a line of code that checks whether 3.0 exists within the data structure.

__(D)__ Write a line of code that checks whether "3" exists within the data structure.

```python
C = [2, 3, 5, 6, 1, "hello"]
```

### Review Exercise:  Using a single list with a `for` loop.
In the cell below, use a `for` loop to print the first letter of each month in the list.

Jump to Indexing for how to pick out individual letters of a string.

Jump to Iterating over lists for how to loop through each element of a list.

```python
# Print the first letter of each month in the list

months = ["January",
         "February",
         "March",
         "April",
         "May",
         "June",
         "July",
         "August",
         "September",
         "October",
         "November",
         "December"]
```

```python

```


# OpenMC introduction

Please indicate your name below, since you will need to submit this notebook completed latest the day after the datalab.

Don't forget to save your progress during the datalab to avoid any loss due to crashes.

```python
name=''
```

In our previous endeavours we experienced that performing an accurate simulation of particle transport is not trivial. There are several softwares developed to solve this task, and for someone who is more interested in performing the analysis of a reactor core, it is often better to use an already verified and validated code instead of developing a new one from scratch. There are several such codes solving neutron transport either relying on deterministic or stochastic methods. During this datalab we will use a rather young code called OpenMC, which was originally developed at MIT.

OpenMC is a Monte Carlo neutron and photon transport simulation code. It can be used both for fixed source and for criticality calculations. The user can define complicated geometries with constructive solid geometries. OpenMC is written in C++, however it has a rich Python API, which will allow us to comminicate with the code through jupyter notebooks.

OpenMC is not yet an industrial standard code, however our motivation to use it for this course was that it is

1. freely available
2. the important physics we could not tackle within this course ourselves (eg. thermal scattering and several reactions) are implemented in it
3. since we can interact with the code through Python, we can deepen our programming skills
4. for small problems it runs relatively fast on a single PC

That said, today we are going to walk through a simple openMC input file. During this course we will only work with pincell models (ie. a single fuel pin infinite in the axial length with reflective boundaries, so neutrons are reflected back from the boundary in this way approximating an infinite core). This is of course a simplification of a complete reactor core, but it will still allow us to see a lot of cool physics. One could of course perform simulations at the assembly level or for a full-core, however such simulations would be more time consuming. There are several further examples at https://docs.openmc.org/.

**Note that this notebook should be opened from an environment where openmc is available!**

## The input file

During the datalab we will define the following problem which roughly matches a typical western-type pressurized water reactor's conditions:

- PWR pincell with reflective boundaries
- Fuel radius: 0.41 cm
- Fuel material: enriched UO2 with density 10.5 g/cm3
- Cladding inner/outer radius: 0.42/0.45 cm
- Cladding material: zirconium  with density 6.6 g/cm3
- Coolant/moderator: pressurized water with density 0.75 g/cm3
- Cell pitch: 1.26 cm

Note that in practice, PWR fuel cladding is made of an alloy called Zircaloy, nevertheless it has little influence on the neutron economy, therefore we will approximate it as pure zirconium with its natural abundance of isotopes.

So what do we need in order to define an openMC input?

1. Materials: which materials are included in our geometry (nuclide content, temperature, density)?
2. Geometry: how does the geometry look like?
3. Tally: what are the quantities of interest? For example flux, or fission rate?
4. Settings: how many neutrons we would like to include in the calculation?

For each of these steps we will use the python API to export an xml file, which the code will use once we execute it. Note however that one could directly write xml files without using python, and run the code outside of Jupyter. For larger problems this is sometimes better. However we will want to analyse the output results in python, and sometimes we will script the input, so for us it is handy to use the python API.

Let's get started!

### Import

We will need first to import openmc, and of course anything else we might need.

```python
import openmc
import numpy as np
import matplotlib.pyplot as plt
import os
```

### Materials

Do you remember the example `Material()` class we developed before? That is pretty similar to the `Material()` class of openMC. We create an instance of `Material()`, with a numeric id of the material, and then we can add nuclides, and set the density. We can also set the temperature (which can be 294 K, 600 K, 900 K, 1200 K, 1500 K, the temperatures at which the cross sections are evaluated). There are other options as well (for example one can directly set the enrichment), what you can find in the documentation.

If the nuclide inventory is not relevant for a material, we can add elements, in which case openMC will assume the natural abundance of isotopes.

Notice that for water we will need to link to the $S(\alpha,\beta)$ laws, since we need to take into account the molecular bounds for scattering on hydrogen.

Finally, we create an instance of `Materials()` (note, it is plural now!), and link the materials of the problem to this object. Then with the `export_to_xml()` method we export the information into an xml file.

```python
uo2 = openmc.Material(1, "uo2",temperature=1200)
# Add nuclides to uo2
uo2.add_nuclide('U235', 0.04)
uo2.add_nuclide('U238', 0.96)
uo2.add_nuclide('O16', 2.0)
uo2.set_density('g/cm3', 10.5)

#cladding
zirconium = openmc.Material(2, "zirconium",temperature=900)
zirconium.add_element('Zr', 1.0)
zirconium.set_density('g/cm3', 6.6)

#coolant
water = openmc.Material(3, "h2o")
water.add_nuclide('H1', 2.0)
water.add_nuclide('O16', 1.0)
water.set_density('g/cm3', 0.75)

water.add_s_alpha_beta('c_H_in_H2O')

#creating Materials() and exporting to xml
mats = openmc.Materials([uo2, zirconium, water])
mats.export_to_xml()
```

We can print the content of the created xml file, with the linux command `cat`. (The `!` sign let's the python interpreter know that this is a command of the operating system.

You can see how all the information was structured in an xml (which is probably familiar from the previous datalab).

```python
!cat materials.xml
```

### Geometry

Defining the geometry is only marginally more complicated. We will need to define surfaces (eg. `ZCylinder()`, `XPlane()`, and `YPlane()`), and regions bounded by the surfaces. Then we link the regions and the filling material to `Cell()` objects, which all have a numeric ID.

Our geometry is infinite in the axial dimension. First we define three cylinders (the bounding surfaces of the fuel, and the cladding). Then we use constuctive solid geometry rules to define the regions between the surfaces, and create the fuel pin. Finally we will define the water region around the fuel.

We will then need to define a `Universe()` (the usefulness of this is not apparent in our simple exercise, but when defining reactor cores with repetitive patterns of various types of fuel assemblies, then "universes" are the way to handle this in most neutron transport code. You do not need to worry about this in this course). And finally we create a `Geometry()`, and link the universe. We will then export the information into an xml file.

We further commented the lines to explain them

```python
fuel_or = openmc.ZCylinder(r=0.41) #fuel cylinder with outer radius
clad_ir = openmc.ZCylinder(r=0.42) #clad inner cylinder with inner radius
clad_or = openmc.ZCylinder(r=0.45) #clad outer cylinder with outer radius

fuel_region = -fuel_or                #inside the fuel cylinder
gap_region = +fuel_or & -clad_ir      #outside of fuel cylinder and inside of clad inner cylinder
clad_region = +clad_ir & -clad_or     #outside of clad inner cylinder and inside of clad outer cylinder

fuel = openmc.Cell(1, 'fuel')
fuel.fill = uo2
fuel.region = fuel_region

#notice that for the gap between the fuel and the clad we do not need to link a material
# we consider that there is void there (considering the low density of the filling gas this is a fair approximation)
gap = openmc.Cell(2, 'air gap')
gap.region = gap_region

clad = openmc.Cell(3, 'clad')
clad.fill = zirconium
clad.region = clad_region

pitch = 1.26
#we define the x and y planes with boundary condition
left = openmc.XPlane(x0=-pitch/2, boundary_type='reflective')
right = openmc.XPlane(x0=pitch/2, boundary_type='reflective')
bottom = openmc.YPlane(y0=-pitch/2, boundary_type='reflective')
top = openmc.YPlane(y0=pitch/2, boundary_type='reflective')

#outside of left and inside of right, outside of bottom, and inside of top and outside of clad outer cylinder
water_region = +left & -right & +bottom & -top & +clad_or

moderator = openmc.Cell(4, 'moderator')
moderator.fill = water
moderator.region = water_region

root = openmc.Universe(cells=[fuel, gap, clad, moderator])

geom = openmc.Geometry()
geom.root_universe = root
geom.export_to_xml()
```

We can again inspect the xml file.

```python
!cat geometry.xml
```

### Tally

We can describe the physical quantities of interest with tallies. If no tally is defined the code will only calculate the k-effective.

As we can see in the openMC documentation (in the user's guide) a tally is a quantity defined as

\begin{equation}
X = \underbrace{\int d\mathbf{r} \int d\mathbf{\Omega} \int
dE}_{\text{filters}} \underbrace{f(\mathbf{r}, \mathbf{\Omega},
E)}_{\text{scores}} \psi (\mathbf{r}, \mathbf{\Omega}, E)
\end{equation}

For example if function $f()$ would be unity, we would score the flux in a certain part of the phase space. If function $f()$ is a macroscopic cross section, we would score reaction rates.

Therefore we have to specify two things for a tally, which is created with a `Tally()` object:

1. *filters*: which part of the phase space we would like score at (for example only a certain part of the geometry is of interest, or certain energy bins)?
2. *scores*: what is the physical quantity of interest (for example flux, fission rate etc)

You can find a detailed description of the available scores and filters in the documentation.

Once the tallies are defined, we will need to link them into a `Tallies()` object, and export an xml file.

In the following code block we define three tallies.

1. tally for the neutron spectrum and the fission rate vs energy in the fuel
2. tally for the neutron spectrum in the moderator
3. a Mesh tally along the x-axis to tally the spatial dependence of the flux with three energy groups (thermal: 0-1eV, epithermal: 1eV-100keV, fast: 100keV-20MeV)

The reason why we tally the fission rate as well in the 1st tally is to see how openMC will store the results.

```python
# Tally 1: energy spectrum in fuel
cell_filter1 = openmc.CellFilter(fuel)
energybins=np.logspace(-2,7,1001) #1000 bins between 1e-2 eV and 1e7 eV
energy_filter = openmc.EnergyFilter(energybins)

t1 = openmc.Tally(1)
t1.filters = [cell_filter1,energy_filter]
t1.scores = ['flux','fission']

# Tally 2: energy spectrum in moderator
# we can use the same energy_filter as before
cell_filter2 = openmc.CellFilter(moderator)

t2 = openmc.Tally(2)
t2.filters = [cell_filter2,energy_filter]
t2.scores = ['flux']

# Tally 3: mesh tally in dimension x in three energy groups
energy_filter2 = openmc.EnergyFilter([0., 1, 100e3, 20.0e6])
myMesh=openmc.Mesh(name='xmesh')
myMesh.dimension=[100] #number of spatial bins along x axis
myMesh.lower_left=[-pitch/2]
myMesh.upper_right=[pitch/2]
mesh_filter = openmc.MeshFilter(myMesh)

t3 = openmc.Tally(3)
t3.filters = [mesh_filter,energy_filter2]
t3.scores = ['flux']

tallies = openmc.Tallies([t1,t2,t3])
tallies.export_to_xml()
```

We can inspect the xml file.

```python
!cat tallies.xml
```

### Settings

There are some parameters we will need to set: we have to include the original source location and specify the number of batches and particles per batches for the criticality mode calculation. We also have to specify the number of "inactive" batches (these cycles are used to spread the fission source over the geometry, but the scores are still biased due to the original source not sampling all the possible fission sites properly).

Since we have no better guess for the moment, we will place the source first in the center of the geometry. The number of batches and particles are set so that the results are reasonably accurate. If you wanted to have more accurate results you could increase these numbers, if you wanted to have shorter calculations, you could decrease these numbers.

Again, the instance of the `Setting()` object is exported to xml.

```python
point = openmc.stats.Point((0, 0, 0))
src = openmc.Source(space=point)

settings = openmc.Settings()
settings.source = src
settings.batches = 100
settings.inactive = 10
settings.particles = 5000
settings.export_to_xml()
```

```python
!cat settings.xml
```

## Plotting the geometry

There are several ways of plotting the geometry. The simplest inline plotting option is to call the `plot()` method of the `Universe()` object (in our case this was called `root`). This is a wrapper of matplotlib. However, there is a more advanced options by setting up an instance of the `Plot()` class, and running openMC directly to generate a plot. Since in this course we work with a rather simple geometry, we will not look at the advanced plotting option, but you are welcome to check the user's guide.

```python
root.plot()
```

## Calculation

Finally, we are done, and nothing left just to run openMC and wait for the results. For this we have to specify where the cross section files are (this we could do in the system as well). Then call the `openmc.run()` method. And you immediately see how the k-effective is being estimated.

**Note 1**: The results are being stored in a file named 'statepoint.100.h5' (100 refers to the number of batches), also a 'summary.h5' and some other files are created. Sometimes openMC is complaining if there are already existing h5 files, so in this case we can remove (`rm`) these files. For this we make a system call with `os.system` to remove any file for which the name starts with 's' and ands with 'h5'. Nevertheless, be careful with removing files, not to loose data.

**Note 2**: Sometimes the python API breaks for no apparent reason, you can just restart the kernel, if that happens.

```python
import os
os.system('rm s*h5')

openmc.run()
```

## Post-processing

Alright, we did our calculation, we even saw that some k-effective values were printed, but we still haven't seen any more results. For this we will need to read in the statepoint file. We will read this file into an object called `sp`. If we hit TAB while typing `sp.` we can review the available methods and attributes. For example we can get the final k-effective as below.

```python
sp = openmc.StatePoint('statepoint.100.h5')
sp.k_combined
```

And the values we can access for further processing or plotting:

```python
keff=sp.k_combined.nominal_value
keff_error=sp.k_combined.std_dev
print(keff,keff_error)
```

The tallies are stored in a dictionary where the keys are the numeric IDs we defined previously:

```python
sp.tallies
```

We can convert the tally results into pandas dataframes. So at the end we have a nice table, for each energy bin there is a row for the flux and a row for the fission rate. If we prefer we can split the dataframe based on conditions to store separately the flux and the fission rate. For this we can apply conditions as we have seen during the previous datalabs.

The results are stored in the 'mean' column.

```python
tallydf1=sp.tallies[1].get_pandas_dataframe()
tallydf1.head() #prints the first 5 rows
```

```python
tallydf1flux=tallydf1[tallydf1['score']=='flux']
tallydf1fiss=tallydf1[tallydf1['score']=='fission']
tallydf1flux.head()
```

Let's plot these results.

```python
energy=(tallydf1flux['energy low [eV]']+tallydf1flux['energy high [eV]'])/2
plt.figure()
plt.loglog(energy,tallydf1flux['mean'])
plt.xlabel('energy (eV)')
plt.ylabel('Group flux per source particle')
plt.show()

```

These of course does not look like the spectrum we saw during the lecture. But here actually each value is not the flux at a certain energy, but the integral of the flux between energies. If we divide the integral flux with the width of the bins (`deltaE`) we get the more familiar shape for the spectrum.

We can clearly see the Maxwellien thermal component, the 1/E part with the self-shielding effect of the resonances, and the Watt-spectrum at high energies.

The very first "negative" peak is due to the 6.67 eV resonance of U-238.

```python
deltaE=(tallydf1flux['energy high [eV]']-tallydf1flux['energy low [eV]'])
plt.figure()
plt.loglog(energy,tallydf1flux['mean']/deltaE,lw=2)

plt.ylabel('Spectrum per source particle (1/eV)')
plt.xlabel('Energy (eV)')
plt.show()
```

Now it is your turn to plot the spectrum in the moderator (preferably include both the fuel and the moderator spectrum in the same figure). You will need to `get_pandas_dataframe` from tally 2, and then plot the results. Compare with the spectrum in the fuel, what is the most noticable difference?

```python
# your code comes here
```

Your conclusion comes here.

Finally, we can look at the spatial dependence of the flux. We read in the 3rd tally. Try to split the dataset according to the energy groups and plot the flux vs x-coordinate for the three energy groups separately. Normalize each curve by their maximum, so they are comparable.

Conclude your findings!

```python
tallydf3=sp.tallies[3].get_pandas_dataframe()
tallydf3.head()
```

```python
# your code comes here
```

Your conclusion comes here!

## Experiment time!

If time permits, modify your input, and see how the results change. Points of interest:

- Change the void content (ie. decrease the moderator density), and observe how the k-eff changes. What happens with the spectrum?
- What is the impact on the k-eff if you change the UO2 temperature?
- What is the impact of increasing the pitch of the pincell?

You can also take a look at the third set of home assignments, where you will need to implement various geometries in openMC, and already give it a try.

```python

```