# When a line begins with a '#' character, it designates a comment. This means that it's not actually a line of code

# This is how you say hello world
print('hello world')

# Can you make Python print the staircase below:
#
#                    ========
#                    |      |
#             ===============      
#             |      |      |
#      ======================

print('                    ========')
print('                    |      |')
print('             ===============')
print('             |      |      |')
print('      ======================')

"""
Explanation: Python 3 Tutorial Notebook
We'll be using this notebook to follow the slides from the workshop. You can also use it to experiment with Python yourself! Simply add a cell wherever you want, type in some Python code, and see what happens!
Topic 1: Printing
1.1 Printing Basics
End of explanation
"""


# The print(...) function can accept as many arguments as you'd like. It prints the arguments
# in order, separated by a space. For example...

print('hello', 'world', 'i', 'go', 'to', 'princeton')

# You can change the delimiter that separates the comma-separated arguments by changing the 'sep' parameter:

print('hello', 'world', 'i', 'go', 'to', 'princeton', sep=' --> ')

# By default, python adds a newline to the end of any statement you print. However, you can change this
# by changing the 'end' parameter. For example...

# Here we've told Python to print 'hello world' and then an empty string. This effectively 
# removes the newline that Python adds by default.
print('hello prince', end='')

# The next line that we print then begins immediately after the previous thing we printed.
print('ton')

# We can also end our lines with something more exotic
print('ACM is so cool', end=' :)')

"""
Explanation: 1.2 Some other useful printing tips/tricks
End of explanation
"""


# What does the following snippet of code do?

day = 24
month = 'September'
year = '2021'
dotw = 'Friday'

print(month, day - 7, year, 'was a', dotw)

# Explanation: Tells us that the day 7 days ago was the same day of the week as today. The more you know...?

age_in_weeks = 1057

# What's the difference between the two statements below? Comment one of them out to check yourself!
age_in_years = 1057 / 52
age_in_years = 1057 // 52

print(age_in_years)

# Explanation: The '/' operator does float division in Python by default, while the '//' operator does
# integer division (i.e. it returns the decimal answer, ALWAYS rounded down to an integer)

# Try to calculate the following in your head and see if your answer matches what Python says

mystery = 2 ** 4 * 3 ** 2 % 7 * (2 + 7)
print(mystery)

# Explanation: First evaluate anything in parentheses, then do the exponents, then do multiplication/modulo 
# FROM LEFT TO RIGHT. So
# 2 ** 4 * 3 ** 2 % 7 * (2 + 7) = 2 ** 4 * 3 ** 2 % 7 * 9 = 16 * 9 % 7 * 9 = 144 % 7 * 9 = 4 * 9 = 36

# Write a function that converts a given temperature in Farenheit to Celsius and Kelvin
# The relevant formulas are (degrees Celsius) = (degrees Farenheit - 32) * 5 / 9
# and (Kelvin) = (degrees Celsius + 273)

farenheit = 86 # Change the value here to test your solution
celsius = (farenheit - 32) * 5 / 9
kelvin = celsius + 273

print(farenheit, 'degrees farenheit =', celsius, 'degrees celsius =', kelvin, 'kelvin')

"""
Explanation: Topic 2: Variables, Basic Operators, and Data Types
2.1 Playing with Numbers
For a description of all the operators that exist in Python, you can visit https://www.tutorialspoint.com/python/python_basic_operators.htm.
End of explanation
"""


# You are given the following string:
a = 'Thomas Cruise'

# Your job is to put the phrase 'Tom Cruise is 9 outta 10' into variable b using ONLY operations on string a.
# You may not concatenate letters or strings of your own. HINT: You can use the str(...) function to convert
# numerical values into strings so that you can concatenate it with another string
b = a[0] + a[2:4] + a[6:] + a[6] + a[10:12] + a[6] + str(a.find('u')) + a[6] + a[2] + a[9] + \
                                            (a[0].lower() * 2) + a[4] + a[6] + str(a.find('i'))

