import numpy as np

"""
Explanation: Introduction to NumPy
NumPy supports arrays which are very useful to numerical computations

Arrays are N dimensional: 1d (vector), 2d (plane),...,N dim
Arrays are (generally) faster than lists
Many packages use numpy arrays to store data
Arrays can be used to make calculations in one command, without For loops or list compreension

Let's import it...
End of explanation
"""


np.lookfor('weighted average')

"""
Explanation: Vectorised
<code>
array2 = array1*k+c
</code>
Non-vectorised, here with a list
<code>
for index in range(len(list1)):
    list2[index] = list1[index]*k + c 
</code>
Do we still need lists?

Lists can have different objects as elements. Arrays are homogenous.
<code>
example_list = [number, string, cat, dog]
</code>
Both arrays and lists can be nested
<code>
nested_list = 
[[1,2],
[1,2,3],
[1]]
</code>

Looking for help?

Documentation: http://docs.scipy.org/doc/numpy/reference/

Use help function (tab will show options available)
<code>
    help(np.mean)
</code>


Interactive help: NumPy has an a built-in search engine
End of explanation
"""


a1d = np.array([3, 4, 5, 6])
a1d

a2d = np.array([[10.,   20, 30], [9, 8, 7]])
a2d

print( type( a1d[0] ) )
print( type( a2d[0,0] ) )

type(a1d)

"""
Explanation: Creating an array from a list
End of explanation
"""


try:
    a = np.array(1,2,3,4)   # WRONG, only 2 non-keyword arguments accepted
except ValueError as err:
    print(err)

a = np.array([1,2,3,4]) # RIGHT

np.ndarray([1,2,3,4]) #  ndarray is is a low level method. Use np.array() instead

"""
Explanation: The core class of NumPy is the ndarray (homogeneous n-dimensional array).
To find methods or attributes:
<code>
a1d.   ->tab
</code>
Common mistakes
End of explanation
"""


np.arange(1, 9, 2)

# for integers, np.arange is same as range but returns an array insted of a list 
np.array( range(1,9,2) )

"""
Explanation: Functions for creating arrays
arange([start,] stop[, step,], dtype=None)
evenly spaced, defined by step
End of explanation
"""


np.linspace(0, 1, 10)   # start, end, num-points

"""
Explanation: linspace(start, stop, num=50, endpoint=True, retstep=False, dtype=None)
evenly spaced, defined by length
End of explanation
"""


np.zeros((2, 3))

"""
Explanation: Filled with zeros
End of explanation
"""


np.zeros((2,2),dtype=int)

"""
Explanation: By default, the dtype of the created array is float64 but other dtypes can be used:
End of explanation
"""


np.ones((2, 3))

"""
Explanation: Filled with ones
End of explanation
"""


np.random.rand(4)       # uniform in [0, 1]

np.random.normal(0,1,4)      # Gaussian (mean,std dev, num samples)

np.random.gamma(1,1,(2,2))      # Gamma (shape, scale , num samples)

"""
Explanation: Create array with random numbers
End of explanation
"""


x = np.linspace(-5, 5, 3)
y = np.linspace(10, 40, 4)
x2d, y2d = np.meshgrid(x, y)
print(x2d)
print(y2d)

"""
Explanation: Grid generation

A common task is to generate a pair of arrays that represent data coordinates. 
Useful for interpolation of mapping contours.
When orthogonal 1D coordinate arrays already exist, NumPy's meshgrid function is very useful:
End of explanation
"""


print(np.transpose(y2d)) # or equivalentely
print(y2d.transpose())   # using the method of y2d
print(y2d.T)             # using the property of y2d

"""
Explanation: Transpose arrays
This can be very useful when dealing with grids
End of explanation
"""


a2d

"""
Explanation: Array attributes
End of explanation
"""


a2d.ndim

"""
Explanation: ndarray.ndim
the number of axes (dimensions) of the array. In NumPy, the number of dimensions is referred to as rank.
End of explanation
"""


a2d.shape

"""
Explanation: ndarray.shape
the dimensions of the array
End of explanation
"""


a2d.size

"""
Explanation: This is a tuple of integers indicating the size of the array in each dimension. For a matrix with n rows and m columns, shape will be (n,m). The length of the shape tuple is therefore the rank, or number of dimensions, ndim.
ndarray.size
the total number of elements of the array
End of explanation
"""


len(a2d)

"""
Explanation: Note that size is not equal to len(). The latter returns the length of the first dimension.
End of explanation
"""


print('array a1d                       :',a1d)
print('Minimum and maximum             :', a1d.min(), a1d.max())
print('Sum and product of all elements :', a1d.sum(), a1d.prod())
print('Mean and standard deviation     :', a1d.mean(), a1d.std())

"""
Explanation: Statistical methods of arrays
End of explanation
"""


print(a2d)
print('sum  :',a2d.sum())
print('sum  :',a2d.sum(axis=0))
print('sum  :',a2d.sum(axis=1))

