Skip to article frontmatterSkip to article content

Workshop 1: Intro to Python

This is a Jupyter Notebook, a type of interactive way to run Python. This just means you can run small snippets of code in each cell and examine what they do.

Jupyter notebooks have a .ipynb file extension and are used for things such as testing/developing code, exploring data, making figures, and are incredibly common in both earth sciences, and the data science world.

This notebook is designed to run through the basics of Python, basic ideas in programming/coding, examples of code/basic Python, and tips on how to write efficient and reuseable code.

# This is a code cell, that means it contains code and when executed runs code. 
# Anything in a code cell following a '#' symbol is a comment and won't be executed.
# Comments are used to explain code, reasoning, and remind yourself of things.
# Triple quotes either ''' or """ are used to start/end longer comments such as below.
# Comments are an important part of writing good code, and you should comment your code!
# To run a cell you click the cell and press shift + enter/return
'''Python is designed to utilize packages/libraries. These are pieces of code
that you or someone else wrote. The benefit of this is that you don't need to
reinvent the wheel every single time you code. If you had to make your own code
to make plots from scratch you would most likely hate it. Below we are going to
import some packages that are commonly used in Atmos. Sci.

We do this using the "import" command. It is considered good practice to denote
which packages you import that are standard libraries (those that come with Python),
are public libraries (those that anyone can download), and private libraries (libraries
that either you or someone else have made and is only available locally)'''

##### IMPORTS GO HERE #####
# STANDARD LIBRARIES #
import datetime as dt # This is used to handle dates
import timeit # This tells us how long code took to run
# PUBLIC LIBRARIES #
import numpy as np # This gives us access to advanced math and data structures
# PRIVATE LIBRARIES #
# we're not using any so this is blank, just putting it here so you can see it

Let’s define some variables and explore basic data types. Some basic data types are:

  • Integers
  • Floats
  • Strings
  • Booleans
  • Lists
  • Dicionaries

There others that we aren’t covering today like Sets, and Tuples that can be useful but have more nuance in both their applications, and best practices.

# Strings are snippets of text some example strings are defined below
# In python variables are defined with the name of the variable followed by 
# an equals sign (=).
ex_str = 'The quick brown fox jumped over the lazy dog.'
# The value assigned to a variable in python can be overwritten by defining
# a new variable with the same name, this can be dangerous if done wrong.
ex_str = 'Turtles are pretty cool.'
# Strings can contains numbers and a whole hose of other symbols
ex_str_with_numbers = '6 is greater than 5.14472941 but < 7'
# In Atmospheric science we often see strings as ways to store dates, or weather station names
ex_date_str = '2009/12/25 18:14:08'
ex_weather_stn_str = 'Christman Field'
# We can use the built in print() function to examine variables
print(ex_date_str)
# In this box you'll write your first code
# Create a string containing the phrase 'Hello World!', it can have any name
# and then print that string