print(b)

# Practice with string formatting with mad libs! For this, you'll need to know 
# how to receive input. It's really easy in Python:

word_1 = input('Input first word:\n') # This prompts the user with the phrase 'Input first word'
                                      # and stores the result in the variable word_1
word_2 = input('Input second word:\n')
word_3 = input('Input third word:\n')
word_4 = input('Input fourth word:\n')

# You want to print the following mad libs:
#
# Hi, my name is [first phrase]. 
# One thing that I love about Princeton is [second phrase].
# One pet peeve I have about Princeton is [third phrase], but I can get over it because I have [fourth phrase].
# 
# For the last sentence, use one print statement to print it!

print('\nYour mad libs is: ')
print('Hi, my name is {}.'.format(word_1))
print('One thing that I love about Princeton is {}.'.format(word_2))
print('One pet peeve I have about Princeton is {}, but it\'s OK because I have {}.'.format(word_3, word_4))

"""
Explanation: 2.2 Playing with Strings
End of explanation
"""


# Your objective is to write a boolean formula in Python that takes three boolean variables (a, b, c)
# and returns True if and only if exactly one of them is True. This is called the xor of the variables

# Toggle these to test your formula
a = False
b = True
c = False

# Write your formula here
xor = (a and not b and not c) or (not a and b and not c) or (not a and not b and c)
print(xor)

"""
Explanation: 2.3 Playing with Booleans
End of explanation
"""


# In Python, data types are divided into two categories: truthy and falsy. Falsy values include anything
# (strings, lists, etc.) that is empty, the special value None, any zero number, and the boolean False. 
# You can use the bool(...) function to check whether a value is truthy or falsy:

print('bool(3) =', bool(3))
print('bool(0) =', bool(0))
print('bool("") =', bool(''))
print('bool(" ") =', bool(' '))
print('bool(False) =', bool(False))
print('bool(True) =', bool(True))

"""
Explanation: 2.4 Mixing Types
End of explanation
"""


x = 5

# What is the difference between this snippet of code:

if x % 2 == 0:
    print(x, 'is even')
if x % 5 == 0:
    print(x, 'is divisible by 5')
if x > 0:
    print(x, 'is positive')
    
print()
    
# And this one:
if x % 2 == 0:
    print(x, 'is even')
elif x % 5 == 0:
    print(x, 'is divisible by 5')
elif x > 0:
    print(x, 'is positive')
    
# Explanation: the elif will only execute if all statements in the block above evaluated to false.
# In the second block, the first if statement evaluates to false, so the first elif's condition is
# tested. It is true, so then the second elif will not execute.

# Follow-up: An if statement starts its own new block. So the first snippet of code is actually
# three if statement blocks.

# FizzBuzz is a very well-known programming challenge. It's quite easy, but it can trip up people
# who are trying to look for shortcuts to solving the problem. The problem is as follows:
# 
# For every number k in order from 1 to 50, print
# - 'FizzBuzz' if the number is divisible by 3 and 5
# - 'Fizz' if the number is only divisible by 3
# - 'Buzz' if the number is only divisble by 5
# - the value of k if none of the above options hold
#
# Your task is to write a snippet of code that solves FizzBuzz.

for i in range(1, 51):
    div3 = ((i % 3) == 0)
    div5 = ((i % 5) == 0)
    
    if div3:
        if div5: print('FizzBuzz')
        else: print('Fizz')
    elif div5:
        print('Buzz')
    else: print(i)

"""
Explanation: Topic 3: If Statements, Ranges, and Loops
3.1 Practice with the Basics
End of explanation
"""


# The following if statement construct is so common it has a name ('ternary statement'):
#
# if (condition): 
#    x = something1
# elif (condition2):
#    x = something2
# else:
#    x = something3
#
# In python, this can be shortened into a one-liner:
#
# x = something else something2 if (condition2) else something3
#
# And this works for an arbitrary number of elif statements in between the initial if and final else.

