You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
402 lines
15 KiB
402 lines
15 KiB
"""Basic experiment class that is designed to be extended. |
|
|
|
Author - Michael Tan (mtan30@uic.edu) |
|
|
|
Adapted from: https://github.com/colinquirk/templateexperiments |
|
|
|
This class provides basic utility functions that are needed by all |
|
experiments. Specific experiment classes should inherit and extend/overwrite as needed. |
|
|
|
Functions: |
|
convert_color_value -- Converts a list of 3 values from 0 to 255 to -1 to 1. |
|
""" |
|
|
|
from __future__ import division |
|
from __future__ import print_function |
|
from datetime import date |
|
|
|
import os |
|
import pickle |
|
import sys |
|
import math |
|
|
|
import psychopy.monitors |
|
import psychopy.visual |
|
import psychopy.gui |
|
import psychopy.core |
|
import psychopy.event |
|
|
|
gender_options = [ |
|
'Male', |
|
'Female', |
|
'Other/Choose Not To Respond', |
|
] |
|
|
|
# Convenience |
|
def convert_color_value(color): |
|
"""Converts a list of 3 values from 0 to 255 to -1 to 1. |
|
|
|
Parameters: |
|
color -- A list of 3 ints between 0 and 255 to be converted. |
|
""" |
|
|
|
return [round(((n/127.5)-1), 2) for n in color] |
|
|
|
|
|
class BaseExperiment(object): |
|
"""Basic experiment class providing functionality in all experiments |
|
|
|
Parameters: |
|
bg_color -- list of 3 values (0-255) defining the background color |
|
data_fields -- list of strings defining data fields |
|
experiment_name -- string defining the experiment title |
|
monitor_distance -- int describing participant distance from monitor in cm |
|
monitor_name -- name of the monitor to be used |
|
monitor_px -- list containing monitor resolution (x,y) |
|
monitor_width -- int describing length of display monitor in cm |
|
|
|
Methods: |
|
display_text_screen -- draws a string centered on the screen. |
|
get_experiment_info_from_dialog -- gets subject info from a dialog box. |
|
open_csv_data_file -- opens a csv data file and writes the header. |
|
open_window -- open a psychopy window. |
|
quit_experiment -- ends the experiment. |
|
save_data_to_csv -- append new entries in experiment_data to csv data file. |
|
save_experiment_info -- write the info from the dialog box to a text file. |
|
save_experiment_pickle -- save a pickle so crashes can be recovered from. |
|
update_experiment_data -- extends any new data to the experiment_data list. |
|
""" |
|
|
|
def __init__(self, experiment_name, data_fields, bg_color=[0, 0, 0], |
|
monitor_name='testMonitor', monitor_width=59.5, |
|
monitor_distance=30, monitor_px=[2560,1440], **kwargs): |
|
"""Creates a new BaseExperiment object. |
|
|
|
Parameters: |
|
bg_color -- A list of 3 values between 0 and 255 defining the |
|
background color. |
|
data_fields -- list of strings containing the data fields to be stored |
|
experiment_name -- A string for the experiment title that also defines |
|
the filename the experiment info from the dialog box is saved to. |
|
monitor_distance -- An int describing the distance the participant sits |
|
from the monitor in cm (default 70). |
|
monitor_name -- The name of the monitor to be used |
|
Psychopy will search for the provided name to see if it was defined |
|
in monitor center. If it is not defined, a temporary monitor will |
|
be created. |
|
monitor_px -- A list containing the resolution of the monitor (x,y). |
|
monitor_width -- An int describing the length of the display monitor |
|
in cm (default 53). |
|
""" |
|
self.experiment_name = experiment_name |
|
self.data_fields = data_fields |
|
self.bg_color = convert_color_value(bg_color) |
|
self.monitor_name = monitor_name |
|
self.monitor_width = monitor_width |
|
self.monitor_distance = monitor_distance |
|
self.monitor_px = monitor_px |
|
|
|
self.experiment_data = [] |
|
self.experiment_data_filename = None |
|
self.data_lines_written = 0 |
|
self.experiment_info = {} |
|
self.experiment_window = None |
|
|
|
self.overwrite_ok = None |
|
self.gender_options = gender_options |
|
|
|
self.experiment_monitor = psychopy.monitors.Monitor( |
|
self.monitor_name, width=self.monitor_width, |
|
distance=self.monitor_distance) |
|
self.experiment_monitor.setSizePix(monitor_px) |
|
|
|
vars(self).update(kwargs) # Add anything else you want |
|
|
|
@staticmethod |
|
def _confirm_overwrite(): |
|
"""Private, static method that shows a dialog asking if a file can be |
|
overwritten. |
|
|
|
Returns a bool describing if the file should be overwritten. |
|
""" |
|
|
|
overwrite_dlg = psychopy.gui.Dlg( |
|
'Overwrite?', labelButtonOK='Overwrite', |
|
labelButtonCancel='New File', screen=0) |
|
overwrite_dlg.addText('File already exists. Overwrite?') |
|
overwrite_dlg.show() |
|
|
|
return overwrite_dlg.OK |
|
|
|
def get_experiment_info_from_dialog(self, additional_fields_dict=None, field_order=None): |
|
"""Gets subject info from dialog box. |
|
|
|
Parameters: |
|
additional_fields_dict -- An optional dictionary containing more |
|
fields for the dialog box and output dictionary. |
|
""" |
|
|
|
self.experiment_info = {'Subject ID': '0000', |
|
'Session': 'A', |
|
'Timepoint': '0', |
|
'Experimenter Initials': 'MT', |
|
'Gender': self.gender_options, |
|
} |
|
|
|
if additional_fields_dict is not None: |
|
self.experiment_info.update(additional_fields_dict) |
|
if field_order is None: |
|
field_order=['Subject ID', |
|
'Session', |
|
'Timepoint', |
|
'Experimenter Initials', |
|
'Gender', |
|
] |
|
|
|
# Modifies experiment_info dict directly |
|
exp_info = psychopy.gui.DlgFromDict( |
|
self.experiment_info, title=self.experiment_name, |
|
order=field_order, |
|
screen=0 |
|
) |
|
|
|
return exp_info.OK |
|
|
|
def save_experiment_info(self, filename=None): |
|
"""Writes the info from the dialog box to a text file. |
|
|
|
Parameters: |
|
filename -- a string defining the filename with no extension |
|
""" |
|
|
|
if filename is None: |
|
filename = ('ETSP_' + self.experiment_info['Subject ID'] |
|
+ self.experiment_info['Session'] |
|
+ self.experiment_info['Timepoint'] + |
|
'_info') |
|
|
|
elif filename[-4:] == '.txt': |
|
filename = filename[:-4] |
|
|
|
if os.path.isfile(filename + '.txt'): |
|
if self.overwrite_ok is None: |
|
self.overwrite_ok = self._confirm_overwrite() |
|
if not self.overwrite_ok: |
|
# If the file exists make a new filename |
|
i = 1 |
|
new_filename = filename + '(' + str(i) + ')' |
|
while os.path.isfile(new_filename + '.txt'): |
|
i += 1 |
|
new_filename = filename + '(' + str(i) + ')' |
|
filename = new_filename |
|
|
|
filename = filename + '.txt' |
|
|
|
with open(filename, 'w') as info_file: |
|
for key, value in self.experiment_info.items(): |
|
info_file.write(key + ':' + str(value) + '\n') |
|
info_file.write('Date: ' + date.today().strftime('%m/%d/%Y') + '\n\n') |
|
|
|
def open_csv_data_file(self, data_filename=None): |
|
"""Opens the csv file and writes the header. |
|
|
|
Parameters: |
|
data_filename -- name of the csv file with no extension |
|
(defaults to experimentname_subjectnumber). |
|
""" |
|
|
|
if data_filename is None: |
|
data_filename = ('ETSP_' + self.experiment_info['Subject ID'] |
|
+ self.experiment_info['Session'] |
|
+ self.experiment_info['Timepoint']) |
|
elif data_filename[-4:] == '.csv': |
|
data_filename = data_filename[:-4] |
|
|
|
if os.path.isfile(data_filename + '.csv'): |
|
if self.overwrite_ok is None: |
|
self.overwrite_ok = self._confirm_overwrite() |
|
if not self.overwrite_ok: |
|
# If the file exists and we can't overwrite make a new filename |
|
i = 1 |
|
new_filename = data_filename + '(' + str(i) + ')' |
|
while os.path.isfile(new_filename + '.csv'): |
|
i += 1 |
|
new_filename = data_filename + '(' + str(i) + ')' |
|
data_filename = new_filename |
|
|
|
self.experiment_data_filename = data_filename + '.csv' |
|
|
|
# Write the header |
|
with open(self.experiment_data_filename, 'w+') as data_file: |
|
for field in self.data_fields: |
|
data_file.write('"') |
|
data_file.write(field) |
|
data_file.write('"') |
|
if field != self.data_fields[-1]: |
|
data_file.write(',') |
|
data_file.write('\n') |
|
|
|
def update_experiment_data(self, new_data): |
|
"""Extends any new data to the experiment_data list. |
|
|
|
Parameters: |
|
new_data -- A list of dictionaries that are extended to |
|
experiment_data. Only keys that are included in data_fields should |
|
be included, as only those will be written in save_data_to_csv() |
|
""" |
|
|
|
self.experiment_data.extend(new_data) |
|
|
|
def save_data_to_csv(self): |
|
"""Opens the data file and appends new entries in experiment_data. |
|
|
|
Only appends lines (tracked by data_lines_written) that have not yet |
|
been written to the csv. |
|
|
|
Update the experiment data to be written with update_experiment_data. |
|
""" |
|
|
|
with open(self.experiment_data_filename, 'a') as data_file: |
|
for trial in range( |
|
self.data_lines_written, len(self.experiment_data)): |
|
for field in self.data_fields: |
|
data_file.write('"') |
|
try: |
|
data_file.write( |
|
str(self.experiment_data[trial][field])) |
|
except KeyError: |
|
data_file.write('NA') |
|
data_file.write('"') |
|
if field != self.data_fields[-1]: |
|
data_file.write(',') |
|
data_file.write('\n') |
|
|
|
self.data_lines_written = len(self.experiment_data) |
|
|
|
def save_experiment_pickle(self, additional_fields_dict=None): |
|
"""Saves the pickle containing the experiment data so that a crash can |
|
be recovered from. |
|
|
|
This method uses dict.update() so if any keys in the |
|
additional_fields_dict are in the default dictionary the new values |
|
will be used. |
|
|
|
Parameters: |
|
additional_fields_dict -- An optional dictionary that updates the |
|
dictionary that is saved in the pickle file. |
|
""" |
|
|
|
pickle_dict = { |
|
'experiment_name': self.experiment_name, |
|
'data_fields': self.data_fields, |
|
'bg_color': self.bg_color, |
|
'monitor_name': self.monitor_name, |
|
'monitor_width': self.monitor_width, |
|
'monitor_distance': self.monitor_distance, |
|
'experiment_data': self.experiment_data, |
|
'experiment_data_filename': self.experiment_data_filename, |
|
'data_lines_written': self.data_lines_written, |
|
'experiment_info': self.experiment_info, |
|
} |
|
|
|
if additional_fields_dict is not None: |
|
pickle_dict.update(additional_fields_dict) |
|
|
|
pickle.dump(pickle_dict, open( |
|
self.experiment_name + |
|
self.experiment_info['Subject Number'].zfill(3) + '.pickle', |
|
'wb+')) |
|
|
|
def open_window(self, **kwargs): |
|
"""Opens the psychopy window. |
|
|
|
Additional keyword arguments are sent to psychopy.visual.Window(). |
|
""" |
|
self.experiment_window = psychopy.visual.Window(self.monitor_px, |
|
monitor=self.experiment_monitor, fullscr=True, color=self.bg_color, |
|
colorSpace='rgb', units='deg', allowGUI=False, **kwargs) |
|
|
|
def display_text_screen( |
|
self, text='', image_file=[], image_scale=0.25, |
|
text_color=[255, 255, 255], text_height=36, |
|
bg_color=None, wait_for_input=True, **kwargs): |
|
"""Takes a string as input and draws it centered on the screen. |
|
|
|
Allows for simple writing of text to a screen with a background color |
|
other than the normal one. Switches back to the default background |
|
color after any keyboard input. |
|
|
|
This works by drawing a rect on top of the background |
|
that fills the whole screen with the selected color. |
|
|
|
Parameters: |
|
text -- A string containing the text to be displayed. |
|
text_color -- A list of 3 values between 0 and 255 |
|
(default is [0, 0, 0]). |
|
text_height --- An int that defines the height of the text in pix. |
|
bg_color -- A list of 3 values between 0 and 255 (default is default |
|
background color). |
|
wait_for_input -- Bool that defines whether the screen will wait for |
|
keyboard input before continuing. If waiting for keys, a .5 second |
|
buffer is added to prevent accidental advancing. |
|
|
|
Additional keyword arguments are sent to psychopy.visual.TextStim(). |
|
""" |
|
|
|
if bg_color is None: |
|
bg_color = self.bg_color |
|
else: |
|
bg_color = convert_color_value(bg_color) |
|
|
|
backgroundRect = psychopy.visual.Rect( |
|
self.experiment_window, fillColor=bg_color, units='norm', width=2, |
|
height=2) |
|
backgroundRect.draw() |
|
|
|
text_color = convert_color_value(text_color) |
|
|
|
textObject = psychopy.visual.TextStim( |
|
self.experiment_window, text=text, color=text_color, units='pix', |
|
height=text_height, alignHoriz='center', alignVert='center', |
|
wrapWidth=round(.8*self.experiment_window.size[0]), **kwargs) |
|
#textObject.draw() |
|
|
|
if image_file: # Display image at bottom of screen. |
|
textObject.pos = (0,self.experiment_window.size[1]/2-5) |
|
textObject.alignVert = 'top' |
|
textObject.draw() |
|
imageObject = psychopy.visual.ImageStim( |
|
self.experiment_window, units='pix', |
|
image=image_file) |
|
sizex = int(round(imageObject.size[0])*image_scale) |
|
sizey = int(round(imageObject.size[1])*image_scale) |
|
#belowText = int(math.floor(-textObject.boundingBox[1]/2-sizey/2)) |
|
bottomOfScreen = int(math.floor(-self.experiment_window.size[1]/2+sizey/2))+5 |
|
imageObject.size = [sizex,sizey] |
|
#imageObject.pos = (0,belowText) |
|
if text: |
|
imageObject.pos = (0,bottomOfScreen) |
|
else: |
|
imageObject.pos = (0,0) |
|
imageObject.draw() |
|
else: |
|
textObject.draw() |
|
|
|
self.experiment_window.flip() |
|
|
|
keys = None |
|
if wait_for_input: |
|
psychopy.core.wait(.2) # Prevents accidental key presses |
|
keys = psychopy.event.waitKeys() |
|
while keys != ['space']: |
|
keys = psychopy.event.waitKeys() |
|
self.experiment_window.flip() |
|
|
|
return keys |
|
|
|
def quit_experiment(self): |
|
"""Completes anything that must occur when the experiment ends.""" |
|
if self.experiment_window: |
|
self.experiment_window.close() |
|
print('The experiment has ended.') |
|
sys.exit(0)
|
|
|