# Strings have many built in methods that allow us to work with them
# We'll look at three for now .lower(), .upper(), and .split()
ex_str_method = 'The-Tiny-Tiger-Took-Toms-Tacos'
print('Converting the string to lower case:')
print(ex_str_method.lower())
print('\nConverting the string to upper case:')
print(ex_str_method.upper())
print('\nSplitting apart the string on the hypens:')
print(ex_str_method.split('-'))
# Integers are whole numbers
ex_int = 5
ex_neg_int = -4
# They can be added together
int_addition_ex = ex_int + ex_neg_int
print('Addition example result:')
print(int_addition_ex)
# They can be substracted from one another
int_subtraction_ex = ex_int - ex_neg_int
print('Subtraction example result:')
print(int_subtraction_ex)
# They can be multiplied together
int_multiplication_ex = ex_int * ex_neg_int
print('Multiplication example result:')
print(int_multiplication_ex)
# They can also be divided (this converts ints to floats which we'll talk about next)
int_division_ex = ex_int / ex_neg_int
print('Division example result:')
print(int_division_ex)
# We can also raise things to powers using **
int_power_ex = ex_int**3
print('Exponentiation example result:')
print(int_power_ex)
# Floats are any number with values after a decimal place
# 1.25 is a float, but so is 10.000
# They have the same properties as integers but provide more precision
ex_flt = 2.415
ex_neg_flt = -2.05
# They can be added together
flt_addition_ex = ex_flt + ex_neg_flt
print('Addition example result:')
print(flt_addition_ex)
# They can be substracted from one another
flt_subtraction_ex = ex_flt - ex_neg_flt
print('Subtraction example result:')
print(flt_subtraction_ex)
# They can be multiplied together
flt_multiplication_ex = ex_flt * ex_neg_flt
print('Multiplication example result:')
print(flt_multiplication_ex)
# They can also be divided (this converts ints to floats which we'll talk about next)
flt_division_ex = ex_flt / ex_neg_flt
print('Division example result:')
print(flt_division_ex)
# If we want to force the result of division in python to be an int
# we use two // for division instead of one, this rounds the number up
# when you use // it is called floor division
flt_floor_division_ex = ex_flt // ex_neg_flt
print('Floor Division example result:')
print(flt_floor_division_ex)
# Python evaluates math from left to right but follows basic math rules
# so multiplication and addition are done first
mixed_operations_example = 5 * 1 + 1 / 2
print('Mixed Operations example result:')
print(mixed_operations_example)
# We can use parentheses to make math operations be evaluated how we want them to be
math_parentheses_example = 5 * (1 + 1) / 2
print('Mixed Operations w/ Parentheses example result:')
print(math_parentheses_example)
# When printing a string we'll often want to include some value we've calculated.
# To include values in a string we utilize what is known as an f-string.
# To do this you put a lower case f before the ' to start off a string,
# and you put curly brackets {} around the name of the variable you want to print.
print(f'The int addition example value is {int_addition_ex}.')
# we can also do things such as round numbers
print(f'Rounding our float addition example to two places {flt_addition_ex:.2f}.')
# we can also do operations inside the brackets
print(f'Doing math inside the f-string: {ex_int + ex_flt:.2f}.')
# Booleans (or Bools) are variables that have two possible values False (0), or True (1)
ex_true_bool = True
ex_false_bool = False
# Bools can also be made by using operators such as ==, >, <, !=, <=, or >=
ex_bool = 1 <= 2
print(ex_bool)
# Bools are really important for if/else statements which we'll cover later
# Lists are the next basic data type we're going to look at.
# Lists are collections of other variables, and have lots of cool properties.
# Lists are defined with square brackets [].
ex_list = [1,'a',0.5]
# We access individual elements of a list through indexing.
# In python indexing starts at 0 and progressively goes up.
# For our example list the value at index 0 is 1
# At index 2 is 0.5
print(f'List indexing example. Index 0: {ex_list[0]}, Index 2: {ex_list[2]}')
# In python we can also use negative indexing to access elements from the back
# of the list going towards the front. Negative indices start at -1 and decrease from there
print(f'List negative indexing example. Index -1: {ex_list[-1]}, Index -3: {ex_list[-3]}')
# We can also index a list based on multiple groupings within by
# using a colon : either before, after, or beween two numbers
ex_list_to_index = list(range(100)) # This makes a list with numbers in the range [0-99]
print(f'Indexing a slice of a list example: {ex_list_to_index[40:50]}')
print(f'Indexing a slice of a list using negative index example: {ex_list_to_index[-10:]}')
# We can also index every nth element of an array by adding a second :
print(f'Indexing every tenth number example: {ex_list_to_index[::10]}')
print(f'Indexing every even number on [40-60): {ex_list_to_index[40:60:2]}')
# Lists are mutable, this means we can change them.
# We can change what values are stored at each index
ex_list[1] = -9999
print(f'Our new modified list: {ex_list}')
# We can also make our list bigger with the built-in method .append()
ex_list.append('Pizza')
print(f'Our list with a value appended to it: {ex_list}')
# We can also delete elements at certain positions using .pop()
ex_list.pop(3)
print(f'Our list sans pizza: {ex_list}')
# We can also add elements at a certain position using .insert()
ex_list.insert(3,'Pizza')
print(f'Putting pizza back into our list: {ex_list}')
# Because lists are random collections of items they're not always guaranteed to be sorted
# if we want to sort a list we can use sorted()
unsorted_list = [9.,1,5.,2,0.,3,6.,8,7.,4]
print(f'Sorting our list with sorted(): {sorted(unsorted_list)}')
# This also works on lists of strings
unsorted_str_list = ['Banana','Fudge','Eggs','Apple','Chocolate','Donut']
print(f'Sorting a list of strings: {sorted(unsorted_str_list)}')
test = ['a',0,'z',100]
# sorted() will not work on lists that aren't comparable data types
# remove the # from the line below to see an error
# sorted(['a',100,'z',-8])
# We can also reverse a list using ::-1
print(f'Reversing a sorted list example: {sorted(unsorted_list)[::-1]}')
# We can also add lists together, this appends the second list to the end of the first
ex_list2 = ['Pizza','Cake','French Fries']
ex_combined_lists = ex_list + ex_list2
print(f'Our combined lists: {ex_combined_lists}')
# We can also multiply a list by a whole number to make it repeat
print(f'List multiplication example: {[0]*10}')
# The length of a list can be checked using the len() function
ex_long_list = [0]*10000
print(f'Checking list length example: {len(ex_long_list)}')
# Much like how we can use sorted() to sort a list we can use the built
# in functions max() and min() to get the smallest and largest value
# of a list of numbers
print(f'min() and max() example, Min: {min(ex_list_to_index)}, Max: {max(ex_list_to_index)}')
# Data types can also be converted between each other.
# For example an int can easily become a float, a Bool can become an int or float,
# and an int, a float, and a bool can all become strings.
# This data-type conversion is done with int(), str(), float(), bool(), this is called type-casting.
# The data-type of a variable can be checked with the type() function.
print(f'Data type conversion example: {type(3)}, {type(float(3))}, {type(str(3))}')
print(f'Data type conversion example: {3}, {float(3)}, {str(3)}')
# Unless the string is purely a number you cannot convert it to another data type
# We can also convert any string to a list with list()
# This does have the property of splitting apart strings however
print(f'List conversion example: {list("Apple")}')
# The last basic data type we're going to cover are dictionaries.
# Dictionaries are similar to lists in that they store things
# except where we use numbers to index a list we use 'keys' to
# index a dictionary. Keys can have many possible data types but
# typically it is a string.
# You define a string with {} with the keys separated from the values
# by :
ex_dict = {'Pizza':8.99, 'Breadsticks':3.95, 'Soda':1.25}
print(f'The price of a pizza is: {ex_dict["Pizza"]}')
# Dictionaries can be expanded quite easily by simply
# setting the dictionary with an unused key to some new value
ex_dict['Calzone'] = 7.99
print(ex_dict)
# You can also do the same thing to overwrite a value stored
# with a specific key
ex_dict['Calzone'] = 6.99
print(ex_dict)
# Dictionaries cannot have duplicate keys, keys are unique
# The keys that access values in a dictionary can be accessed via
# calling the .keys() method, this can be bound by the function list()
# to turn the keys into a list we can iterate over
print(f'The keys for our dictionary are: {list(ex_dict.keys())}')
# the values can be accessed in the same way with the .values() method
print(f'The values for our dictionary are: {list(ex_dict.values())}')
# If you have a very large dictionary and you want to check if a key
# is used in that dictionary you can use the .get() method
# If it returns a value of None that means the key does not exist
# within that specific dictionary
print(ex_dict.get('Soup'))