# Can you convert the following block into a one-liner?
budget = 3
if budget > 50:
    restaurant = 'Agricola'
elif budget > 30:
    restaurant = 'Mediterra'
elif budget > 15:
    restaurant = 'Thai Village'
else:
    retaurant = 'Wawa'
    
# Write your solution below:
restaurant = 'Agricola' if budget > 50 else 'Mediterra' if budget > 30 \
                                else 'Thai Village' if budget > 15 else 'Wawa'

print(restaurant)

"""
Explanation: 3.2 Ternary Statements in Python
End of explanation
"""


# Your job is to create a 'guessing game' where the program thinks of an integer from 1 to 50
# and will keep prompting you for a guess. It'll tell you each time whether your guess is
# too high or too low until you find the number.

# Don't touch these two lines of code; they choose a random number between 1 and 50
# and store it in mystery_num
from random import randint
mystery_num = randint(1, 100)

# Write your guessing game below:
guess = int(input('Guess a number:\n')) # First guess; don't forget to convert it to an int!
while mystery_num != guess:
    if guess > mystery_num: guess = int(input('Nope. Guess was too high!\n'))
    elif guess < mystery_num: guess = int(input('Nope. Guess was too low!\n'))

print('You got it!')

# Follow-up: Using the best strategy, what's the worst-case number of guesses you should need?

"""
Explanation: 3.3 Practice with While Loops
End of explanation
"""


# When at the top of a loop, the 'in' keyword in Python will iterate through all of the sequence's 
# members in order. For strings, members are individual characters; for lists and tuples, they're 
# the items contained.

# Task: Given a list of lowercase words, print whether the word has a vowel. Example: if the input is
# ['rhythm', 'owl', 'hymn', 'aardvark'], you should output the following:
# rhythm has no vowels
# owl has a vowel
# hymn has no vowels
# aardvark has a vowel

# HINT: The 'in' keyword can also test whether something is a member of another object.
# Also, don't forget about break and continue!

vowels = ['a', 'e', 'i', 'o', 'u']
words = ['rhythm', 'owl', 'hymn', 'aardvark']

for word in words:
    has_vowel = False
    for letter in word:
        if letter in vowels: 
            has_vowel = True
            break # Not necessary, but is more efficient
    
    if has_vowel:
        print(word, 'has a vowel')
    else:
        print(word, 'has no vowels')

# Given a tuple, write a program to check if the value at index i is equal to the square of i.
# Example: If the input is nums = (0, 2, 4, 6, 8), then the desired output is
#
# True
# False
# True
# False
# False
#
# Because nums[0] = 0^2 and nums[2] = 4 = 2^2. HINT: Use enumerate!

nums = (0, 2, 4, 6, 8)
for i, num in enumerate(nums): print(num == i * i)

"""
Explanation: Topic 4: Data Structures in Python
4.1 Sequences
Strings, tuples, and lists are all considered sequences in Python, which is why there are many operations that work on all three of them.
4.1.1 Iterating
End of explanation
"""


# Slicing is one of the operations that work on all of them.

