# Lecture 4 -- Examples of Functions

#%%
#---------------------------------------------------
# A. Libraries (i.e. Modules/Packages)
#---------------------------------------------------
#%%

# Important Modules
#   * built-in, e.g. print(), input()
#       link: https://docs.python.org/2/library/functions.html
#
#   * standard library, e.g. math
#       standard library link: https://docs.python.org/3/library/index.html
#       math library link: https://docs.python.org/3/library/math.html
#
#   * numpy: numerical python (fast numerical arrays)
#       numpy documentation: https://docs.scipy.org/doc/numpy/reference/index.html
#
#   * scipy: scientific python (numerical methods)
#       scipy documentation: https://docs.scipy.org/doc/scipy/reference/
#
#   * matplotlib: 2D plotting
#       matplotlib documentation: https://matplotlib.org/contents.html

# Modules can be imported using the `import` keyword
import math

# functions from modules need the name of the module
# as part of the function name: e.g. math.sin()
print('sin(pi/2) = ', math.sin(math.pi/2))

# Using the `as` keyword allows you to give a different prefix for the function.
# This is useful to shorten the name of the module so you don't have to type
# as much.
import numpy as np
x = np.zeros(5)
print('x is an array of 5 zeros: ', x)

# One can also use the `from` keyword to get part of a module
from scipy import linalg
A = np.matrix([[2, 0],[0, 2]])
Ainv = linalg.inv(A)

print('A:', A)
print('Ainv:', Ainv)

#%%
#---------------------------------------------------
# B. Function syntax
#---------------------------------------------------
#%%

# We've already seen how to *call* a function.

math.cos(math.pi)
y = math.cos(math.pi)
print('cos(pi)} = ', y)

# To *define* a function we need the keyword `def`.
# Note the colon and the indentation. These are not optional.

def my_first_function():
    print('wahoo!')
    
#%%
# Functions can take arguments and return values.
# We need a new keyword to do the latter: `return`
def sum_values( x, y ):
    return (x+y)

print( sum_values(3, 4.2) )

# `return` ends a function. Be careful with indendation.
def difference( x, y ):
    return (x-y)
    print('What is wrong here?')
    
#%%
# Can return more than one value as well
def more_than_one(x, y):
    return x**2, y**2

print(more_than_one(3, 5))

#%%
# A triple quoted string at the beginning of the function is a 
# *docstring* that is output when getting "help" on the function.
# This allows us to provide good documentation.

def square_it(x):
    """
    input: x
    returns: x**2
    type "help(square_it)" to see these comments.
    """
    return x*x

help(square_it)

#%%
#---------------------------------------------------
# C. Order of Definition and Scope
#---------------------------------------------------

# ** A function must be defined before it is used. **
# If you write some code that uses functions that are not 
# defined until later, it won't work, because python doesn't 
# know about the functions yet.

a = 5.5
print(f_below(a))   # function that is not defined yet!

def f_below(x):
    return x*x

#%%
# This can be annoying, because you often want to organize your code with 
# a "main" code at the top and auxiliary functions at the bottom.
# One solution is to:
#   * Put your main code at the top in a "driver" function.
#   * This driver can call functions that are defined later.
#   * Then *CALL* the driver at the bottom. 
#   * This works because both functions are defined 
#     by the time they are called.

def my_driver(x):
    return f_aux(x)

def f_aux(x):
    return x**2.0

my_driver(5)

#%%
# Will this one work? Why or why not?
# Note: Be careful with ipython console. After you run it once, it will load
#       functions and remember them. Reset with the "magic command" %reset.

def my_driver2(x):
    # some long code calling many functions goes here.
    return f_aux2(x)

my_driver2(5)

def f_aux2(x):
    return x**2.0

#%%
# *Scope* is a word we use to talk about where a variable or function can
# be accessed. A variable can have either local or global scope.
#
# Variables or functions defined *inside* a function are *local* to that 
# function and cannot be seen or accessed outside of it.
#
# Variables or functions defined *outside* (and above) a function are 
# *global* and can be seen inside later functions.
#
# Analogy. Think of the FBI and the BYU police. The FBI can go anywhere in
# the USA and arrest somebody. They are like global variables. They have 
# a global jurisdiction. The BYU police can only arrest you on campus.
# They are like local variables. They have a local jurisdiction.

# Here a is a global variable
a_global = 1.1

def print_global():
    print("(inside) a = ", a_global)    # a isn't passed in, but is known

print()
print_global()
print('(outside) a = ', a_global)

#%%
# Let's look at a local variable
def print_local():
    # Here a is a local variable
    a_local = 2.2
    print("(inside) a = ", a_local)

print()
print_local()
print('(outside) a = ', a_local) # What happens here?

#%%
# What happens if they conflict?
abc = 1.1

def print_abc():
    abc = 2.2     # this is a NEW, local abc, not the same as above
    print("(inside) abc = ", abc)

print()
print_abc()
print("(outside) abc = ", abc)

#%%
# How can we change a global variable inside a function?
b_global=3

def f():
    global b_global
    b_global=2
    print(b_global)
    
f()
print(b_global)

#%%
# Functions act similarly. If we define a function locally, 
# we can't use it globally

def f_outer(x_out):
    
    def f_inner(x_in):                     # f_inner is local to f_outer
        print('x_in, x_out:', x_in, x_out) # x_out is global and seen in f_inner
        
    f_inner(1)

f_outer(2)

f_inner(3)        # this causes an error: f_inner is local to f_outer
                  # and isn't known here