Now that we’ve covered basic data types let’s look at if/else statements.

If/else statements are ways to do different things with code based on the values of your variables, and allow you to do different things with your code.

# This is a simple example and feel free to change the values of the two variables below.
ife_ex_val1 = 4
ife_ex_val2 = 3
ife_ex_val3 = 8
# There are multiple expressions that can be done
# You can have an expression by itself or paired with another
# using the operator 'and' or the operator 'or'

if ife_ex_val1 < ife_ex_val2 and ife_ex_val2 < ife_ex_val3:
    print('Case 1 True')
elif ife_ex_val1 != ife_ex_val2 or ife_ex_val3/ife_ex_val1 == 2:
    print('Case 2 True')
else:
    print('Case 3 True')

Now let’s look at loops.

Loops are ways to repeat pieces of code multiple times. There are two types of loops, for loops, and while loops.

For loops repeat a certain number of times, while loops continue until the boolean that defined them becomes false or the loop is broken. It is very easy to accidentally make an infinite loop that will never end so be careful when looping.

# For loops are defined by what you're looping over, this can be elements of a list, or values generated by the range() function
loop_example_list = [0,1,2,3,4]

# Looping over a list via range
print('Range List Loop')
for i in range(len(loop_example_list)):
    print(loop_example_list[i])