"""
Explanation: Operations over a given axis
End of explanation
"""


np.exp(a/100.)/a

# Non-vectorised
r=np.zeros(a.shape)   # create empy array for results

for i in range(len(a)):
    r[i] = np.exp(a[i]/100.)/a[i]
r

"""
Explanation: Vectorisation: operations on whole arrays
End of explanation
"""


a = np.arange(10, 100, 10)
a

a[2:9:3] # [start:end:step]

a[:3] # last is not included

a[-2] # negative index counts from the end

"""
Explanation: Vectorization is generally faster than a for loop.
But it is not always possible or the most readable:
<code>
for i in range(len(a)):
    if isprime(i):
        r[i] = a[i]
    else
        r[i]= a[i]+a[i-1]
</code>
Array indexing

Indices begin at 0, like other Python sequences and C/C++. Note that many languages, such as Matlab, R and Fortran, start with 1
In 2D, the first dimension corresponds to rows, the second to columns.
The fastest varying dimension is the last dimension! The outer level of the hierarchy is the first dimension.
End of explanation
"""


x = np.random.rand(6)
x

xs = np.sort(x)
xs

xs[1:] - xs[:-1]

"""
Explanation: Using indexes: how to calculate x[i]-x[i-1] without a loop?
End of explanation
"""


# [[2, 3.2, 5.5, -6.4, -2.2, 2.4],
#  [1, 22, 4, 0.1, 5.3, -9],
#  [3, 1, 2.1, 21, 1.1, -2]]

"""
Explanation: Exercise 1
Create a 2D NumPy array from the following list and assign it to the variable "arr":
End of explanation
"""


# a[:, 3]

# a[1:4, 0:4]

# a[1:, 2]

"""
Explanation: Can you guess what the following slices are equal to? Print them to check your understanding.
End of explanation
"""


a = np.random.randint(1, 100, 6) # array of 6 random integers between 1 and 100
a

mask = ( a % 3 == 0 ) # Where divisible by 3 (% is the modulus operator).
mask

a[mask]

"""
Explanation: Fancy indexing
NumPy arrays can be indexed with slices, but also with boolean or
integer arrays (masks)
End of explanation
"""


a = np.arange(-100, 0, 5).reshape(4, 5)
a

"""
Explanation: Exercise 2
Consider an 4x5 2D array of negative integers:
End of explanation
"""


result = np.zeros(a.shape, dtype=a.dtype)

for i in range(a.shape[0]):
    for j in range(a.shape[1]):
        if a[i, j] > -90 and a[i, j] < -40:
            result[i, j] = a[i, j]**2
        else:
            result[i, j] = 1
            
result

"""
Explanation: Suppose you want to return an array result, which has the squared value when an element in array a is greater than -90 and less than -40, and is 1 otherwise.

With a For loop it would look like this:
End of explanation
"""


condition = (a > -90) & (a < -40)
condition

result[condition] = a[condition]**2
result[~condition] = 1
print(result)

"""
Explanation: But a more less verbose and quicker approach would be:
End of explanation
"""


result = np.where(condition, a**2, 1)
print(result)

"""
Explanation: A one-liner using np.where:
End of explanation
"""


a = np.ma.masked_array(data=[1, 2, 3],
                       mask=[True, True, False],
                       fill_value=-999)
a

"""
Explanation: Masked arrays - how to handle (propagating) missing values

All operations related to masked arrays live in numpy.ma submodule.
The simplest example of manual creation of a masked array:
End of explanation
"""


a = np.linspace(1, 15, 15)

masked_a = np.ma.masked_greater_equal(a, 11)

masked_a

"""
Explanation: Often, a task is to mask array depending on a criterion.
End of explanation
"""


# Your code:
# arr = 

# Your code:
# condition = 

# masked_arr = np.ma.masked_where(condition, arr)
# print(masked_arr)

"""
Explanation: Exercise 3

Create a "data" array of 30 linearly spaced numbers in the interval (1, 15)
Hint: use np.linspace or np.arange functions
Create a condition - a True/False (boolean) array, corresponding to the masked values
The data array should be masked where elements are
greater than 1
less or equal than 12
divisible by 2.


Mask the array depending on the condition
Hint: use np.where function
End of explanation
"""


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

print('{} <-- array'.format(a))
print('{} <-- its shape'.format(a.shape))

a.flatten()

a.repeat(4)

a.reshape((3, 2))

print('Old shape: {}'.format(a.shape))
print('New shape: {}'.format(a.reshape((3, 2)).shape))

"""
Explanation: Shape manipulation
End of explanation
"""


a[..., np.newaxis].shape

"""
Explanation: Add a dimension
End of explanation
"""


e4 = np.arange(0.,2.5,.1)
e4

e4.reshape([5,5])

"""
Explanation: Exercise 4
Generate a 2d array with 5x5. The first value is 0 and it grows left to right and top to bottom in increments on 0.1.
End of explanation
"""


