#-------------------------------------
# Lecture 7 -- Example 1. Array Basics
#-------------------------------------
# Some additional references
# * Python Documentation on Lists. https://docs.python.org/3/tutorial/introduction.html#lists
# * Python Documentation on Tuples. https://docs.python.org/3/tutorial/datastructures.html#tuples-and-sequences
# * When to use lists vs tuples? https://stackoverflow.com/questions/1708510/python-list-vs-tuple-when-to-use-each
# * Numpy Docs on arrays. https://docs.scipy.org/doc/numpy/reference/generated/numpy.array.html
# * Numpy Docs on array creation. https://docs.scipy.org/doc/numpy-1.13.0/reference/routines.array-creation.html

import numpy as np

#%%
#-------------------------------------
# A. Lists and Tuples
#-------------------------------------
#
# Very often, we want a variable that can hold some *sequence* of data.
# For example, we might want a variable that holds values the values:
# 2, 4, 6, 8, and 10.
# 
# In python this is called a *list*.
# Lists are created using square brackets:

a_list = [2, 4, 6, 8, 10]

# Python also has another data type for sequences called a *tuple*.
# Tuples are created using parentheses.

a_tuple = (1, 3, 5, 7, 9)

# The elements of tuples and lists can be accessed by using the square
# bracket operator. Note that the *index* of the list/tuple starts 
# counting at *zero* (not one).

print('A list:')
print(a_list[0])
print(a_list[1])
print(a_list[2])
print(a_list[3])
print(a_list[4])

print('\n'+'A tuple:')
print(a_tuple[0])
print(a_tuple[1])
print(a_tuple[2])
print(a_tuple[3])
print(a_tuple[4])

# The main difference between lists and tuples is that the elements of a list
# can be changed, whereas the elements of a tuple cannot be. Lists are said to 
# be *mutable* and tuples are said to be *immutable*. In addition, Tuples are 
# meant to be *heterogeneous*. That means that their entries have different 
# meanings. By contrast, lists are supposed to be *homogeneous*; that is their
# entries should be an ordered list of the same thing.

# lists are mutable
a_list[0] = 12
print('\n', 'a_list[0]: ', a_list[0])

# tuples are immutable
a_tuple[0] = 11 # This will give an error
print('\n', 'a_tuple[0]: ', a_tuple[0])

# lists are usually homogeneous
homo_list = ('12 Aug', '1 Jan', '22 Mar') # list of birthdays

# tuples are usually heterogeneous
hetero_tuple = ('house', '1234 E 300 W') # type of residence, address

# *** Practice ***
# Create a list called my_list which has the first 5 prime numbers in it: 
# 2, 3, 5, 7, 11

#%%
#-------------------------------------
# B. Numpy arrays
#-------------------------------------
#
# The Numpy module defines a more useful sequence data type for working with
# numbers called an *array*.

an_array = np.array([3, 6, 9, 12])
print('\n' + 'A numpy array: ', an_array)

# Numpy has many other ways to define an array, without having to type in all
# of the numbers by hand. (Thankfully!)

# *** Ways to define an array ***

# (i) np.zeros(N) -- gives an array of zeros of length N

x = np.zeros(5)
print('\n' + 'np.zeros(5) = ', x)

# (ii) np.ones(N) -- gives an array of ones of length N

x = 3*np.ones(6)
print('\n' + '3*np.ones(6) = ', x)

# (iii) np.arange(start, stop) -- gives an array of integers with range [start, stop)
# * Note the open half-range at the top, i.e. start <= x < stop
    
x = np.arange(4, 10)
print('\n' + 'np.arange(4,10) = ', x)

# (iv) np.linspace(start, stop, N) -- gives an array of N floats with range [start, stop]
# * Note the closed half-range at the top, i.e. start <= x <= stop

x = np.linspace(4, 10, 4)
print('\n' + 'np.linspace(4,10,4) = ', x)

# (v) Copy another array
# * Be careful! The assignment operator doesn't create a new copy!

y = x # This copies the array x *by reference* (same memory!)

print('\n'+'x = ', x)
print('y = ', y)

x[2] = 7 # this also changes y!
print('\n'+'x = ', x)
print('y = ', y)

z = np.copy(x) # this is the right way to copy the array.
x[1] = 11 # this doesn't change z
print('\n'+'x = ', x)
print('z = ', z)