# Looping over a list via the items in the list
print('Iterator List Loop')
for item in loop_example_list:
    print(item)

# both ways of iterating over a list have their place and knowing which to use is
# a skill that you will develop over time
# We can also put loops inside of loops, these are called nested loops
# nested loops extend the functionality of loops but can quickly become slow
# while powerful they should be used sparingly
# In a nested loop the interior loop goes to completion before the outer loop
# finishes a single iteration
for i in range(3):
    for j in range(3):
        print(f'i:{i}, j:{j}')
# While loops continue while some conditions is true, you exit them either by making the condition untrue
# or by using the keyword break.

# While loop using the break keyword pair with if statement
print('While Loop Break Keyword')
while(True):
    print('7')
    if 6 < 7:
        break

# While loop using a value we modify
print('While Loop with Conditional')
while_test_val = 3
while(while_test_val <= 7):
    print(while_test_val)
    while_test_val += 1 # this is another way to add, this adds 1 to while_test_val each time it's evaluated
# Lists can be generated via loops, this is called a list comprehension 
# and is the fastest/most efficient way to generate lists in many circumstances
list_comp_example = [(x+1)**2 for x in range(10)]
print(f'List Comprehension Example Result: {list_comp_example}')
# There are also dictionary comprehension where you loop
# over zipped together pairs of key,value pairs
# that are combined using the zip() function
key_list = ['A','B','C','D']
value_list = [1,2,3,4]
dict_comp_example = {key:value for key,value in zip(key_list,value_list)}
print(f'Dict Comprehension Example: {dict_comp_example}')
%%timeit
# This code block times how long it takes to generate a loop via list comprehension
# it will take a while because it does many many interations
timed_list_comp = [(x**(1/80)/(x+1)) for x in range(1000)]
%%timeit
# This is the same list generated via a for loop and append
timed_list_comp = []
for x in range(1000):
    timed_list_comp.append((x**(1/80)/(x+1)))

The difference between a list comprehension and a for-loop list is small in this example but the more elements you work with (e.g. hourly global reanalysis data for 100 years [~1,000,000,000,000 elements]), and the more complex the operation you’re doing, the larger the difference.

Now let’s look at a non-standard data type, namely datetime objects and numpy arrays. These are very widely used in Atmospheric Science and data science.

# Dealing with dates can be complicated, the datetime package makes this easier
# a datetime object is defined by calling the datetime package we imported earlier
# Datetime objects are useful because it allows us to work with dates that
# make sense to people, versus those that work better for computers 
# (e.g. seconds since Jan. 1st 1970)
example_date = dt.datetime(2024,1,1)
example_date2 = dt.datetime(2000,1,1)
print(example_date,example_date2)
# We can modify date time objects using timedelta objects
# Modify the timedelta objects by change the number of weeks,days,hours,minutes,seconds
# the values can be positive or negative
print(example_date + dt.timedelta(weeks = 0,days = 111, hours = -24, minutes = 0, seconds = 0))
# we can also subtract two dates to get their difference
example_date_timedelta = example_date - example_date2
print(example_date_timedelta)
# We can use boolean operations on datetime objects
print(example_date2 > example_date)
# We can also extract specific pieces from each datetime object
print(example_date.year)
# Timedelta objects immediately group into days when possible
# we can extract the total number of seconds using the .total_seconds() method
print(example_date_timedelta.seconds)
print(example_date_timedelta.total_seconds())
# we can also get the current date
print(dt.datetime.today())
# As well as the day of the week. 0 = Monday, 6 = Sunday
print(dt.datetime.today().weekday())

NumPy is a package that has an extensive library of math, and data structures built into it. The core of this package is the data type we’re going to talk about next NumPy arrays

