Creating Modular an Experiments with Functions
Author
The aim of this course is to learn how to create programs that are robust and reusable.
Functions play an ingetral role in achieving this aim.
Functions allow us to spearate our program into different logical units that have a clear scope and purpose.
This allow us to reuse the same function in multiple programs.
A function that does one thing can also be easily tested because its scope is limited.
Finally, functions with a clear purpose and descriptive name make our code much more readable and act as a form of self-documenting code: it is very clear what unctions called play_tone() or draw_circle() do without having to consult any additional documentation.
Important: When importing PsychoPy, we can set the "audioLatency" in prefs.hardware BEFORE importing from psychopy.sound.
There are four latency modes, labeled 0 to 4.
At the highest setting, PsychoPy aggressively takes control of you system’s drivers to minimize audio latency.
This is great for experiments, especially if reaction times matter, but interferes with other applications using the soundcard.
To avoid such problems, we are using the high-latency mode 0 here.
import random
from typing import List
from psychopy import prefs
prefs.hardware["audioLatency"] = 0
from psychopy.sound import Sound
from psychopy.event import waitKeys
from psychopy.visual import Window
Sound(stereo=False)
# avoid error when using mono-channel output deviceSection 1: Defining Functions
A function is defined by the def keyword, may accept certain input parameters and may return a result. A function has only access to the parameters that are passed to it as arguments and any result produced by the function can only be accessed by other parts of the program if that value is explicitly returned. This limited scope makes functions great for testing and robustness! In this notebook you will find many assert statements which test the functions that you will write. Don’t worry about how exactly assert works just yet - just treat it as a tool that provides feedback on the correctness of your functions.
| Code | Duration |
|---|---|
def add(a,b): return a+b |
Define an add() function that takes in two parameters a and b and returns their sum |
assert a == b |
Raise an AssertionError if a is NOT equal to b, otherwise do nothing |
tone = Sound(value=350, secs=0.5) |
Create a tone at 350Hz with a duration of 0.5 seconds |
Exercises
Example: Write the add() function below, so that running the tests below shows "Success!"
def add(a, b):
return a + bassert add(2, 3) == 5
assert add(4, 4) == 8
print("Success!")Success!Exercise: Write the subtract() function below, so that running the tests below shows "Success!".
Solution
def subtract(a, b):
return a - bassert subtract(4,1) == 3
assert subtract(7,12) == -5
print('Success!')Success!Exercise: Write the is_odd() function below, so that running the tests below shows "Success!".
Solution
def is_odd(a):
if a % 2 == 0:
return False
else:
return Trueassert is_odd(8) == False
assert is_odd(5) == True
print('Success!')Success!Exercise: Write the make_list_of_zeros() function below, so that running the tests below shows "Success!" (Hint: you can make a list of 5 zeros with [0]*5).
Solution
def make_list_of_zeros(n_zeros):
return [0] * n_zerosassert len(make_list_of_zeros(10)) == 10
for z in make_list_of_zeros(5):
assert z == 0
print('Success!')Success!Exercise: Write a fist_and_last() function below, so that running the tests below shows “Success!”. (Hint: to return multiple values, separate them by a comma: return val1, val2).
Solution
def first_and_last(x):
return x[0], x[-1]assert first_and_last([1,2,3,4]) == (1,4)
assert first_and_last(["x", "c"]) == ("x", "c")
print("Success!")Success!Exercise: Write the make_tone() function below, so that running the tests below shows "Success!".
(Hint: Sound(value==800).sound == 800).
Solution
def make_tone(freq):
return Sound(value=freq)assert make_tone(500).sound == 500
assert make_tone(1200).sound == 1200
print('Success!')Success!Exercise: Write the change_pitch() function below, so that running the tests below shows "Success!".
Solution
def change_pitch(tone, freq):
tone.sound = tone.sound+ freq
return tonetone = Sound(value=700)
assert change_pitch(tone, 50).sound == 750
assert change_pitch(tone, -100).sound == 650
print('Success!')Success!Exercise: Write the cumulative_duration() function below, so that running the tests below shows "Success!".
Solution
def cumulative_duration(sound1, sound2):
return sound1.secs + sound2.secsassert cumulative_duration(Sound(secs=1.2), Sound(secs=0.5)) == 1.7
assert cumulative_duration(Sound(secs=0.8), Sound(secs=2)) == 2.8Section 2: Optional Arguments
Parameters be passed to a function based on their name or their position. For example, we can call the say_hi_to() function below as say_hi_to("John", "Doe", True).
However, this means we have to pass the parameters in the correct order.
If we instead give the names of the parameters, we make the call more descriptive and independent of the parameters order: say_hi_to(sout=True, first="John", last="Doe").
Some arguments are optional which means that they have a default value defined in the function’s definition.
If we pass in a value for that parameter, the default will be overwritten, if not, the function will use the default.
| Code | Duration |
|---|---|
def add(a,b, c=0): return a+b+c |
Define an add() function that takes in two required parameters a and b and an optional parameter c and returns their sum |
key = waitKeys(keyList="space", timeStamped=True) |
Wait until the "space" key was pressed and return a list of lists with [[name, time]]. |
Exercises
Example: Write the say_hi_to() function below, so that running the tests below shows "Success!".
def say_hi_to(first, last="", shout=False):
msg = "Hi " + first + " " + last + "!"
if shout is True:
return msg.upper()
else:
return msgassert say_hi_to(first="Bob") == "Hi Bob !"
assert say_hi_to(first="Bob", last="McBobface") == "Hi Bob McBobface!"
assert say_hi_to(first="Bob", last="McBobface", shout=True) == "HI BOB MCBOBFACE!"
print("Success!")Success!Exercise: Write the make_tone() function below, so that running the tests below shows "Success!".
Solution
def make_tone(freq, dur=1.5):
return Sound(value=freq, secs=dur)assert make_tone(550).sound == 550
assert make_tone(550).secs== 1.5
assert make_tone(750, 0.1).sound == 750
assert make_tone(750, 0.1).secs== 0.1
print("Success!")Success!Exercise: Write a make_and_play_tone() function below, so that running the tests below shows "Success!".
Solution
def make_and_play_tone(freq, dur, play=False):
sound = Sound(value=freq, secs=dur)
if play is True:
sound.play()
return soundassert make_and_play_tone(500, dur=0.2, play=True).statusDetailed["State"] == 1
assert make_and_play_tone(500, 0.2).statusDetailed["State"] == 0
print("Success!")Success!Exercise: Write the wait_keys() function below, so that running the tests below shows "Success!".
Solution
def wait_for_key(key="space", timed=False):
return waitKeys(keyList=[key], timeStamped=timed)with Window() as win:
assert wait_for_key() == "space"
assert wait_for_key("x") == "x"
assert len(wait_for_key(timed=True)) == 2
print("Success!")Success!Section 3: Importing functions
Importing allows us to make our code truly reusable.
We can build up our own library of functions and import from this library across all of our projects.
When we import functions we have to provide their full name, similar to how you would locate files and folders on your computer.
For example, the function add() within the file maths.py can be imported as from maths import add.
Just like with variables, there can every only be one function with the same name in your namespace, so if there already is a function called add(), it will be overwritten by this import.
Alternatively, you could just import maths and call the function as maths.add(). This is a bit more to type but it will make sure that your other add() function is not overwritten.
When creating and editing separate scripts that you want to run in a Jupyter notebook, it is advisable to use the autoreload extension.
This extension automatically reloads that all required modules when a cell is executed which means that changes in an imported script will be effective immediately
Exercises
%load_ext autoreload
%autoreload 2| Code | Description |
|---|---|
import mymod |
Import the module mymod |
import mymod as m |
Import the module mymod with the alias m |
from mymod import myfun |
Import the function myfun from the module mymod |
from mymod import * |
Import all functions from the module mymod |
Example: Create a file say_hi_to.py that contains the say_hi_to() function from the previous section, and import it so it passes the tests below.
from say_hi_to import say_hi_toassert say_hi_to(first="Bob") == "Hi Bob !"
assert say_hi_to(first="Bob", last="McBobface") == "Hi Bob McBobface!"
assert say_hi_to(first="Bob", last="McBobface", shout=True) == "HI BOB MCBOBFACE!"
print("Success!")Success!Exercise: Create a file make_tone.py that contains a make_tone() function from @exr-maketone and import it so it passes the tests below.
Solution
from make_tone import make_toneassert make_tone(freq=700).secs == 1.5
assert make_tone(freq=1000, dur=0.5).secs == 0.5
print("Success!")Success!Exercise: Add the make_and_play_tone() function from @exr-mnp to make_tone.py and import it under the alias mpt so it passes the tests below.
Solution
from make_tone import make_and_play_tone as mptassert mpt(3000, dur=0.2, play=True).statusDetailed["State"] == 1
assert mpt(freq=500, dur=0.2).statusDetailed["State"] == 0
print("Success!")Success!Exercise: Create a file keys.py that contains a wait_for_key() function and import it so it passes the tests below,
Solution
from keys import wait_for_keywith Window() as win:
assert wait_for_key() == "space"
assert wait_for_key("y") == "y"
assert len(wait_for_key(timed=True)) == 2
print("Success!")Success!Section 4: Type Hints
Functions can be made more robust by adding type hints. Type hints are an, entirely optional, way of declaring the data type of the parameters and return values of a function. This allows deddicated type-checking tools to check if the functions are used as intended which helps to uncover suttle errors that might otherwise go uncaught and lead to strange outputs down the line. What’s more, type hints give additional information to the user by clearly declaring what types of data have to be provided as inputs and can be expected as returns. This can be really helpful to know, for example, if the length of a signal is defined in samples (an integer value) or in seconds (a float value). In this section, we are using the static type-checker MyPy. The cell below enables MyPy in this notebook so that all cells are checked automatically as they are executed and errors are raised if the wrong types are used.
Exercises
%load_ext nb_mypy
%nb_mypy On
%nb_mypy mypy-options --strictVersion 1.0.6
Note: MyPy will consider each cell on its own, without taking the rest of the notebook into account. To avoid warnings due to missing imports, every module used in a cell tested with MyPy must be imported in the same cell
| Code | Duration |
|---|---|
def say_hi(name:str): return a/b |
Define a say_hi() function that takes in one string parameter name |
def divide(a:int,b:int)->float: return a/b |
Define a divide() function that takes in two integer parameters a and b and returns a float |
!mypy my_module.py |
Run MyPy to typecheck the functions in my_module.py |
Example: Add type hints to the find_primes function below, to indicate that it takes arguments start and stop of type int and returns List[int]. Then, run the cell below that calls find_primes() and observe the MyPy error.
from typing import List
def find_primes(start, stop):
start, stop = int(start), int(stop)
primes = []
for i in range(start, stop):
is_prime = True
for j in range(2, int(i / 2)):
if i % j == 0:
is_prime = False
if is_prime:
primes.append(i)
return primesfind_primes(0, 20.0)<cell>1: error: Argument 2 to "find_primes" has incompatible type "float"; expected "int" [arg-type]
[0, 1, 2, 3, 4, 5, 7, 11, 13, 17, 19]Solution
from typing import List
def find_primes(start: int, stop: int) -> List[int]:
start, stop = int(start), int(stop)
primes = []
for i in range(start, stop):
is_prime = True
for j in range(2, int(i / 2)):
if i % j == 0:
is_prime = False
if is_prime:
primes.append(i)
return primesExercise: Add type hints to the shuffle_trials function below, to indicate that it takes arguments trials of type List[str] and max_iter of type int and returns List[str]. Then, run the cell below that calls shuffle_trials() and observe the MyPy error.
import random
def shuffle_trials(trials, max_iter=1000):
ok = False
count = 0
while not ok:
count += 1
if count > max_iter:
raise StopIteration
found_duplicate = False
for i in range(1, len(trials)):
if trials[i] == trials[i - 1]:
found_duplicate = True
if not found_duplicate:
ok = True
else:
random.shuffle(trials)
return trialsshuffle_trials(["1", "1", "2", "3", 2, "3"], max_iter=1000.5)<cell>1: error: List item 4 has incompatible type "int"; expected "str" [list-item]
<cell>1: error: Argument "max_iter" to "shuffle_trials" has incompatible type "float"; expected "int" [arg-type]
[2, '1', '3', '2', '1', '3']Solution
import random
def shuffle_trials(trials: List[str], max_iter: int = 1000) -> List[str]:
ok = False
count = 0
while not ok:
count += 1
if count > max_iter:
raise StopIteration
found_duplicate = False
for i in range(1, len(trials)):
if trials[i] == trials[i - 1]:
found_duplicate = True
if not found_duplicate:
ok = True
else:
random.shuffle(trials)
return trialsExercise: Add type hints to the say_hi_to function below, to indicate that it takes arguments first and last of type str, shout of type bool and returns str. Then, run the cell below that calls say_hi_to() and observe the MyPy error.
def say_hi_to(first, last="", shout=False):
msg = f"Hi {first} {last}!"
if shout is True:
msg = msg.upper()
return msgsay_hi_to("T", 800, shout=1) <cell>1: error: Argument 2 to "say_hi_to" has incompatible type "int"; expected "str" [arg-type]
<cell>1: error: Argument "shout" to "say_hi_to" has incompatible type "int"; expected "bool" [arg-type]
'Hi T 800!'Solution
def say_hi_to(first: str, last: str = "", shout: bool = False) -> str:
msg = f"Hi {first} {last}!"
if shout is True:
msg = msg.upper()
return msgExercise: Add type hints to the say_hi_to in the say_hi_to.py file to indicate that the function takes arguments first of type str, last of type str, shout of type bool and returns a str. Then, run MyPy in the file below to assert that there are no issues in the file.
!mypy --strict say_hi_to.py[1m[32mSuccess: no issues found in 1 source file[mThe annotated function in say_hi_to.py looks like this:
Solution
def say_hi_to(first: str, last: str = "", shout: bool = False) -> str:
msg = "Hi " + first + " " + last + "!"
if shout is True:
return msg.upper()
else:
return msgExercise: Add type hints to the make_tone and make_and_play_tone functions in the make_tone.py file from @exr-maketone to indicate that the functions take arguments freq of type int, dur of type float and play of type bool and returns a Sound object. Then, run MyPy in the file below to assert that there are no issues in the file.
!mypy --strict make_tone.py[1m[32mSuccess: no issues found in 1 source file[mThe annotated functions in keys.py look like this:
Solution
def make_tone(freq: int, dur: float = 1.5) -> Sound:
return Sound(value=freq, secs=dur)
def make_and_play_tone(freq: int, dur: float, play: bool = False) -> Sound:
sound = Sound(value=freq, secs=dur)
if play is True:
sound.play()
return sound