# *** Practice ***
# Create an array, t, which ranges from [0, 1] (inclusive) with steps of 0.1.

#%%
#-------------------------------------
# C. 2D arrays (matrices)
#-------------------------------------
# In addition to 1D arrays, we often need 2D arrays (matrices).

# One way to define a 2D array is using nested lists,
# i.e. a list inside a list.

a_2d_array = np.array([[1,2],[3,4]])
print('\n' + 'A 2D array (matrix): \n', a_2d_array)

# Another way is to *reshape* a 1D array using the reshape function:
# * np.reshape( 1D_array, (rows, cols) )
# * Notice the way the rows of the matrix are filled. The columns of 
#   each row are filled first before moving to the next row. 
#   (This is called row-major order since it fills up one row at a time.)

a_1d_array = np.arange(0, 12, 2)
a_2d_array = np.reshape(np.copy(a_1d_array), (3,2))

print('\n' + 'A 1D array: ', a_1d_array)
print('A 2D array made by reshape: \n', a_2d_array)

# These can be combined in one line to save space
b_2d_array = np.reshape(np.arange(0, 18, 3), (2,3))
print('\n'+'A 2D array made by reshape on one line: \n', b_2d_array)

# *** Practice ***
# Create a matrix called my_matrix which has the values:
# [ -2  1  0  0 ]
# [  1 -2  1  0 ]
# [  0  1 -2  1 ] 
# [  0  0  1 -2 ]
# Print it to the console

#%%
#--------------------------------------------------
# D. Accessing Elements
#--------------------------------------------------
#
# *** Accessing elements of an array or matrix ***
# Basic element access of numpy arrays is the same as lists and tuples
# * use square brackets with index number
# * array indexing starts at *zero*

# 1D arrays
x = np.arange(3,21,3)
print('\nx: ', x)                        # x is         [ 3 6 9 12 15 18]
print('x[2] = ', x[2])                   # indicies are [ 0 1 2  3  4  5]

# 2D arrays
# A[i,j] is row i, column j
A = np.arange(4).reshape(2,2)            # A is [0 1]
print('\nA: \n', A)                      #      [2 3]
print('A[row = 1, col = 0]:', A[1,0])

#%%

# *Slicing* allows us to more easily get some or all of the elements 
# of the array
x = np.arange(3,21,3)
print('\nx = ', x)
print('x[:] = '    , x[:])               # get all elements
print('x[2:] = '   , x[2:])              # get indices 2 to the end
print('x[2:4] = '  , x[2:4])             # get indices 2 and 3 (skips 4!)
print('x[-1] = '   , x[-1])              # get the last element
print('x[-2] = '   , x[-2])              # get the second to the last element
print('x[1,2,5] = ', x[[1,2,5]])         # gets indices 1,2,5

# *** Practice 1 ***
# Given the array:
B = np.arange(9).reshape(3, 3)
# Use indices to extract the value of 7 from the matrix and print it to the
# conosle.

# *** Practice 2 ***
# Suppose an array is defined by the following:
x = np.arange(0, 20, 2)
# Use slicing to extract to define a new array, y, with the values:
#   8, 10, 12, 14.

#%%
#--------------------------------------------------
# E. Find the size and shape of an array
#--------------------------------------------------
#
# There are three useful functions for finding about the size or shape of an 
# array or matrix:  len, np.shape, np.size

x = np.arange(3, 21, 3)
A = np.reshape(np.copy(x), (3,2))
B = np.reshape(np.copy(x), (2,3))

print('\nx: ', x)
print('A: \n', A)
print('B: \n', B)

# (i) len(x) -- length of 1D array or the number of rows of a matrix

len_x = len(x)
len_A = len(A)
len_B = len(B)
print('\nlen(x)=', len_x, '; len(A)=', len_A, '; len(B)=', len_B)

# (ii) np.shape(x) -- tuple of the dimensions of x
shape_x = np.shape(x)
shape_A = np.shape(A)
shape_B = np.shape(B)
print('\nshape(x)=', shape_x, '; shape(A)=', shape_A, '; shape(B)=', shape_B)

# (iii) np.size(x) -- total number of elements in x
size_x = np.size(x)
size_A = np.size(A)
size_B = np.size(B)
print('\nsize(x)=', size_x, '; size(A)=', size_A, '; size(B)=', size_B)