# A NumPy array is like a list, it can contain anything, although it has certain restrictions.
# In exchange for this restrictions there is a lot more we can do with numpy arrays that we
# can do with lists. NumPy arrays, if used properly, can be faster than lists.
# A NumPy array can be defined in multiple ways.
# The first way is by simply converting a pre-existing list, either after
# we have defined the list or during creation
numpy_array_creation_ex = np.array([i for i in range(50)])
print(f'Initial content of the array we just created: {numpy_array_creation_ex[0:5]}')
# Numpy arrays cannot have mixed typed contents like a list. If it can
# numpy will try and convert everything to the same data type such as with
# this array below.
np_mixed_type_array_ex = np.array([1.1,'a'])
print(f'Element 0 type: {type(np_mixed_type_array_ex[0])}, Element 1 type: {type(np_mixed_type_array_ex[1])}')
# We can also make lists of a pre-determined size
# Say we need an array to store 10,000 strings we can make one to store the 
# data quite easily
str_array_ex = np.empty(10000,dtype=str) # this array is empty but is designed to hold 10,000 strings
# but once a numpy array expects a certain data type it will not accept a different one
# unless of course they are ints/floats, it is fine with those being together
str_array_ex[0] = 55 # this doesn't yield an error but let's print this value
print(str_array_ex[0])
# We can also make lists containing specific values
# The np.linspace() function lets us get N evenly spaced values [i.e. 11]
# between a starting value [i.e. 0], and an inclusive stopping value [i.e. 1]
all_tenths_linspace = np.linspace(0,1,11)
print(all_tenths_linspace)
# The np.arange() function lets us get every between two numbers (non-inclusive)
# based on the starting value [i.e. 0], a stopping value[i.e. 1.1], and a step value [i.e. 0.1]
all_tenths_arange = np.arange(0,1.1,0.1)
print(all_tenths_arange)
# With lists we could modify the size of the list using .append() or .pop()
# while you can technically do similar stuff with numpy arrays it isn't advisable
# Let's use timeit to see how slow that can be
%%timeit
time_test = np.array([])
for i in range(1000):
    time_test = np.append(time_test,i**2/(i+1))
%%timeit
time_test = []
for i in range(1000):
    time_test.append(i**2/(i+1))
# numpy arrays let you apply math operations to the entire array, and doing so
# is incredibly quick if you use the built in numpy math operations
array_to_square = np.array([i+1 for i in range(1000000)])
# if this was a list and we wanted to square all the values in it we would
# be forced to loop over the entire list, this is very slow (would take minutes)
# but with numpy arrays we can do something else.
squared_array = np.square(array_to_square)
print(f'Our now squared array: {squared_array[0:10]}')
# We can also apply operation to the array like we would a regular float/int
array_to_math = np.array([i for i in range(10)]) * 5 + 50
print(f'Result of doing math on array: {array_to_math}')
# Indexing of 1-D numpy arrays is the exact same as lists
print(f'Returning every other number from the back: {array_to_math[::-2]}')
# Unlike lists, numpy arrays work really well with 2D, 3D, 4D, and beyond data
# Let's make a 3-D array just to show 
multi_dimension_array = np.zeros((3,3,3)) # this is a 3x3x3 array containing only zeros
print(multi_dimension_array)
# Because numpy arrays can be in many different shapes we often need to see
# just how big the arrays are. We can do this with the .shape property
print(f'Shape of our numpy array: {multi_dimension_array.shape}')
# Lot's of atmospheric science data is 3D or 4D so getting used to multi-dimensional data is key
# For multi-dimensional arrays indexing is a little different
# Let's make a numpy array where each of the elements has different values
array_to_index = np.reshape(np.array([i+1 for i in range(27)]),(3,3,3))
print(f'How this array originally looks:\n {array_to_index}')
# Because this is a 3-D array it has three different indices to access values within it
# (for real data we can view this as maybe a time, a lat, and a lon).
# Let's see what happens if we index element 0 of the first index
# To do this we do [0,:,:] this says we want the 0th element of the first dimension
# and the : means we want everything of the 2nd and 3rd dimension
# the commas separating the numbers tell python which dimension you want to index
print(f'Indexing 0 of the first dimension:\n {array_to_index[0,:,:]}') 
print(f'Indexing 0 of the second dimension:\n {array_to_index[:,0,:]}')
print(f'Indexing 0 of the third dimension:\n {array_to_index[:,:,0]}')
# Another cool feature of numpy arrays is that we can easily search them
# for specific values
# The line below creates a randomized array of 40 numbers between 0-10
random_nums = np.random.randint(0,10,40)
# Using the np.where() function we get a numpy array of indices for values
# we care about
inds_above_20 = np.where(random_nums > 7)
# We can use this numpy array to subset our array
print(f'Random numbers above 20: {random_nums[inds_above_20]}')
# Numpy also contains useful functions to understand data with.
# The function np.max() returns the maximum value of a list or numpy array.
# np.abs() takes an absolute value of either a float, int, list, or numpy array.
numpy_function_array_ex = np.array([-20,55,12,-1])
print(f'Maximum value of our array: {np.max(numpy_function_array_ex)}')
print(f'Absolute value of items in our array: {np.abs(numpy_function_array_ex)}')
# We can also do things such as take a mean with np.mean(), the median with np.median()
# There are a wide array of numpy functions that will make your life easier. 
# To learn about them I recommend the documentation on numpy.
# https://numpy.org/doc/2.0/

