<jupyter_start><jupyter_text>Introduction to programming for Geoscientists through Python [<NAME>](http://www.imperial.ac.uk/people/g.gorman) Lecture 3: Tuples, functions and if statements Learning objectives:* Know how to modify elements in a list.* Be able iterate through different combinations of lists.* Know how to use a *tuple* to store data elements and understand how it differs from a *list*.* Be able to write your own *function*.* Know the difference between a locally-scoped and globally-scoped variables.* Be able to use an *if* statement to execute some code blocks conditionally.  Changing elements in a listSay we want to add $2$ to all the numbers in a list:<jupyter_code>v = [-1, 1, 10]
for e in v:
    e=e+2
print(v)<jupyter_output>[-1, 1, 10]<jupyter_text>We can see that the list *v* is unaltered! The reason for this is that inside the loop, *e* is an ordinary (int) variable. At the start of the iteration *e* is assigned a *copy* of the next element in the list. Inside the loop we can change *e* but *v* itself is unaltered. If we want to change *v* we have to use an index to access and modify its elements:<jupyter_code>v[1] = 4 # assign 4 to 2nd element (index 1) in v
print(v)<jupyter_output>[-1, 4, 10]<jupyter_text>To add 2 to all values we need a *for* loop over indices:<jupyter_code>for i in range(len(v)):
    v[i] = v[i] + 2
print(v)<jupyter_output>[1, 6, 12]<jupyter_text>Traversing multiple lists simultaneously - *zip(list1, list2, ...)*Consider how we can loop over elements in both Cdegrees and Fdegrees at the same time. One approach would be to use list indices:<jupyter_code># First we have to recreate the data from the previous lecture
Cdegrees = [deg for deg in range(-20, 41, 5)]
Fdegrees = [(9/5)*deg + 32 for deg in Cdegrees]

for i in range(len(Cdegrees)):
    print(Cdegrees[i], Fdegrees[i])<jupyter_output>-20 -4.0
-15 5.0
-10 14.0
-5 23.0
0 32.0
5 41.0
10 50.0
15 59.0
20 68.0
25 77.0
30 86.0
35 95.0
40 104.0<jupyter_text>An alternative construct (regarded as more ”Pythonic”) uses the *zip* function:<jupyter_code>for C, F in zip(Cdegrees, Fdegrees):
    print(C, F)<jupyter_output>-20 -4.0
-15 5.0
-10 14.0
-5 23.0
0 32.0
5 41.0
10 50.0
15 59.0
20 68.0
25 77.0
30 86.0
35 95.0
40 104.0<jupyter_text>Another example with three lists:<jupyter_code>l1 = [3, 6, 1]; l2 = [1.5, 1, 0]; l3 = [9.1, 3, 2]
for e1, e2, e3 in zip(l1, l2, l3):
    print(e1, e2, e3)<jupyter_output>3 1.5 9.1
6 1 3
1 0 2<jupyter_text>If the lists are of unequal length then the loop stops when the end of the shortest list is reached. Experiment with this:<jupyter_code>l1 = [3, 6, 1, 4, 6]; l2 = [1.5, 1, 0, 7]; l3 = [9.1, 3, 2, 0, 9]
for e1, e2, e3 in zip(l1, l2, l3):
    print(e1, e2, e3)<jupyter_output>3 1.5 9.1
6 1 3
1 0 2
4 7 0<jupyter_text>Nested lists: list of listsA *list* can contain **any** object, including another *list*. To illustrate this, consider how to store the conversion table as a single Python list rather than two separate lists.<jupyter_code>Cdegrees = range(-20, 41, 5)
Fdegrees = [(9.0/5)*C + 32 for C in Cdegrees]
table1 = [Cdegrees, Fdegrees]  # List of two lists
print("table1 = ", table1)
print("table1[0] = ", table1[0])
print("table1[1] = ", table1[1])
print("table1[1][3] = ", table1[1][3])<jupyter_output>table1 =  [range(-20, 41, 5), [-4.0, 5.0, 14.0, 23.0, 32.0, 41.0, 50.0, 59.0, 68.0, 77.0, 86.0, 95.0, 104.0]]
table1[0] =  range(-20, 41, 5)
table1[1] =  [-4.0, 5.0, 14.0, 23.0, 32.0, 41.0, 50.0, 59.0, 68.0, 77.0, 86.0, 95.0, 104.0]
table1[2][3] =  23.0<jupyter_text>This gives us a table of two rows. How do we create a table of columns instead:<jupyter_code>table2 = []
for C, F in zip(Cdegrees, Fdegrees):
    row = [C, F]
    table2.append(row)
print(table2)<jupyter_output>[[-20, -4.0], [-15, 5.0], [-10, 14.0], [-5, 23.0], [0, 32.0], [5, 41.0], [10, 50.0], [15, 59.0], [20, 68.0], [25, 77.0], [30, 86.0], [35, 95.0], [40, 104.0]]<jupyter_text>We can use list comprehension to do this more elegantly:<jupyter_code>table2 = [[C, F] for C, F in zip(Cdegrees, Fdegrees)]
print(table2)<jupyter_output>[[-20, -4.0], [-15, 5.0], [-10, 14.0], [-5, 23.0], [0, 32.0], [5, 41.0], [10, 50.0], [15, 59.0], [20, 68.0], [25, 77.0], [30, 86.0], [35, 95.0], [40, 104.0]]<jupyter_text>And you can loop through this list as before:<jupyter_code>for C, F in table2:
    print(C, F)<jupyter_output>-20 -4.0
-15 5.0
-10 14.0
-5 23.0
0 32.0
5 41.0
10 50.0
15 59.0
20 68.0
25 77.0
30 86.0
35 95.0
40 104.0<jupyter_text>Tuples: lists that cannot be changedTuples are **constant** lists, i.e. you can use them in much the same way as lists except you cannot modify them. They are an example of an [**immutable**](http://en.wikipedia.org/wiki/Immutable_object) type.<jupyter_code>t = (2, 4, 6, 'temp.pdf')               # Define a tuple.
t =  2, 4, 6, 'temp.pdf'                # Can skip parenthesis as it is assumed in this context.<jupyter_output><empty_output><jupyter_text>Let's see what happens when we try to modify the tuple like we did with a list:<jupyter_code>t[1] = -1
t.append(0)
del t[1]<jupyter_output><empty_output><jupyter_text>However, we can use the tuple to compose a new tuple:<jupyter_code>t = t + (-1.0, -2.0)
print(t)<jupyter_output>(2, 4, 6, 'temp.pdf', -1.0, -2.0)<jupyter_text>So, why would we use tuples when lists have more functionality?* Tuples are constant and thus protected against accidental changes.* Tuples are faster than lists.* Tuples are widely used in Python software (so you need to know about tuples to understand other people's code!)* Tuples (but not lists) can be used as keys in dictionaries (more about dictionaries later).  Exercise 3.1: Make a table of function valuesStep 1: Write a program that prints a table with $t$ values in the first column and the corresponding $y(t) = v_0 t − 0.5gt^2$ values in the second column. Use $n$ uniformly spaced $t$ values throughout the interval [0, $2v_0/g$]. Set $v0 = 1$, $g = 9.81$, and $n = 11$.Step 2: Once step 1 is working, modify the program so that the $t$ and $y$ values are stored in two lists *t* and *y*. Thereafter, transverse the lists with a *for* loop and write out a nicely formatted table of *t* and *y* values using a *zip* construction.  FunctionsWe have already used many Python functions, e.g. mathematical functions:<jupyter_code>from math import *
x = pi
print(cos(x))<jupyter_output>-1.0<jupyter_text>Other functions you used include *len* and *range*:<jupyter_code>somelist = range(5, 10, 2)
print(somelist)
print(len(somelist))<jupyter_output>range(5, 10, 2)
3<jupyter_text>You have also used functions that are executed with the dot syntax (called *methods*):<jupyter_code>C = [5, 10, 40, 45]
i = C.index(10)
C.append(50)
C.insert(2, 20)<jupyter_output><empty_output><jupyter_text>A function is a collection of statements we can execute wherever and whenever we want. Functions can take any number of inputs (called *arguments*) to produce outputs. Functions help to organize programs, make them more understandable, shorter, and easier to extend. For our first example we will turn our temperature conversion code into a function:<jupyter_code>def C2F(C):
    return (9.0/5)*C + 32<jupyter_output><empty_output><jupyter_text>Functions start with *def*, then the name you want to give your function, then a list of arguments (here C). This is referred to as the *function header*. Inside the function there is a block of statements called the *function body*. Notice that the function body is indented - as was the case for the *for* / *while* loop the indentation indicates where the function ends. At any point within the function, we can "stop thefunction" and return as many values/variables as required.  Local and global variablesVariables defined within a function are said to have *local scope*. That is to say that they can only be referenced within that function. Let's consider an example (and look carefully at the indentation!!):<jupyter_code>def sumint(start, stop):
    s = 0      # variable for accumulating the sum
    i = start  # counter
    while i <= stop:
        s += i
        i += 1
    return s

print(sumint(0, 5))<jupyter_output>15<jupyter_text>Variables *i* and *s* are local variables in *sumint*. They are destroyed at the end (return) of the function and never visible outside the function (in the calling program); in fact, *start* and *stop* are also local variables. So let's see what happens if we now try to print one of these variables:<jupyter_code>print(stop)<jupyter_output><empty_output><jupyter_text>Functions can also return multiple values. Let's recycle another of our previous examples - compute $y(t)$ and $y'(t)=v_0-gt$:<jupyter_code>def yfunc(t, v0):
    g = 9.81
    y = v0*t - 0.5*g*t**2
    dydt = v0 - g*t
    return y, dydt

# call:
position, velocity = yfunc(0.6, 3)

print(position, velocity)<jupyter_output>0.034199999999999786 -2.886<jupyter_text>Remember that a series of comma separated variables implies a tuple - therefore "return y, dydt" is the same as writing "return (y, dydt)". Therefore, in general what is returned is a tuple. Let's take a look at another example illustrating this:<jupyter_code>def f(x):
    return x, x**2, x**4
s = f(2)
print(s)
print(type(s)) # The function type() tells us what type a variable it is.<jupyter_output>(2, 4, 16)
<class 'tuple'><jupyter_text>No return value implies that *None* is returned. *None* is a special Python object that represents an ”empty” or undefined value. It is surprisingly useful and we will use it a lot later.<jupyter_code>def message(course):
    print("%s rocks!"% course)

message("Python")

r = message("Geo")

print("r = ", r)<jupyter_output>Python rocks!
Geo rocks!
r =  None<jupyter_text>Keyword arguments and default input valuesFunctions can have arguments of the form variable_name=value and are called keyword arguments:<jupyter_code>def somefunc(arg1, arg2, kwarg1=True, kwarg2=0):
    print(arg1, arg2, kwarg1, kwarg2)

somefunc("Hello", [1,2])   # Note that we have not specified inputs for kwarg1 and kwarg2
somefunc("Hello", [1,2], kwarg1="Hi")
somefunc("Hello", [1,2], kwarg2="Hi")
somefunc("Hello", [1,2], kwarg2="Hi", kwarg1=6)<jupyter_output>Hello [1, 2] 6 Hi<jupyter_text>If we use variable_name=value for all arguments, their sequence in the function header can be in any order.<jupyter_code>somefunc(kwarg2="Hello", arg1="Hi", kwarg1=6, arg2=[2])<jupyter_output>Hi [2] 6 Hello<jupyter_text>Let's look at another example - consider a function of $t$, with parameters $A$, $a$, and $\omega$:$$f(t; A,a, \omega) = Ae^{-at}\sin (\omega t)$$. (The choice of equation is actually pretty random - but it serves to show you that it is easy to translate formulae you encounter into Python code). We can implement $f$ in a Python function with $t$ as positional argument and $A$, $a$, and $\omega$ as keyword arguments.<jupyter_code>from math import pi, exp, sin
def f(t, A=1, a=1, omega=2*pi):
    return A*exp(-a*t)*sin(omega*t)

v1 = f(0.2)
v2 = f(0.2, omega=1)
v2 = f(0.2, 1, 3)  # same as f(0.2, A=1, a=3)
v3 = f(0.2, omega=1, A=2.5)
v4 = f(A=5, a=0.1, omega=1, t=1.3)
v5 = f(t=0.2, A=9)

print(v1, v2, v3, v4, v5)<jupyter_output>0.778659217806053 0.5219508827258282 0.40664172703834794 4.230480200204721 7.007932960254476<jupyter_text>Convention for input and output data in functionsA function can have three types of input and output data:* Input data specified through positional/keyword arguments.* Input/output data given as positional/keyword arguments that will be modified and returned.* Output data created inside the function.All output data are returned, all input data are arguments.Sketch of a general Python function:<jupyter_code>def somefunc(i1, i2, i3, io4, io5, i6=value1, io7=value2):
           # modify io4, io5, io7; compute o1, o2, o3
           return o1, o2, o3, io4, io5, io7
           
* i1, i2, i3, i6: pure input data
* io4, io5, io7: input and output data
* o1, o2, o3: pure output data<jupyter_output><empty_output><jupyter_text>Exercise 3.2: Implement a Gaussian functionMake a Python function *gauss*( *x*, *m*=0, *s*=1) for computing the Gaussian function $$f(x)=\frac{1}{\sqrt{2\pi}s}\exp\left(-\frac{1}{2} \left(\frac{x-m}{s}\right)^2\right)$$Call the function and print out the result for x equal to −5, −4.9, −4.8, ..., 4.8, 4.9, 5, using default values for *m* and *s*.  The *if* constructConsider this simple example:<jupyter_code>def f(x):
    if 0 <= x <= pi:
        return sin(x)
    else:
        return 0
print(f(-pi/2), f(pi/2), f(3*pi/2))<jupyter_output>0 1.0 0<jupyter_text>Sometimes it is clearer to write this as an *inline* statement:<jupyter_code>def f(x):
    return (sin(x) if 0 <= x <= pi else 0)
print(f(-pi/2), f(pi/2), f(3*pi/2))<jupyter_output>0 1.0 0
{'fraction_non_alphanumeric': 0.1306670857232655, 'fraction_numerical': 0.07048008171603677, 'mean_word_length': 4.363674673409187, 'pattern_counts': {'":': 0, '<': 101, '<?xml version=': 0, '>': 96, 'https://': 0, 'lorem ipsum': 0, 'www.': 1, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 1, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '29', 'dir': 'jupyter-structured-clean-dedup', 'id': '254923', 'license': 'CC-BY-3.0', 'n_tokens_mistral': 5094, 'n_tokens_neox': 4585, 'n_words': 1742, 'path': 'notebook/Lecture-3-Introduction-to-programming-for-geoscientists.ipynb', 'repo_name': 'navjotk/test', 'size': '12734'}