# Task 1: Given a string s whose length is odd and at least 5, can you print 
# the middle three characters of it? Try to do it in one line.
# Example: if the input is 'PrInCeToN', the the output should be 'nCe'
s = 'PrInCeToN'
print(s[len(s) // 2 - 1 : len(s) // 2 + 2])

# Task 2: Given a tuple, return a tuple that includes only every other element, starting
# from the first. Example: if the input is (4, 5, 'cow', True, 9.4), then the output should
# be (4, 'cow', 9.4). Again, try to do it in one line — there's an easy way to do it with slicing.

t = (4, 5, 'cow', True, 9.4)
print(t[::2])
print(t[0::2]) # also acceptable
print(t[0:len(t):2]) # also acceptable, but less ideal

# Task 3: Do the same as task 2, except start from the last element and alternate backwards.
# Example: if the input is (3, 9, 1, 0, True, 'Tiger'), output should be ('Tiger', 0, 9)

t = (3, 9, 1, 0, True, 'Tiger')
print(t[::-2])

"""
Explanation: 4.1.2 Slicing
End of explanation
"""


# Task 1: Given a list of names, return a new list where all the names which are more than 15
# characters long are removed.

names = ['Nalin Ranjan', 'Howard Yen', 'Sacheth Sathyanarayanan', 'Henry Tang', \
         'Austen Mazenko', 'Michael Tang', 'Dangely Canabal', 'Vicky Feng']

# Write your solution below
print([name for name in names if len(name) <= 15])

# Task 2: Given a list of strings, return a list which is the reverse of the original, with
# all the strings reversed. Example: if the input is ['Its', 'nine', 'o-clock', 'on a', 'Saturday'],
# then the output should be ['yadrutaS', 'a no', 'kcolc-o', 'enin', 'stI']. Try to do it in one line!

# HINT: Use list comprehension and negative indices!

l = ['Its', 'nine', 'o-clock', 'on a', 'Saturday']
print([word[::-1] for word in l[::-1]]) 
print([l[-i][::-1] for i in range(1, len(l) + 1)]) # Also acceptable, but a little less ideal

l1 = [5, 2, 6, 1, 8, 2, 4]
l2 = [6, 1, 2, 4]

# Python has a bunch of useful built-in list functions. Some of them are

l1.append(3) # adds the element 3 to the end of the the list
print(l1)

l1.insert(1, 7) # adds the element 7 as the second element of the list
print(l1)

l1.remove(2) # Removes the first occurrence of 7 in the list (DOES NOT REMOVE ALL)
print(l1)

l1.pop(4) # Remove the fifth item of the list (since everything is zero-indexed)
print(l1)

l1.sort() # Sorts the list in increasing order
print(l1)

l1.sort(reverse=True) # Sorts the list in decreasing order
print(l1)

print(l1.count(2)) # Counts the number of occurrences of the number 2 in the list

l1.extend(l2) # Appends all elements in l2 to the end of l1
print(l1)

# If the list is numeric, we can find the min, max, and sum easily:
print('Sum:', sum(l1))
print('Minimum:', min(l1))
print('Maximum:', max(l1))

# You can see all the list methods at https://www.w3schools.com/python/python_ref_list.asp

"""
Explanation: 4.2 List Comprehension and Other Useful List Functions
End of explanation
"""


# Task 1: In a dictionary, keys must be unique, but values need not be. Given a dictionary, write a script
# that prints the set of all unique values in a dictionary. Example: if the dictionary is
# {'Cap': 'bicker', 'Quad': 'sign-in', 'Colonial': 'sign-in', 'Tower': 'bicker', 'Charter': '???'}
# The program should print {'sign-in', 'bicker', '???'}

d = {'Cap': 'bicker', 'Quad': 'sign-in', 'Colonial': 'sign-in', 'Tower': 'bicker', 'Charter': '???'}
unique_vals = set()
for key in d: 
    unique_vals.add(d[key])

print(unique_vals)

# In fact, there's a more Pythonic way to do this in one line:
print(set([val for val in d.values()]))

# We used list comprehension to put every value into a list (which may have contained duplicates)
# and then converting it to a set removed the duplicates (since every element in a set must be unique).

# Task 2: Given a passage of text (a string), analyze the frequency of each individual letter. Sort
# the letters by their frequency in the passage. Does your distribution look reasonable for English?

passage = """it was the best of times, it was the worst of times, it was the age of wisdom, it was 
            the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was 
            the season of Light, it was the season of Darkness, it was the spring of hope, it was the 
            winter of despair, we had everything before us, we had nothing before us, we were all going 
            direct to Heaven, we were all going direct the other way -- in short, the period was so far 
            like the present period that some of its noisiest authorities insisted on its being received, 
            for good or for evil, in the superlative degree of comparison only"""

# Here's the alphabet to help you out; it'll help you ignore other characters
alphabet = "abcdefghijklmnopqrstuvwxyz"

# This adds a key in the dictionary for each letter of the alphabet
d = dict.fromkeys(alphabet, 0)

for char in passage:
    if char in alphabet: d[char] += 1

# Don't change the code below: it'll take your dictionary of frequencies and sort it from most frequent to least
freqs = [(letter, d[letter]) for letter in d]
freqs.sort(key = lambda x: x[1], reverse=True)

print(freqs)

"""
Explanation: 4.3 Sets and Dictionaries
End of explanation
"""


# Task 1: Write a function that returns the minimum of three numbers. Don't use the built-in min function
def my_min(a, b, c):
    if a <= b:
        return a if a <= c else c
    else:
        return b if b <= c else c
    
print('Minimum of 6, 3, 7 is', my_min(6, 3, 7))
print('Minimum of 0, 3.333, -52 is', my_min(0, -52, 3.333))
print('Minimum of -3, -1, 3.14159 is', my_min(-3, -1, 3.14159))

# Task 2: Write a function that checks if a given tuple of numbers is increasing (that is, each number
# is at least the number before it)

def my_increasing(t):
    if len(t) == 0: return True
    
    prev = t[0]
    for i in t:
        if i < prev: return False
        prev = i
        
    return True

print('(1, 2, 3, 4, 5, 7, 8) is increasing:', my_increasing((1, 2, 3, 4, 5, 7, 8)))
print('(1, 2, 3, 2, 5, 7, 8) is increasing:', my_increasing((1, 2, 3, 2, 5, 7, 8)))
print('(-1, 2, 3, 2.99, 5, 7, 8) is increasing:', my_increasing((1, 2, 3, 2, 5, 7, 8)))

# Task 3: Given a list of numbers that is guaranteed to contain all but one of the consecutive integers
# 1 to N (for some N), find the one that is missing. For example, if the input is [2, 1, 5, 4], your function
# should return 3, because that's the number missing from 1-5.

def my_missing(l):
    s = sum(l)
    n = len(l) + 1
    return n * (n + 1) // 2 - s # Why does this work?

print(my_missing([2, 1, 5, 4]))
print(my_missing([3, 4, 6, 2, 5, 7, 9, 8]))

"""
Explanation: Topic 5: Functions in Python
5.1 Practice with Basic Functions
End of explanation
"""


# Task: The sequence of Fibonacci numbers starts with the numbers 1 and 1 and every subsequent term
# is the sum of the previous two terms. So the sequence is 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 87, 144, ...
# Can you write a simple recursive function that calculates the nth Fibonacci number?

# WARNING: Don't call your function for anything more than 35 or pass a non-integer parameter. 
# Your notebook might crash if you do.

def fib(n):
    if n == 1 or n == 2: return 1
    
    return fib(n - 1) + fib(n - 2)

print(fib(35))

"""
Explanation: 5.2 Recursion
End of explanation
"""


# Part of the reason that we told you not to run your answer for 5.2 for large n is because the number of
# function calls generated is exponentially large: for n = 35, the number of function calls you have is on
# the order of 34 billion, which is a lot, even for a computer! If you did n = 75, the number of calls you
# would make is approximately 37 sextillion, which is more than the number of seconds until the heat death
# of the sun. 

# You can avert this issue, however, if you **memoize** your function, which is just a fancy way of saying
# that you can remember values of your function instead of having to re-evaluate your function again. Python
# has a handy memoization tool:

from functools import lru_cache

@lru_cache
def fib(n):
    if n == 1 or n == 2: return 1
    
    return fib(n - 1) + fib(n - 2)

print(fib(100)) # Works no problem!

# All we had to do was add the import statement and 'decorate' the function we wanted to remember
# values from with the line @lru_cache

"""
Explanation: 5.3 Memoization
End of explanation
"""


# Write a PrincetonStudent class, where a PrincetonStudent has a name, major, year,
# set of clubs, and a preference ordering of dining halls. We want to have
#
# - a default constructor that initializes the PrincetonStudent with a name, major, PUID, year, no clubs,
#   and a random preference ordering of dining halls
# - a special constructor (class method) called detailed_student that initializes a PrincetonStudent 
#   with a name, major, year,
#   a specific set of clubs, and a particular preference ordering of dining halls
# - a __str__() method that prints all the data of the student
# - a move_dhall_to_top() function that takes a dhall and moves it to the top
#   of one's dining hall preference list
# - a __lt__() method that returns true if and only if this student has a name that comes before
#   the other's alphabetically
# - an __eq__() method that returns true if and only if the PUIDs of students are equal 

# HINT: To generate a random dining hall preference order, you can take a particular preference order
# and shuffle it using the random.shuffle(list) function

from random import shuffle

class PrincetonStudent():
    def __init__(self, name, major, puid, year):
        self.name = name
        self.major = major
        self.year = year
        self.puid = puid
        self.clubs = []
        
        # From least preferred to most
        self.dhall_pref = ['WuCox', 'Whitman', 'Forbes', 'CJL', 'RoMa']
        shuffle(self.dhall_pref)
        
    @classmethod
    # Initialize a student with name, major, year, specific set of clubs, and a dhall preference ordering
    def detailed_student(cls, name, major, puid, year, clubs, dhall_pref):
        new_student = PrincetonStudent(name, major, puid, year)
        new_student.clubs = clubs
        new_student.dhall_pref = dhall_pref
        
        return new_student
        
    def move_dhall_to_top(self, dhall):
        if dhall in self.dhall_pref:
            move_to_top = self.dhall_pref.remove(dhall)
            self.dhall_pref.append(dhall)
        
    # Returns a string description of the student. This allows us to call print(...) on a
    # PrincetonStudent and get an intelligible result
    def __str__(self):
        str_version = "Name: " + self.name + "\n"
        str_version += "Year: " + str(self.year) + "\n"
        str_version += "Concentration: " + self.major + "\n"
        str_version += "Clubs: " + str(self.clubs) + "\n"
        str_version += "Dining Halls from Most Favorite to Least: " + str(self.dhall_pref[::-1]) + "\n"
        
        return str_version
    
    def __lt__(self, other):
        return (self.name.lower() < other.name.lower()) # This works because string comparison 
                                                        # is automatically alphabetical
        
        
# Test your PrincetonStudent class using this test suite. Feel free to write your own too!
nalin = PrincetonStudent('Nalin Ranjan', 'COS', '123456789', 2022)
print(nalin, end="\n\n")

nalin.clubs.extend(['ACM', 'Taekwondo', 'Princeton Legal Journal', 'Badminton'])
print(nalin, end="\n\n")

sacheth_clubs = ['ACM', 'Table Tennis']
sacheth_prefs = ['WuCox', 'Whitman', 'Forbes', 'RoMa', 'CJL']
sacheth = PrincetonStudent.detailed_student('Sacheth Sathyanarayanan', 'COS', \
                                '24681012', 2022, sacheth_clubs, sacheth_prefs)
print(sacheth)

print('Sacheth had a great meal at Whitman! It is now his favorite.\n')
sacheth.move_dhall_to_top('Whitman')
print(sacheth)

print('Sacheth is the same student as Nalin:', sacheth == nalin)
print('Sacheth\'s name comes before Nalin\'s:', sacheth < nalin)

"""
Explanation: Topic 6: Classes in Python
6.1 Practice Writing Basic Classes
End of explanation
"""


# Write an ACMOfficer class that inherits the PrincetonStudent class. An ACMOfficer has every attribute
# a PrincetonStudent has, and also a position and term expiration date. You'll only need to overwrite
# the constructors to accommodate these two additions. Remember that you can still call the parent's 
# functions as subroutines.

class ACMOfficer(PrincetonStudent):
    def __init__(self, name, major, puid, year, acm_pos, acm_term_exp):
        PrincetonStudent.__init__(self, name, major, puid, year)
        self.acm_pos = acm_pos
        self.acm_term_exp = acm_term_exp
        
    @classmethod
    def detailed_officer(cls, name, major, puid, year, clubs, dhall_pref, acm_pos, acm_term_exp):
        new_officer = ACMOfficer(name, major, puid, year, acm_pos, acm_term_exp)
        new_officer.clubs = clubs
        new_officer.dhall_pref = dhall_pref
        
        return new_officer
    
    def __str__(self):
        str_version = PrincetonStudent.__str__(self)
        str_version += "Position on the ACM Board: " + self.acm_pos + "\n"
        str_version += "Term expires in: " + str(self.acm_term_exp) + "\n"
        
        return str_version
        
# Test your PrincetonStudent class using this test suite. Feel free to write your own too!
nalin = ACMOfficer('Nalin Ranjan', 'COS', '123456789', 2022, 'Chair', 2022)
print(nalin, end="\n\n")

nalin.clubs.extend(['ACM', 'Taekwondo', 'Princeton Legal Journal', 'Badminton'])
print(nalin, end="\n\n")

sacheth_clubs = ['ACM', 'Table Tennis']
sacheth_prefs = ['WuCox', 'Whitman', 'Forbes', 'RoMa', 'CJL']
sacheth = ACMOfficer.detailed_officer('Sacheth Sathyanarayanan', 'COS', '24681012', 
                                      2022, sacheth_clubs, sacheth_prefs, 'Treasurer', 2022)
print(sacheth)

print('Sacheth had a great meal at Whitman! It is now his favorite.\n')
sacheth.move_dhall_to_top('Whitman')
print(sacheth)

print('Sacheth is the same student as Nalin:', sacheth == nalin)
print('Sacheth\'s name comes before Nalin\'s:', sacheth < nalin)

"""
Explanation: 6.2 Inheritance
End of explanation
"""


# Numpy contains many mathematical functions/data analysis tools you might want to use
import numpy as np

# First: Write a function that returns the Gudermannian function evaluated at x.
def gudermannian(x, gamma):
    return gamma * np.arctan(np.tanh(x / gamma))

# Next: use matplotlib to plot the function. HINT: Use matplotlib.pyplot
from matplotlib import pyplot as plt # You'll refer to pyplot as plt from now on

# HINT: pyplot requires that you have a set of x-values and a corresponding set of y-values.
# To make your plot look like a continuous curve, just make your x-values close enough (say in
# increments of 0.01). You'll have to use numpy's arange function (Google it!)
x_vals = np.arange(-5, 5, 0.01)

# Then, you'll have to make a set of y values for each gamma. HINT: If f(x) is a function
# defined on a single number, then running it on x_vals evaluates the function at every x value
# in x_vals. 

plt.plot(x_vals, gudermannian(x_vals, 2), label='gamma = 2')
plt.plot(x_vals, gudermannian(x_vals, 4), label='gamma = 4')
plt.plot(x_vals, gudermannian(x_vals, 6), label='gamma = 6')
plt.legend()

"""
Explanation: Topic 7: Using Existing Python Libraries
Sigmoid activation functions are ubiquitous in machine learning. They all look somewhat like an S shape, starting
out flat, and then somewhere in the middle jumping pretty quickly before leveling off. One example is the Gudermannian Function, which takes the form
$$f(x, \gamma) = \gamma \arctan \left(\tanh \left( \frac x \gamma \right) \right)$$
for some value $\gamma$. You can think of $\gamma$ as a parameter that specifies "which" Gudermannian function we're talking about. Can you plot the Gudermannian Function in the range $[-5, 5]$ with $\gamma = {2, 4, 6}$? You will need access to numpy to find implementations of the arctan and tanh functions, and you will need matplotlib to create the actual plot.
HINT: Since we have three different values of $\gamma$, we'll have three different curves on the same graph.
End of explanation
"""