Now let’s look at functions.

Functions are small snippets of code designed to accomplish a single task that we want to reuse quickly and easily. By chaining multiple functions together we can write large amounts of reuseable and efficient code.

In atmospheric sciences we often work with data that is similar with maybe a few parameters being different (e.g. time series of different length, or outputs of different model runs). Correctly written functions allow us to process data like this with minimal effort.

Functions work off of the idea of inputs turning into outputs. Functions can take in an input (or even no inputs) and their output is either some affect on another piece of code or a variable(s).

# below is an example of a simple function in python
# creating a new function is composed of several parts
# first is the word 'def' this tells python that we are defining a function
# following def is the name of the function in this case convert_f_to_c
# usually your function name should succinctly describe what your function does
# after that is a set of parentheses () these are the inputs your function takes
# this can be left black but for this example our function takes a single input temp
# behind our input temp is what's called a type hint, this is a way of telling
# another program what data type the function expects as an input
# in our example the data type is float
# the final bit -> float: this is another type hint
# this tells whoever is using the function to expect the output of a function
# to be a float. Type hints are a really important tool for better
# understanding how functions work and operate.
def convert_f_to_c(temp:float) -> float:
    # functions typically start with what's called a doc string
    # this is a string outlining what the function does and what
    # is expected of the functions inputs and outputs.
    '''
        This functions converts temperatures in fahrenheit to celsius.

        Parameters:
            - temp (float): The temperature in fahrenheit

        Returns:
            - conv_temp (float): The temperature in celsius
    '''
    # After the doc string is the body of the function, this is the
    # piece of code that we would like to repeat.
    conv_temp = 5*(temp - 32)/9

    # The last part of any function is the return, this is what the function
    # actually outputs. In some cases we are returning nothing or None, but
    # for this example we are returning the result of our calculation
    return conv_temp

# let's test out our function to make sure it works, we'll test it at -40, and 212 fahrenheit
# these are called test cases and serve as important checks on code to ensure it is well written
print(f'-40 Fahrenheit Test Conversion. Expected: -40.0, Returned: {convert_f_to_c(-40)}')
print(f'212 Fahrenheit Test Conversion. Expected: 100.0, Returned: {convert_f_to_c(212)}')
# You can call functions inside functions (this allows us to chain functions together)
# this is a very powerful ability that helps lead to well-written code and provides
# unique functionality 
def convert_f_to_kelvin(temp:float) -> float:
    '''
        This functions converts temperatures in fahrenheit to kelvin.

        Parameters:
            - temp (float): The temperature in fahrenheit

        Returns:
            - conv_temp (float): The temperature in kelvin
    '''

    # Get the temperature in C using our function from earlier
    temp_in_c = convert_f_to_c(temp)
    # now convert to kelvin
    conv_temp = temp_in_c + 273.15

    return conv_temp

print(f'32 Fahrenheit Test Conversion. Expected: 273.15, Returned {convert_f_to_kelvin(32)}')
# functions can be applied to lists easily either by making a new list
# via a list comprehension, or using pythons built in map() function
# list comprehensions are still preferred over map() but I just want to 
# mention them because there is lots of older code that uses map()
# particularly code that comes from computer scientists
%%timeit
test_list = [-40,212,100,-5,78,95,53,29,15,0.4,66,72,58,35,52]*1000
converted_list_map = list(map(convert_f_to_c,test_list))
%%timeit
test_list = [-40,212,100,-5,78,95,53,29,15,0.4,66,72,58,35,52]*1000
converted_list_comp = [convert_f_to_c(temp) for temp in test_list]
%%timeit
# We can also apply our function to a numpy array quite easily
# This ends up being very fast because of how well made numpy is
test_array = np.array([-40,212,100,-5,78,95,53,29,15,0.4,66,72,58,35,52]*1000)
converted_array = np.apply_along_axis(convert_f_to_c,0,test_array)

Classes are the final thing we’ll cover today.

Classes are a way to combine data storage and functions together. When you make a class you make a new type of ‘object’. The data types we have been working with so far are all secretly objects.

Classes are incredibly powerful, you could theoretically write a class that is a self-contained functioning climate model.

We are going to explore the basic definition of a class below. In the exercises for this workshop we’ll further explore and expand upon classes.

# Here is an example of a class for a weather station object
# This weather station class stores information such as its name, and geographic position
# it also has a (for now) empty function to convert an observed temperature and RH to dewpoint

# Classes are defined similar to functions except instead of leading with
# the keyword def we lead with the keyword class. We also do not include
# parameters or type hints here, a class is a class. 
class weather_station():
    # all class definitions start with a the __init__ function, this is just
    # a way to tell python that when we make an object of a class these are
    # the bare minimum properties it has to have.
    # The word self, the leads the function definition just tells python
    # that the class has access to itself. There are niche circumstances
    # where you do not need to include self in __init__ or __init__ but
    # those are very niche circumstances and meant for very advanced users
    # of python or object oriented programming
    def __init__(self, name:str,lat:float,lon:float) -> None:
        # this says that any weather station object that we define must
        # have the following properties, a name, and a lat/lon location.
        # The temperature, relative humidity, and dew point temperature
        # variables are left empty for now so that we can fill them in later.
        self.name = name
        self.lat = lat
        self.lon = lon
        self.temp = None
        self.RH = None
        self.dew_pt_temp = None
    
    # functions defined inside a class are called methods, they serve
    # as ways to either interact with/process data inside a class
    # or to allow other things to interact with the data inside a class
    # this function tells the weather station to make observations of its
    # 'surroundings' to get the temperature and relative humidity from the
    # sensors that are part of the physical weather station.
    def observe_temp_and_rh(self,temp:float,RH:float) -> None:
        '''
            Given a temperature provided by the user set the weather station's
            current observed temp to that value.

            Parameters:
                - temp (float): The temperature in fahrenheit

            Returns:
                - None
        '''
        # This sets the the temperature and relative humidity values
        # that is stored by the class
        # Calling self.{insert property name} either calls that value 
        # and is used to interact with data stored within the class object
        self.temp = temp
        self.RH = RH

        return None
    
    # This is the function we saw earlier but in this context it is now
    # a method of the weather_station class
    def convert_f_to_c(temp:float) -> float:
        '''
            This functions converts temperatures in fahrenheit to celsius.

            Parameters:
                - temp (float): The temperature in fahrenheit

            Returns:
                - conv_temp (float): The temperature in celsius
        '''

        conv_temp = 5*(temp - 32)/9

        return conv_temp
    
    def calculate_dew_point(self) -> float:
        '''
            I am leaving this intentionally blank for now, we'll fill it in
            later.
        '''

        return None
# Now that we've defined our weather_station class we can create new instances of it
# we do this by invoking the name of our class and providing the parameters it
# needs to define itself.
# The example below makes a weather_station object for the Christman Field
# weather station across the street from the department.
christman_field = weather_station('Christman Field',40.597,-105.144)
# We can call the methods we defined earlier (the functions we made inside the class definition)
# We do this by taking the object we defined earlier and calling the methods contained 
# within it with a . and then the name of the method
christman_field.observe_temp_and_rh(88,55)
# We can also access properties of the class we created in exactly
# the same way that we called the method.
print(f'Name of our weather station: {christman_field.name}')
print(f'Temp at our weather station: {christman_field.temp}F')
print(f'RH at our weather station: {christman_field.RH}%')
# Looking back at some of the data types we examined earlier
ex_str_class = 'Example of String as a Class'
# We can see that strings are secretly class objects
# because we can call methods on them
print(ex_str_class.upper())
# This goes for ints, floats, bools, lists, dictionaries, and numpy arrays
# they're all secretly class objects hiding in plain sight.
# When we import packages in python we are also making objects in the import class
# python, and many other languages, are actually just classes upon classes
# stacked on top of each other.

Now that we’ve covered a lot of material let’s try and put it into practice with the Workshop_1_Exercises notebook