MultiTaskBattery 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- MultiTaskBattery/__init__.py +4 -0
- MultiTaskBattery/battery.py +223 -0
- MultiTaskBattery/experiment_block.py +340 -0
- MultiTaskBattery/fetch.py +77 -0
- MultiTaskBattery/screen.py +152 -0
- MultiTaskBattery/task_blocks.py +1703 -0
- MultiTaskBattery/task_details.json +1047 -0
- MultiTaskBattery/task_file.py +1833 -0
- MultiTaskBattery/task_table.tsv +23 -0
- MultiTaskBattery/ttl_clock.py +73 -0
- MultiTaskBattery/utils.py +201 -0
- multitaskbattery-1.3.0.dist-info/METADATA +92 -0
- multitaskbattery-1.3.0.dist-info/RECORD +17 -0
- multitaskbattery-1.3.0.dist-info/WHEEL +5 -0
- multitaskbattery-1.3.0.dist-info/entry_points.txt +2 -0
- multitaskbattery-1.3.0.dist-info/licenses/LICENSE +21 -0
- multitaskbattery-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1703 @@
|
|
|
1
|
+
# Task Class definitions
|
|
2
|
+
# March 2021: First version: Ladan Shahshahani - Maedbh King - Suzanne Witt,
|
|
3
|
+
# Revised 2023: Bassel Arafat, Jorn Diedrichsen, Incé Husain
|
|
4
|
+
# Revised 2024: Caroline Nettekoven
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import numpy as np
|
|
9
|
+
import random
|
|
10
|
+
from psychopy import prefs
|
|
11
|
+
prefs.hardware['audioLib'] = ['sounddevice']
|
|
12
|
+
from psychopy import visual, sound, core, event
|
|
13
|
+
from psychopy.tools.monitorunittools import deg2pix
|
|
14
|
+
from pyglet.window import key
|
|
15
|
+
import MultiTaskBattery.utils as ut
|
|
16
|
+
from ast import literal_eval
|
|
17
|
+
from copy import deepcopy
|
|
18
|
+
from moviepy.audio.io.AudioFileClip import AudioFileClip
|
|
19
|
+
import gc
|
|
20
|
+
import math
|
|
21
|
+
import json
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Task:
|
|
25
|
+
"""
|
|
26
|
+
Task: takes in inputs from run_experiment.py and methods
|
|
27
|
+
some methods are universal across all tasks. Those methods are included in the super class Task.
|
|
28
|
+
There are methods like display_instructions which are the same across most of the tasks.
|
|
29
|
+
There are, however, some tasks that have different instructions from the rest (like fingerSequence).
|
|
30
|
+
For those tasks, a display_instructions method is defined within the corresponding class which overrides
|
|
31
|
+
the universal display_instruction method.
|
|
32
|
+
Each of other classes runs a unique task given input from target files and from the Task class
|
|
33
|
+
(VisualSearch, SemanticPrediction, NBack, SocialPrediction, ActionObservation).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
37
|
+
|
|
38
|
+
# Pointers to Screen and experimental constants
|
|
39
|
+
self.screen = screen
|
|
40
|
+
self.window = screen.window # Shortcut to window
|
|
41
|
+
self.const = const
|
|
42
|
+
self.ttl_clock = ttl_clock # This is a reference to the clock of the run
|
|
43
|
+
self.name = info['task_name']
|
|
44
|
+
self.descriptive_name = info['descriptive_name']
|
|
45
|
+
self.code = info['task_code']
|
|
46
|
+
self.task_file = info['task_file']
|
|
47
|
+
self.feedback_type = 'none'
|
|
48
|
+
|
|
49
|
+
# Set the instruction text height to a value defined in the constants, or use default - useful for smaller screens
|
|
50
|
+
self.const.instruction_text_height = getattr(self.const, 'instruction_text_height', None) or 1
|
|
51
|
+
|
|
52
|
+
# Width the instruction text wraps at, in degrees. PsychoPy's own default is
|
|
53
|
+
# narrow enough to break short lines such as "Left - Press [1] Right - Press [2]".
|
|
54
|
+
self.const.instruction_wrap_width = getattr(self.const, 'instruction_wrap_width', None) or 30
|
|
55
|
+
|
|
56
|
+
def init_task(self):
|
|
57
|
+
"""
|
|
58
|
+
Initialize task - default is to read the target information into the trial_info dataframe
|
|
59
|
+
"""
|
|
60
|
+
self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
|
|
61
|
+
|
|
62
|
+
def display_instructions(self):
|
|
63
|
+
"""
|
|
64
|
+
Displays the descriptive name of the task.
|
|
65
|
+
then instruction to press response key 1 for true and response key 2 for false.
|
|
66
|
+
Tasks with different instructions implement a new version to overwrite this default version
|
|
67
|
+
"""
|
|
68
|
+
true_str = f"if True press {self.const.response_keys[1]}"
|
|
69
|
+
false_str = f"if False press {self.const.response_keys[2]}"
|
|
70
|
+
|
|
71
|
+
self.instruction_text = f"{self.descriptive_name} Task\n\n {true_str} \n {false_str}"
|
|
72
|
+
|
|
73
|
+
# 3.2 display the instruction text
|
|
74
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
75
|
+
# instr.size = 0.8
|
|
76
|
+
instr_visual.draw()
|
|
77
|
+
self.window.flip()
|
|
78
|
+
|
|
79
|
+
def run(self):
|
|
80
|
+
"""Loop over trials in the task object and collects data
|
|
81
|
+
Data will be stored in self.trial_data
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
acc (float): Mean accuracy in proportion correct
|
|
85
|
+
rt (float): Mean reaction time in s
|
|
86
|
+
"""
|
|
87
|
+
self.trial_data = [] # an empty list which will be appended with the responses from each trial
|
|
88
|
+
|
|
89
|
+
for i,trial in self.trial_info.iterrows():
|
|
90
|
+
t_data = trial.copy()
|
|
91
|
+
# Wait for the the start of next trial
|
|
92
|
+
t_data['real_start_time'],t_data['start_ttl'],t_data['start_ttl_time'] = self.ttl_clock.wait_until(self.start_time + trial.start_time )
|
|
93
|
+
# Run the trial
|
|
94
|
+
t_data = self.run_trial(t_data)
|
|
95
|
+
# Append the trial data
|
|
96
|
+
self.trial_data.append(t_data)
|
|
97
|
+
self.trial_data = pd.DataFrame(self.trial_data)
|
|
98
|
+
# Calculate the feedback for the run
|
|
99
|
+
acc = None
|
|
100
|
+
rt = None
|
|
101
|
+
if self.feedback_type[:3] == 'acc':
|
|
102
|
+
acc = self.trial_data['correct'].mean()
|
|
103
|
+
if self.feedback_type[-2:] == 'rt':
|
|
104
|
+
rt = self.trial_data['rt'].mean()
|
|
105
|
+
return acc,rt
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def show_progress(self, seconds_left, show_last_seconds=5, height=1, width=10, x_pos=-5, y_pos=8):
|
|
109
|
+
""" Displays a progress bar for the Picture Sequence task
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
seconds_left (float):
|
|
113
|
+
The number of seconds remaining in the current trial.
|
|
114
|
+
If this value is greater than `show_last_seconds`, the progress bar is not shown.
|
|
115
|
+
|
|
116
|
+
show_last_seconds (float, optional):
|
|
117
|
+
The duration (in seconds) over which to display the progress bar at the end of a trial.
|
|
118
|
+
Default is 5 seconds. When `seconds_left` is less than this value, the progress bar appears.
|
|
119
|
+
|
|
120
|
+
height (float, optional):
|
|
121
|
+
The vertical size of the progress bar in PsychoPy window units. Default is 1.
|
|
122
|
+
|
|
123
|
+
width (float, optional):
|
|
124
|
+
The horizontal size of the progress bar in PsychoPy window units. Default is 10.
|
|
125
|
+
|
|
126
|
+
x_pos (float, optional):
|
|
127
|
+
The horizontal position of the center of the progress bar in window coordinates.
|
|
128
|
+
Negative values move it leftward. Default is -5.
|
|
129
|
+
|
|
130
|
+
y_pos (float, optional):
|
|
131
|
+
The vertical position of the center of the progress bar in window coordinates.
|
|
132
|
+
Positive values move it upward. Default is 8.
|
|
133
|
+
"""
|
|
134
|
+
# If we are in the last five seconds of the trial, display the remaining time
|
|
135
|
+
if seconds_left < show_last_seconds:
|
|
136
|
+
progress = visual.Progress(
|
|
137
|
+
win=self.window,
|
|
138
|
+
progress=1-(seconds_left/show_last_seconds),
|
|
139
|
+
size=(width, height),
|
|
140
|
+
pos=(x_pos, y_pos),
|
|
141
|
+
backColor='blue',
|
|
142
|
+
barColor='black',
|
|
143
|
+
borderColor='black',
|
|
144
|
+
lineWidth=5,
|
|
145
|
+
)
|
|
146
|
+
progress.draw()
|
|
147
|
+
|
|
148
|
+
def wait_response(self, start_time, max_wait_time, show_last_seconds=None, current_stimuli=None):
|
|
149
|
+
"""
|
|
150
|
+
waits for a response to be made and then returns the response
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
start_time (float): the time the RT-period started
|
|
154
|
+
max_wait_time (float): How long to wait maximally
|
|
155
|
+
show_last_seconds (float or None): If set, show a progress bar
|
|
156
|
+
during the final *show_last_seconds* of the response window.
|
|
157
|
+
Requires *current_stimuli* to be set so the display can be
|
|
158
|
+
redrawn each frame.
|
|
159
|
+
current_stimuli (optional): A single PsychoPy stimulus or a list of stimuli
|
|
160
|
+
to redraw each frame when the progress bar is active.
|
|
161
|
+
Returns:
|
|
162
|
+
key (str): the key that was pressed (1-4) (0 if no key was pressed)
|
|
163
|
+
rt (float): the reaction time (nan if no key was pressed)
|
|
164
|
+
"""
|
|
165
|
+
response_made = False
|
|
166
|
+
key = 0
|
|
167
|
+
rt = np.nan
|
|
168
|
+
|
|
169
|
+
while (self.ttl_clock.get_time() - start_time <= max_wait_time) and not response_made:
|
|
170
|
+
self.ttl_clock.update()
|
|
171
|
+
if show_last_seconds is not None:
|
|
172
|
+
if isinstance(current_stimuli, (list, tuple)):
|
|
173
|
+
for stim in current_stimuli:
|
|
174
|
+
stim.draw()
|
|
175
|
+
else:
|
|
176
|
+
current_stimuli.draw()
|
|
177
|
+
seconds_left = max_wait_time - (self.ttl_clock.get_time() - start_time)
|
|
178
|
+
self.show_progress(seconds_left,
|
|
179
|
+
show_last_seconds=show_last_seconds,
|
|
180
|
+
y_pos=6)
|
|
181
|
+
self.window.flip()
|
|
182
|
+
keys=event.getKeys(keyList= self.const.response_keys, timeStamped=self.ttl_clock.clock)
|
|
183
|
+
if len(keys)>0:
|
|
184
|
+
response_made = True
|
|
185
|
+
key_char = keys[0][0]
|
|
186
|
+
key = self.const.response_keys.index(key_char) + 1
|
|
187
|
+
rt = keys[0][1] - start_time
|
|
188
|
+
return key, rt
|
|
189
|
+
|
|
190
|
+
def display_trial_feedback(self, give_feedback,correct_response):
|
|
191
|
+
"""
|
|
192
|
+
display the feedback for the current trial at the position of fixation cross. Correct is a green check mark, incorrect a red cross.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
give_feedback (bool):
|
|
196
|
+
If true, gives informative feedback - otherwise just shows fixation cross
|
|
197
|
+
correct_response (bool):
|
|
198
|
+
Response was correct?, False otherwise
|
|
199
|
+
"""
|
|
200
|
+
if give_feedback:
|
|
201
|
+
if correct_response:
|
|
202
|
+
self.screen.check_mark('green')
|
|
203
|
+
else:
|
|
204
|
+
self.screen.error_cross('red')
|
|
205
|
+
else:
|
|
206
|
+
self.screen.fixation_cross('white')
|
|
207
|
+
|
|
208
|
+
def save_data(self, subj_id, run_num):
|
|
209
|
+
"""Saves the data to the trial data file
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
subj_id (str): Subject id to determine name
|
|
213
|
+
run_num (int): Number of run - inserted as first column
|
|
214
|
+
"""
|
|
215
|
+
self.trial_data.insert(0, 'run_num', [run_num]*len(self.trial_data))
|
|
216
|
+
trial_data_file = self.const.data_dir / subj_id / f"{subj_id}_task-{self.code}.tsv"
|
|
217
|
+
ut.append_data_to_file(trial_data_file, self.trial_data)
|
|
218
|
+
|
|
219
|
+
def screen_quit(self):
|
|
220
|
+
""" Checks for quit or escape key presses and quits the experiment if necessary """
|
|
221
|
+
keys = event.getKeys()
|
|
222
|
+
for key in keys:
|
|
223
|
+
if 'q' in key or 'esc' in key:
|
|
224
|
+
self.window.close()
|
|
225
|
+
core.quit()
|
|
226
|
+
|
|
227
|
+
def get_audio_from_movie(self, movie_path, sample_rate=48000):
|
|
228
|
+
"""Seperates the audio from the movie file and returns the audio object (for better memory handling when playing movies with sound)
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
movie_path (str): Path and filename of the Movie file
|
|
232
|
+
sample_rate (int): Sampling rate of movie file, defaults to 48000.
|
|
233
|
+
Returns:
|
|
234
|
+
audio (sound.sound): Sound object
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
# Gets the movie audio
|
|
238
|
+
audio_clip = AudioFileClip(movie_path)
|
|
239
|
+
audio_array = audio_clip.to_soundarray(fps=sample_rate)
|
|
240
|
+
audio_clip.close()
|
|
241
|
+
audio = sound.Sound(audio_array,sampleRate=sample_rate, stereo=True)
|
|
242
|
+
|
|
243
|
+
return audio
|
|
244
|
+
|
|
245
|
+
class NBack(Task):
|
|
246
|
+
|
|
247
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
248
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
249
|
+
self.feedback_type = 'acc+rt'
|
|
250
|
+
|
|
251
|
+
def init_task(self):
|
|
252
|
+
"""
|
|
253
|
+
Initialize task - default is to read the target information into the trial_info dataframe
|
|
254
|
+
"""
|
|
255
|
+
trial_info_file = self.const.task_dir / self.name / self.task_file
|
|
256
|
+
self.trial_info = pd.read_csv(trial_info_file, sep='\t')
|
|
257
|
+
self.stim = []
|
|
258
|
+
stim_width = self.trial_info['stim_width'].iloc[0] if 'stim_width' in self.trial_info.columns else 18.4
|
|
259
|
+
for stim in self.trial_info['stim']:
|
|
260
|
+
stim_path = ut.find_stim(self.const, self.name, stim)
|
|
261
|
+
img = visual.ImageStim(self.window, str(stim_path), size=(stim_width, None)) # width in deg
|
|
262
|
+
self.stim.append(img)
|
|
263
|
+
self.corr_key = [self.trial_info['key_nomatch'].iloc[0], self.trial_info['key_match'].iloc[0]]
|
|
264
|
+
self.n_back = self.infer_n_back()
|
|
265
|
+
|
|
266
|
+
def infer_n_back(self):
|
|
267
|
+
""" Derive the numeric n-back level (used only for the instruction text).
|
|
268
|
+
|
|
269
|
+
The level lives in the 'condition' column as an 'N-back' label (e.g.
|
|
270
|
+
'2-back'), so a hand-made task file only needs that one column. Defaults
|
|
271
|
+
to 2 if no usable 'condition' label is present.
|
|
272
|
+
"""
|
|
273
|
+
if 'condition' in self.trial_info.columns:
|
|
274
|
+
head = str(self.trial_info['condition'].iloc[0]).split('-')[0].strip()
|
|
275
|
+
if head.isdigit():
|
|
276
|
+
return int(head)
|
|
277
|
+
return 2
|
|
278
|
+
|
|
279
|
+
def display_instructions(self):
|
|
280
|
+
"""
|
|
281
|
+
displays the instruction for the task
|
|
282
|
+
"""
|
|
283
|
+
str1 = f"Compare image to the one shown {self.n_back} previously"
|
|
284
|
+
str2 = f"if match, press {self.corr_key[1]}"
|
|
285
|
+
str3 = f"if no match, press {self.corr_key[0]}"
|
|
286
|
+
self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3}"
|
|
287
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
288
|
+
instr_visual.draw()
|
|
289
|
+
self.window.flip()
|
|
290
|
+
|
|
291
|
+
def run_trial(self,trial):
|
|
292
|
+
"""Runs a single trial of the nback task (after it started)
|
|
293
|
+
Args:
|
|
294
|
+
trial (pd.Series):
|
|
295
|
+
Row of trial_info, with all information about the trial
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
trial (pd.Series):
|
|
299
|
+
Row of trial_data with all response data added
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
# Flush any keys in buffer
|
|
303
|
+
event.clearEvents()
|
|
304
|
+
|
|
305
|
+
# display stimulus
|
|
306
|
+
self.stim[trial['trial_num']].draw()
|
|
307
|
+
self.window.flip()
|
|
308
|
+
|
|
309
|
+
# collect responses 0: no response 1-4: key pressed
|
|
310
|
+
trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['trial_dur'])
|
|
311
|
+
trial['correct'] = (trial['response'] == self.corr_key[trial['trial_type']])
|
|
312
|
+
|
|
313
|
+
# display trial feedback
|
|
314
|
+
self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
|
|
315
|
+
return trial
|
|
316
|
+
|
|
317
|
+
class Rest(Task):
|
|
318
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
319
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
320
|
+
self.name = 'rest'
|
|
321
|
+
|
|
322
|
+
def display_instructions(self): # overriding the display instruction routine from the parent
|
|
323
|
+
self.instruction_text = 'Rest: Fixate on the cross'
|
|
324
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
325
|
+
# instr.size = 0.8
|
|
326
|
+
instr_visual.draw()
|
|
327
|
+
self.window.flip()
|
|
328
|
+
|
|
329
|
+
def show_stim(self):
|
|
330
|
+
# show fixation cross
|
|
331
|
+
self.screen.fixation_cross()
|
|
332
|
+
|
|
333
|
+
def run_trial(self,trial):
|
|
334
|
+
# get current time (self.t0)
|
|
335
|
+
self.screen.fixation_cross()
|
|
336
|
+
self.ttl_clock.wait_until(self.start_time + trial['trial_dur'])
|
|
337
|
+
return trial
|
|
338
|
+
|
|
339
|
+
class VerbGeneration(Task):
|
|
340
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
341
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
342
|
+
|
|
343
|
+
def init_task(self):
|
|
344
|
+
"""
|
|
345
|
+
Initialize task - default is to read the target information into the trial_info dataframe
|
|
346
|
+
"""
|
|
347
|
+
trial_info_file = self.const.task_dir / self.name / self.task_file
|
|
348
|
+
self.trial_info = pd.read_csv(trial_info_file, sep='\t')
|
|
349
|
+
self.trial_info['noun'] = self.trial_info['stim'].str.strip()
|
|
350
|
+
|
|
351
|
+
cue_labels = {'read': 'READ', 'generate': 'GENERATE'}
|
|
352
|
+
cue_colors = {'read': 'red', 'generate': 'green'}
|
|
353
|
+
|
|
354
|
+
def cue(self, condition):
|
|
355
|
+
""" Build the coloured condition label drawn just above the stimulus word. """
|
|
356
|
+
return visual.TextStim(self.window,
|
|
357
|
+
text=self.cue_labels.get(condition, condition.upper()),
|
|
358
|
+
pos=(0.0, 2.5), color=self.cue_colors.get(condition, 'black'),
|
|
359
|
+
units='deg', height=1, bold=True)
|
|
360
|
+
|
|
361
|
+
def display_instructions(self):
|
|
362
|
+
str1 = "A label at the top of the screen tells you what to do for each word:"
|
|
363
|
+
str2 = f"{self.cue_labels['read']} - silently read the word"
|
|
364
|
+
str3 = f"{self.cue_labels['generate']} - silently think of a verb that goes with it"
|
|
365
|
+
self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3}"
|
|
366
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
367
|
+
instr_visual.draw()
|
|
368
|
+
self.window.flip()
|
|
369
|
+
|
|
370
|
+
def show_stim(self, noun, condition, text_height=2):
|
|
371
|
+
""" Display the condition cue (top) and the word for a fixed time. """
|
|
372
|
+
stim = visual.TextStim(self.window, text=noun, pos=(0.0, 0.0), color=(-1, -1, -1), units='deg', height=text_height)
|
|
373
|
+
self.cue(condition).draw()
|
|
374
|
+
stim.draw()
|
|
375
|
+
self.window.flip()
|
|
376
|
+
|
|
377
|
+
def run_trial(self, trial):
|
|
378
|
+
""" Run a single trial of the VerbGeneration task. """
|
|
379
|
+
|
|
380
|
+
self.show_stim(trial['noun'], trial['condition'], trial.get('text_height', 2))
|
|
381
|
+
|
|
382
|
+
# wait for trial duration
|
|
383
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
|
|
384
|
+
|
|
385
|
+
# ITI: blank the word but keep the cue on, so the context never disappears
|
|
386
|
+
self.cue(trial['condition']).draw()
|
|
387
|
+
self.screen.fixation_cross(flip=False)
|
|
388
|
+
self.window.flip()
|
|
389
|
+
return trial
|
|
390
|
+
|
|
391
|
+
class TongueMovement(Task):
|
|
392
|
+
"""
|
|
393
|
+
Tongue movement following Buckner et al., 2022! No particular feedback.
|
|
394
|
+
"""
|
|
395
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
396
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
397
|
+
|
|
398
|
+
def display_instructions(self):
|
|
399
|
+
self.instruction_text = f"{self.descriptive_name} Task \n\n Move your tongue left to right touching your upper premolar teeth"
|
|
400
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
401
|
+
instr_visual.draw()
|
|
402
|
+
self.window.flip()
|
|
403
|
+
|
|
404
|
+
def run_trial(self, trial):
|
|
405
|
+
""" Run a single trial of the tonguemovement task. """
|
|
406
|
+
|
|
407
|
+
# draw fixation cross without flipping
|
|
408
|
+
self.screen.fixation_cross(flip=False)
|
|
409
|
+
|
|
410
|
+
# Check the trial_type and display the corresponding stimulus
|
|
411
|
+
if trial['trial_type'] == 'right':
|
|
412
|
+
# If trial_type is 'right', show the black circle around the fixation cross
|
|
413
|
+
circle_visual = visual.Circle(self.window, size=6, edges= 100, lineWidth = 20, fillColor=None, lineColor='black')
|
|
414
|
+
circle_visual.draw()
|
|
415
|
+
|
|
416
|
+
self.window.flip()
|
|
417
|
+
|
|
418
|
+
# wait for trial duration
|
|
419
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
|
|
420
|
+
|
|
421
|
+
# return to a plain fixation cross (no response, so no feedback)
|
|
422
|
+
self.screen.fixation_cross()
|
|
423
|
+
|
|
424
|
+
return trial
|
|
425
|
+
|
|
426
|
+
class MotorLocalizer(Task):
|
|
427
|
+
"""
|
|
428
|
+
General motor localizer. Each row shows a condition label (e.g. 'hand',
|
|
429
|
+
'foot', 'tongue') at the top of the screen in black, plus a central
|
|
430
|
+
fixation cross. A circle appears around the fixation when trial_type == 1
|
|
431
|
+
and is absent when trial_type == 0. The participant performs the movement
|
|
432
|
+
associated with the current condition.
|
|
433
|
+
|
|
434
|
+
The condition -> movement mapping (e.g. tongue = left-right, foot =
|
|
435
|
+
flex/extend) and the meaning of the circle appearing/disappearing are
|
|
436
|
+
explained to the participant during training and are deliberately NOT
|
|
437
|
+
encoded here: the code only reads the condition and trial_type and renders
|
|
438
|
+
them. No response is collected, so there is no feedback. Because the
|
|
439
|
+
condition can change within a block, a single block can localize several
|
|
440
|
+
body parts (e.g. tongue, hand and foot).
|
|
441
|
+
"""
|
|
442
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
443
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
444
|
+
self.feedback_type = 'None'
|
|
445
|
+
|
|
446
|
+
def init_task(self):
|
|
447
|
+
self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
|
|
448
|
+
|
|
449
|
+
def display_instructions(self):
|
|
450
|
+
self.instruction_text = f"{self.descriptive_name} Task \n\n Move the indicated body part as instructed"
|
|
451
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text,
|
|
452
|
+
height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
453
|
+
instr_visual.draw()
|
|
454
|
+
self.window.flip()
|
|
455
|
+
|
|
456
|
+
def run_trial(self, trial):
|
|
457
|
+
""" Run a single row of the motor-localizer task. """
|
|
458
|
+
# Condition label (body part) at the top of the screen, in black
|
|
459
|
+
cond_visual = visual.TextStim(self.window, text=str(trial['condition']), pos=(0.0, trial.get('label_y', 6.0)),
|
|
460
|
+
color=(-1, -1, -1), units='deg', height=trial.get('text_height', 1.5))
|
|
461
|
+
cond_visual.draw()
|
|
462
|
+
|
|
463
|
+
# Central fixation cross (drawn without flipping yet)
|
|
464
|
+
self.screen.fixation_cross(flip=False)
|
|
465
|
+
|
|
466
|
+
# Circle around the fixation only when trial_type == 1
|
|
467
|
+
if int(trial['trial_type']) == 1:
|
|
468
|
+
circle_visual = visual.Circle(self.window, size=6, edges=100, lineWidth=20,
|
|
469
|
+
fillColor=None, lineColor='black')
|
|
470
|
+
circle_visual.draw()
|
|
471
|
+
|
|
472
|
+
self.window.flip()
|
|
473
|
+
|
|
474
|
+
# hold this row for its duration (no response, so no feedback)
|
|
475
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
|
|
476
|
+
return trial
|
|
477
|
+
|
|
478
|
+
class AuditoryNarrative(Task):
|
|
479
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
480
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
481
|
+
|
|
482
|
+
def display_instructions(self):
|
|
483
|
+
self.instruction_text = f'{self.descriptive_name} Task\n\nListen to the narrative attentively.'
|
|
484
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
485
|
+
instr_visual.draw()
|
|
486
|
+
self.window.flip()
|
|
487
|
+
|
|
488
|
+
def run_trial(self, trial):
|
|
489
|
+
""" Run a single trial of the AuditoryNarrative task. """
|
|
490
|
+
self.screen.fixation_cross()
|
|
491
|
+
|
|
492
|
+
# Load and play audio stimulus for the current trial
|
|
493
|
+
audio_path = ut.find_stim(self.const, self.name, trial['stim'])
|
|
494
|
+
audio_stim = sound.Sound(str(audio_path))
|
|
495
|
+
audio_stim.play()
|
|
496
|
+
|
|
497
|
+
trial_dur = audio_stim.getDuration()
|
|
498
|
+
|
|
499
|
+
# wait for trial duration (fixation cross stays on screen during playback)
|
|
500
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
|
|
501
|
+
|
|
502
|
+
return trial
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class SpatialNavigation(Task):
|
|
506
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
507
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
508
|
+
|
|
509
|
+
def init_task(self):
|
|
510
|
+
self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
|
|
511
|
+
|
|
512
|
+
def display_instructions(self):
|
|
513
|
+
start_location = self.trial_info.iloc[0]['location_1']
|
|
514
|
+
end_location = self.trial_info.iloc[0]['location_2']
|
|
515
|
+
|
|
516
|
+
self.instruction_text = (f"{self.descriptive_name} Task \n\n"
|
|
517
|
+
f"Imagine walking around your childhood home\n"
|
|
518
|
+
f"Start in the {start_location} – end in the {end_location}\n"
|
|
519
|
+
f"Focus on the fixation cross")
|
|
520
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
521
|
+
instr_visual.draw()
|
|
522
|
+
self.window.flip()
|
|
523
|
+
|
|
524
|
+
def run_trial(self,trial):
|
|
525
|
+
# fixation cross stays on screen while the participant imagines the route (no response)
|
|
526
|
+
self.screen.fixation_cross()
|
|
527
|
+
|
|
528
|
+
# wait for trial duration
|
|
529
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
|
|
530
|
+
|
|
531
|
+
return trial
|
|
532
|
+
|
|
533
|
+
class TheoryOfMind(Task):
|
|
534
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
535
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
536
|
+
self.feedback_type = 'acc+rt'
|
|
537
|
+
|
|
538
|
+
def init_task(self):
|
|
539
|
+
"""
|
|
540
|
+
Initialize task - default is to read the target information into the trial_info dataframe
|
|
541
|
+
"""
|
|
542
|
+
self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
|
|
543
|
+
self.corr_key = [self.trial_info['key_false'].iloc[0],self.trial_info['key_true'].iloc[0]]
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def display_instructions(self):
|
|
547
|
+
"""
|
|
548
|
+
displays the instruction for the task
|
|
549
|
+
"""
|
|
550
|
+
task_name = visual.TextStim(self.window, text=f'{self.descriptive_name.capitalize()}', height=self.const.instruction_text_height, color=[-1, -1, -1], bold=True, pos=(0, 3))
|
|
551
|
+
task_name.draw()
|
|
552
|
+
str1 = f"You will read a story and decide if the answer to the question is True or False."
|
|
553
|
+
str2 = f"if true, press {self.corr_key[1]}"
|
|
554
|
+
str3 = f"if false, press {self.corr_key[0]}"
|
|
555
|
+
self.instruction_text = f"\n\n {str1} \n\n {str2} \n {str3}"
|
|
556
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
557
|
+
instr_visual.draw()
|
|
558
|
+
self.window.flip()
|
|
559
|
+
|
|
560
|
+
def run_trial(self, trial):
|
|
561
|
+
""" Runs a single trial of the Theory of Mind task """
|
|
562
|
+
|
|
563
|
+
event.clearEvents()
|
|
564
|
+
|
|
565
|
+
height = trial.get('text_height', 1.25)
|
|
566
|
+
wrapWidth = trial.get('wrap_width', 25)
|
|
567
|
+
|
|
568
|
+
# Display story
|
|
569
|
+
story_clean = ' '.join(trial['story'].split('\n'))
|
|
570
|
+
story_formatted = '.\n'.join(story_clean.split('. '))
|
|
571
|
+
story_stim = visual.TextStim(self.window, text=story_formatted, alignHoriz='center', wrapWidth=wrapWidth, pos=(0.0, 0.0), color=(-1, -1, -1), units='deg', height=height)
|
|
572
|
+
story_stim.draw()
|
|
573
|
+
self.window.flip()
|
|
574
|
+
|
|
575
|
+
# wait until story duration
|
|
576
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['story_dur'])
|
|
577
|
+
|
|
578
|
+
# Flush any keys in buffer
|
|
579
|
+
event.clearEvents()
|
|
580
|
+
|
|
581
|
+
# Display question
|
|
582
|
+
question_stim = visual.TextStim(self.window, text=trial['question'], pos=(0.0, 0.0), color=(-1, -1, -1), units='deg', height=height, wrapWidth=wrapWidth)
|
|
583
|
+
question_stim.draw()
|
|
584
|
+
self.window.flip()
|
|
585
|
+
|
|
586
|
+
# collect responses 0: no response 1-4: key pressed
|
|
587
|
+
trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['question_dur'])
|
|
588
|
+
trial['correct'] = (trial['response'] == self.corr_key[trial['trial_type']])
|
|
589
|
+
|
|
590
|
+
# display trial feedback
|
|
591
|
+
self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
|
|
592
|
+
|
|
593
|
+
return trial
|
|
594
|
+
|
|
595
|
+
class PassageListening(Task):
|
|
596
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
597
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
598
|
+
|
|
599
|
+
def display_instructions(self):
|
|
600
|
+
self.instruction_text = f'{self.descriptive_name} Task \n\nListen to the audio attentively.'
|
|
601
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
602
|
+
instr_visual.draw()
|
|
603
|
+
self.window.flip()
|
|
604
|
+
|
|
605
|
+
def run_trial(self, trial):
|
|
606
|
+
""" Run a single trial of the task. """
|
|
607
|
+
|
|
608
|
+
self.screen.fixation_cross()
|
|
609
|
+
|
|
610
|
+
# Load and play audio stimulus for the current trial
|
|
611
|
+
audio_path = ut.find_stim(self.const, self.name, trial['stim'])
|
|
612
|
+
audio_stim = sound.Sound(str(audio_path))
|
|
613
|
+
audio_stim.play()
|
|
614
|
+
|
|
615
|
+
# wait for trial duration (fixation cross stays on screen during playback)
|
|
616
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
|
|
617
|
+
|
|
618
|
+
return trial
|
|
619
|
+
|
|
620
|
+
class ActionObservation(Task):
|
|
621
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
622
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
623
|
+
|
|
624
|
+
def display_instructions(self): # overriding the display instruction from the parent class
|
|
625
|
+
self.instruction_text = f"{self.descriptive_name} Task \n\n Keep your head still while watching the two clips. \n\n Try and remember the knot shown."
|
|
626
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
627
|
+
instr_visual.draw()
|
|
628
|
+
self.window.flip()
|
|
629
|
+
|
|
630
|
+
def run_trial(self, trial):
|
|
631
|
+
""" Runs a single trial of the ActionObservation task """
|
|
632
|
+
# Assuming that 'stim' column in trial contains the file name of the video clip
|
|
633
|
+
movie_file_name = trial['stim']
|
|
634
|
+
|
|
635
|
+
# Construct the movie file path
|
|
636
|
+
movie_path = ut.find_stim(self.const, self.name, 'clips', movie_file_name)
|
|
637
|
+
|
|
638
|
+
# Convert Path object to string for compatibility
|
|
639
|
+
movie_path_str = str(movie_path)
|
|
640
|
+
|
|
641
|
+
# Create a MovieStim3 object
|
|
642
|
+
# MovieStim sizes are in pixels, so convert the width (deg); height keeps the clip's aspect
|
|
643
|
+
movie_clip = visual.MovieStim(self.window, movie_path_str, loop=False,
|
|
644
|
+
size=(deg2pix(trial.get('stim_width', 18.4), self.window.monitor), None))
|
|
645
|
+
|
|
646
|
+
while movie_clip.isFinished == False:
|
|
647
|
+
movie_clip.play()
|
|
648
|
+
movie_clip.draw()
|
|
649
|
+
self.window.flip()
|
|
650
|
+
self.ttl_clock.update()
|
|
651
|
+
|
|
652
|
+
self.screen.fixation_cross()
|
|
653
|
+
|
|
654
|
+
# Flush memory: This is necessary for the script to be able to run more than 1 run. Presenting movies is very memory hungry, so do not remove!
|
|
655
|
+
movie_clip.unload()
|
|
656
|
+
gc.collect() # Collect garbarge
|
|
657
|
+
|
|
658
|
+
return trial
|
|
659
|
+
|
|
660
|
+
class DemandGrid(Task):
|
|
661
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
662
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
663
|
+
self.square_size = 1.5
|
|
664
|
+
self.feedback_type = 'acc+rt'
|
|
665
|
+
|
|
666
|
+
def init_task(self):
|
|
667
|
+
"""
|
|
668
|
+
Initialize task - default is to read the target information into the trial_info dataframe
|
|
669
|
+
"""
|
|
670
|
+
trial_info_file = self.const.task_dir / self.name / self.task_file
|
|
671
|
+
self.trial_info = pd.read_csv(trial_info_file, sep='\t')
|
|
672
|
+
self.corr_key = [self.trial_info['key_left'].iloc[0],self.trial_info['key_right'].iloc[0]]
|
|
673
|
+
|
|
674
|
+
def display_instructions(self):
|
|
675
|
+
"""
|
|
676
|
+
displays the instruction for the task
|
|
677
|
+
"""
|
|
678
|
+
str1 = f"You will watch the sequence of boxes that light up and then choose the correct pattern"
|
|
679
|
+
str2 = f"if left, press {self.corr_key[0]}"
|
|
680
|
+
str3 = f"if right, press {self.corr_key[1]}"
|
|
681
|
+
self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3}"
|
|
682
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
683
|
+
instr_visual.draw()
|
|
684
|
+
self.window.flip()
|
|
685
|
+
|
|
686
|
+
def create_grid(self, sequence=None, position='center',grid_size=(3,4)):
|
|
687
|
+
"""Creates the grid of squares for the DemandGrid task, lighting up specific squares blue if a sequence is given,
|
|
688
|
+
and positions the grid left, right, or center."""
|
|
689
|
+
# Calculate offsets based on the desired position
|
|
690
|
+
if position == 'left':
|
|
691
|
+
offset_x = -self.answer_offset
|
|
692
|
+
elif position == 'right':
|
|
693
|
+
offset_x = self.answer_offset
|
|
694
|
+
else: # center
|
|
695
|
+
offset_x = 0
|
|
696
|
+
|
|
697
|
+
# Center the grid vertically
|
|
698
|
+
offset_y = 0
|
|
699
|
+
|
|
700
|
+
grid = []
|
|
701
|
+
# Create and draw the grid
|
|
702
|
+
for i in range(grid_size[0]):
|
|
703
|
+
row = []
|
|
704
|
+
for j in range(grid_size[1]):
|
|
705
|
+
# Calculate position with the offsets
|
|
706
|
+
# i runs over rows (grid_size[0]), j over columns (grid_size[1])
|
|
707
|
+
square_x = (j - grid_size[1] / 2 + 0.5) * self.square_size + offset_x
|
|
708
|
+
square_y = (grid_size[0] / 2 - i - 0.5) * self.square_size + offset_y
|
|
709
|
+
|
|
710
|
+
# Determine the fill color based on the sequence
|
|
711
|
+
fill_color = 'blue' if sequence and (i, j) in sequence else 'white'
|
|
712
|
+
|
|
713
|
+
rect = visual.Rect(self.window, width=self.square_size, height=self.square_size,
|
|
714
|
+
pos=(square_x, square_y), lineWidth=3,
|
|
715
|
+
lineColor='black', fillColor=fill_color)
|
|
716
|
+
rect.draw()
|
|
717
|
+
row.append(rect)
|
|
718
|
+
grid.append(row)
|
|
719
|
+
|
|
720
|
+
return grid
|
|
721
|
+
|
|
722
|
+
def run_trial(self, trial):
|
|
723
|
+
"""Runs a single trial of the DemandGrid task with two boxes lighting up at a time"""
|
|
724
|
+
# Draw the entire grid in its initial state
|
|
725
|
+
if 'grid_size' in trial:
|
|
726
|
+
grid_size = literal_eval(trial['grid_size'])
|
|
727
|
+
else:
|
|
728
|
+
grid_size = (3,4)
|
|
729
|
+
|
|
730
|
+
# Make the code adaptable to old DemandGrid implementation
|
|
731
|
+
if 'num_steps' in trial:
|
|
732
|
+
num_steps = trial['num_steps']
|
|
733
|
+
else:
|
|
734
|
+
num_steps = 3
|
|
735
|
+
|
|
736
|
+
step_dur = trial['sequence_dur']/num_steps
|
|
737
|
+
self.square_size = trial.get('square_size', 1.5)
|
|
738
|
+
self.answer_offset = trial.get('answer_offset', 5)
|
|
739
|
+
self.grid = self.create_grid(grid_size=grid_size)
|
|
740
|
+
|
|
741
|
+
# Display the sequence in steps
|
|
742
|
+
if 'original_sequence' in trial:
|
|
743
|
+
original_sequence = literal_eval(trial['original_sequence'])
|
|
744
|
+
else:
|
|
745
|
+
original_sequence = literal_eval(trial['grid_sequence'])
|
|
746
|
+
|
|
747
|
+
if 'num_steps' in trial: # new implementation
|
|
748
|
+
for i in range(num_steps):
|
|
749
|
+
step_sequence_name = f'original_step_{i+1}'
|
|
750
|
+
step_sequence = literal_eval(trial[step_sequence_name])
|
|
751
|
+
|
|
752
|
+
for tuple in step_sequence:
|
|
753
|
+
x, y = tuple
|
|
754
|
+
self.grid[x][y].fillColor = 'blue'
|
|
755
|
+
|
|
756
|
+
for row in self.grid:
|
|
757
|
+
for rect in row:
|
|
758
|
+
rect.draw()
|
|
759
|
+
self.window.flip()
|
|
760
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + step_dur)
|
|
761
|
+
|
|
762
|
+
for tuple in step_sequence:
|
|
763
|
+
x, y = tuple
|
|
764
|
+
self.grid[x][y].fillColor = 'white'
|
|
765
|
+
|
|
766
|
+
else: # old implementation
|
|
767
|
+
for i in range(0, len(original_sequence), 2): # Iterate in steps of 2
|
|
768
|
+
if i + 1 < len(original_sequence):
|
|
769
|
+
pair = [original_sequence[i], original_sequence[i + 1]]
|
|
770
|
+
else:
|
|
771
|
+
pair = [original_sequence[i]] # Handle odd-length sequences
|
|
772
|
+
|
|
773
|
+
# Highlight positions in the current pair
|
|
774
|
+
for x, y in pair:
|
|
775
|
+
self.grid[x][y].fillColor = 'blue'
|
|
776
|
+
|
|
777
|
+
# Draw and update the window
|
|
778
|
+
for row in self.grid:
|
|
779
|
+
for rect in row:
|
|
780
|
+
rect.draw()
|
|
781
|
+
self.window.flip()
|
|
782
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + step_dur)
|
|
783
|
+
|
|
784
|
+
# Reset colors after the pair
|
|
785
|
+
for x, y in pair:
|
|
786
|
+
self.grid[x][y].fillColor = 'white'
|
|
787
|
+
|
|
788
|
+
# Flush any keys in buffer
|
|
789
|
+
event.clearEvents()
|
|
790
|
+
|
|
791
|
+
# Determine which side the correct sequence will be displayed
|
|
792
|
+
correct_side = trial['correct_side']
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# # Display the original and modified sequence on the left or right side
|
|
796
|
+
modified_sequence = literal_eval(trial['modified_sequence'])
|
|
797
|
+
|
|
798
|
+
original_grid = self.create_grid(sequence=original_sequence, position=correct_side, grid_size=grid_size)
|
|
799
|
+
modified_grid = self.create_grid(sequence=modified_sequence, position='left' if correct_side == 'right' else 'right', grid_size=grid_size)
|
|
800
|
+
self.window.flip()
|
|
801
|
+
|
|
802
|
+
# collect responses 0: no response 1-4: key pressed
|
|
803
|
+
trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['question_dur'])
|
|
804
|
+
trial['correct'] = (trial['response'] == self.corr_key[trial['trial_type']])
|
|
805
|
+
|
|
806
|
+
# Provide feedback if necessary
|
|
807
|
+
self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
|
|
808
|
+
|
|
809
|
+
return trial
|
|
810
|
+
|
|
811
|
+
class Reading(Task):
|
|
812
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
813
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
814
|
+
|
|
815
|
+
def display_instructions(self):
|
|
816
|
+
self.instruction_text = f'{self.descriptive_name} Task \n\n Read the shown text and press a button when the image of a hand pressing a button is displayed'
|
|
817
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
818
|
+
instr_visual.draw()
|
|
819
|
+
self.window.flip()
|
|
820
|
+
|
|
821
|
+
def run_trial(self, trial):
|
|
822
|
+
""" Run a single trial of the sentence reading task. """
|
|
823
|
+
|
|
824
|
+
# get sentence and split into words by space
|
|
825
|
+
sentence = trial['stim']
|
|
826
|
+
words = sentence.split()
|
|
827
|
+
|
|
828
|
+
#show words seqeuntially each for 450ms
|
|
829
|
+
for word in words:
|
|
830
|
+
word_stim = visual.TextStim(self.window, text=word, pos=(0.0, 0.0), color=(-1, -1, -1), units='deg', height=trial.get('text_height', 2))
|
|
831
|
+
word_stim.draw()
|
|
832
|
+
self.window.flip()
|
|
833
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + 0.45)
|
|
834
|
+
|
|
835
|
+
event.clearEvents()
|
|
836
|
+
|
|
837
|
+
# show press button image
|
|
838
|
+
button_stim = visual.ImageStim(self.window, image=str(ut.find_stim(self.const, self.name, 'hand_press_transparent.png')), size=(trial.get('stim_width', 14.4), None))
|
|
839
|
+
button_stim.draw()
|
|
840
|
+
self.window.flip()
|
|
841
|
+
trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), 0.4)
|
|
842
|
+
|
|
843
|
+
# show blank_transparent image
|
|
844
|
+
blank_stim = visual.ImageStim(self.window, image=str(ut.find_stim(self.const, self.name, 'blank_transparent.png')), size=(trial.get('stim_width', 14.4), None))
|
|
845
|
+
blank_stim.draw()
|
|
846
|
+
self.window.flip()
|
|
847
|
+
|
|
848
|
+
# flush any keys in buffer
|
|
849
|
+
event.clearEvents()
|
|
850
|
+
|
|
851
|
+
# not displaying crosshair to replicate the localizer
|
|
852
|
+
|
|
853
|
+
return trial
|
|
854
|
+
|
|
855
|
+
class OddBall(Task):
|
|
856
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
857
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
858
|
+
self.feedback_type = 'acc+rt'
|
|
859
|
+
|
|
860
|
+
def init_task(self):
|
|
861
|
+
"""
|
|
862
|
+
Initialize task - default is to read the target information into the trial_info dataframe
|
|
863
|
+
"""
|
|
864
|
+
trial_info_file = self.const.task_dir / self.name / self.task_file
|
|
865
|
+
self.trial_info = pd.read_csv(trial_info_file, sep='\t')
|
|
866
|
+
self.press_key = self.trial_info['key_one'].iloc[0]
|
|
867
|
+
|
|
868
|
+
def display_instructions(self):
|
|
869
|
+
"""
|
|
870
|
+
displays the instruction for the task
|
|
871
|
+
"""
|
|
872
|
+
str1 = f"Press {self.press_key} when you see a red K"
|
|
873
|
+
self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n"
|
|
874
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
875
|
+
instr_visual.draw()
|
|
876
|
+
self.window.flip()
|
|
877
|
+
|
|
878
|
+
def run_trial(self, trial):
|
|
879
|
+
""" Run a single trial of the oddball task. """
|
|
880
|
+
|
|
881
|
+
current_trial = trial['stim']
|
|
882
|
+
|
|
883
|
+
# show stem
|
|
884
|
+
if current_trial == 'red_K':
|
|
885
|
+
word_stim = visual.TextStim(self.window, text='K', pos=(0.0, 0.0), color='red', units='deg', height=trial.get('text_height', 1.5))
|
|
886
|
+
elif current_trial == 'black_K':
|
|
887
|
+
word_stim = visual.TextStim(self.window, text='K', pos=(0.0, 0.0), color='black', units='deg', height=trial.get('text_height', 1.5))
|
|
888
|
+
elif current_trial == 'red_O':
|
|
889
|
+
word_stim = visual.TextStim(self.window, text='O', pos=(0.0, 0.0), color='red', units='deg', height=trial.get('text_height', 1.5))
|
|
890
|
+
elif current_trial == 'black_O':
|
|
891
|
+
word_stim = visual.TextStim(self.window, text='O', pos=(0.0, 0.0), color='black', units='deg', height=trial.get('text_height', 1.5))
|
|
892
|
+
|
|
893
|
+
word_stim.draw()
|
|
894
|
+
self.window.flip()
|
|
895
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
|
|
896
|
+
|
|
897
|
+
# blank the letter to a fixation cross during the response window (no per-trial feedback)
|
|
898
|
+
self.screen.fixation_cross()
|
|
899
|
+
|
|
900
|
+
# Flush any keys in buffer
|
|
901
|
+
event.clearEvents()
|
|
902
|
+
|
|
903
|
+
# collect responses 0: no response 1-4: key pressed
|
|
904
|
+
trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['iti_dur'])
|
|
905
|
+
|
|
906
|
+
# method 2 for accuracy for oddball
|
|
907
|
+
if trial['trial_type'] == 1:
|
|
908
|
+
trial['correct'] = True if trial['response']!= 0 else False
|
|
909
|
+
elif trial['trial_type'] == 0:
|
|
910
|
+
trial['correct'] = np.nan
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
return trial
|
|
914
|
+
|
|
915
|
+
class FingerSequence(Task):
|
|
916
|
+
"""
|
|
917
|
+
Finger sequence task
|
|
918
|
+
"""
|
|
919
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
920
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
921
|
+
self.feedback_type = 'acc+rt'
|
|
922
|
+
|
|
923
|
+
def init_task(self):
|
|
924
|
+
"""
|
|
925
|
+
Initialize task - default is to read the target information into the trial_info dataframe
|
|
926
|
+
"""
|
|
927
|
+
trial_info_file = self.const.task_dir / self.name / self.task_file
|
|
928
|
+
self.trial_info = pd.read_csv(trial_info_file, sep='\t')
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def display_instructions(self):
|
|
932
|
+
self.instruction_text = f"{self.descriptive_name} Task \n\n Using your four fingers, press the keys in the order shown on the screen\n Use all four fingers for this task"
|
|
933
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
934
|
+
instr_visual.draw()
|
|
935
|
+
self.window.flip()
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def run_trial(self, trial):
|
|
939
|
+
""" Run a single trial of the finger sequence task. """
|
|
940
|
+
#clear buffer
|
|
941
|
+
event.clearEvents()
|
|
942
|
+
|
|
943
|
+
# Display the sequence
|
|
944
|
+
sequence = trial['stim'].split()
|
|
945
|
+
|
|
946
|
+
# Calculate the start position for the sequence and determine the spacing between numbers
|
|
947
|
+
num_items = len(sequence)
|
|
948
|
+
spacing = 2.0
|
|
949
|
+
start_x = -(num_items - 1) * spacing / 2
|
|
950
|
+
|
|
951
|
+
# Show the numbers in the sequence next to each other ( using the spacing and start_x calculated above)
|
|
952
|
+
for i, number in enumerate(sequence):
|
|
953
|
+
pos = (start_x + i * spacing, 0.0) # Horizontal position is adjusted based on index
|
|
954
|
+
stim = visual.TextStim(self.window, text=number, pos=pos, color='black', units='deg', height=trial.get('text_height', 1.5))
|
|
955
|
+
stim.draw()
|
|
956
|
+
|
|
957
|
+
self.window.flip()
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
sequence_start_time = self.ttl_clock.get_time() # Needed for knowing when to stop looking for key presses
|
|
961
|
+
digit_start_time = sequence_start_time # Updated with each key press for calculating RT
|
|
962
|
+
|
|
963
|
+
rt_list = np.full(num_items,np.nan)
|
|
964
|
+
correct_list = np.zeros((num_items,)) # List of booleans indicating whether each press was correct needed for overall trial accuracy
|
|
965
|
+
num_presses =0
|
|
966
|
+
# Initialize the color for each digit in the sequence as black
|
|
967
|
+
digit_colors = ['black'] * num_items
|
|
968
|
+
while self.ttl_clock.get_time() - sequence_start_time < trial['trial_dur'] and num_presses < num_items:
|
|
969
|
+
self.ttl_clock.update()
|
|
970
|
+
|
|
971
|
+
keys = event.getKeys(keyList=self.const.response_keys, timeStamped=self.ttl_clock.clock)
|
|
972
|
+
if keys:
|
|
973
|
+
key_char, key_press_time = keys[0]
|
|
974
|
+
key = self.const.response_keys.index(key_char) + 1
|
|
975
|
+
rt = key_press_time - digit_start_time
|
|
976
|
+
rt_list[num_presses]=rt
|
|
977
|
+
digit_start_time = key_press_time
|
|
978
|
+
|
|
979
|
+
# Check if key pressed is correct
|
|
980
|
+
correct_list[num_presses] = key == int(sequence[num_presses])
|
|
981
|
+
|
|
982
|
+
# Update color based on correctness
|
|
983
|
+
digit_colors[num_presses] = 'green' if correct_list[num_presses] else 'red'
|
|
984
|
+
|
|
985
|
+
num_presses += 1
|
|
986
|
+
# Draw all digits with their adjusted colors
|
|
987
|
+
for i, (number, color) in enumerate(zip(sequence, digit_colors)):
|
|
988
|
+
pos = (start_x + i * spacing, 0.0)
|
|
989
|
+
stim = visual.TextStim(self.window, text=number, pos=pos, color=color, units='deg', height=trial.get('text_height', 1.5))
|
|
990
|
+
stim.draw()
|
|
991
|
+
|
|
992
|
+
self.window.flip()
|
|
993
|
+
|
|
994
|
+
else:
|
|
995
|
+
# If the sequence is completed, wait until the end of the trial
|
|
996
|
+
self.ttl_clock.wait_until(sequence_start_time + trial['trial_dur'])
|
|
997
|
+
|
|
998
|
+
# if any press is wrong trial['correct'] needs to be false, this is for post trial feedback
|
|
999
|
+
trial['correct'] = correct_list.sum()/num_items
|
|
1000
|
+
|
|
1001
|
+
if np.all(np.isnan(rt_list)):
|
|
1002
|
+
# calculate mean rt across presses
|
|
1003
|
+
trial['rt'] = np.nan
|
|
1004
|
+
|
|
1005
|
+
else:
|
|
1006
|
+
trial['rt'] = np.nanmean(rt_list)
|
|
1007
|
+
|
|
1008
|
+
# display trial feedback (for whole trial)
|
|
1009
|
+
self.display_trial_feedback(trial['display_trial_feedback'], trial['correct']== 1)
|
|
1010
|
+
|
|
1011
|
+
return trial
|
|
1012
|
+
|
|
1013
|
+
class FlexionExtension(Task):
|
|
1014
|
+
"""
|
|
1015
|
+
Flexion extension of toes! No particular feedback.
|
|
1016
|
+
"""
|
|
1017
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
1018
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
1019
|
+
self.feedback_type = 'None'
|
|
1020
|
+
|
|
1021
|
+
def init_task(self):
|
|
1022
|
+
self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
|
|
1023
|
+
|
|
1024
|
+
def display_instructions(self):
|
|
1025
|
+
self.instruction_text = f"{self.descriptive_name} Task \n\n Flex and extend your right and left toes"
|
|
1026
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
1027
|
+
instr_visual.draw()
|
|
1028
|
+
self.window.flip()
|
|
1029
|
+
|
|
1030
|
+
def run_trial(self, trial):
|
|
1031
|
+
# Show this phase's cue ('flexion' or 'extension') for its duration; no response.
|
|
1032
|
+
stim = visual.TextStim(self.window, text=trial['stim'], pos=(0.0, 0.0),
|
|
1033
|
+
color=(-1, -1, -1), units='deg', height=trial.get('text_height', 1.5))
|
|
1034
|
+
stim.draw()
|
|
1035
|
+
self.window.flip()
|
|
1036
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + trial['trial_dur'])
|
|
1037
|
+
return trial
|
|
1038
|
+
|
|
1039
|
+
class SemanticPrediction(Task):
|
|
1040
|
+
|
|
1041
|
+
"""
|
|
1042
|
+
Read a sentence and decide if the last word of the sentence makes sense. Click "3" if the last word makes sense; click "4" if not. Be as accurate and fast as possible.
|
|
1043
|
+
"""
|
|
1044
|
+
|
|
1045
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
1046
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
1047
|
+
self.feedback_type = 'acc+rt'
|
|
1048
|
+
|
|
1049
|
+
def init_task(self):
|
|
1050
|
+
"""
|
|
1051
|
+
Initialize task - default is to read the target information into the trial_info dataframe
|
|
1052
|
+
"""
|
|
1053
|
+
self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
|
|
1054
|
+
self.corr_key = [self.trial_info['key_false'].iloc[0], self.trial_info['key_true'].iloc[0]]
|
|
1055
|
+
|
|
1056
|
+
def display_instructions(self):
|
|
1057
|
+
"""
|
|
1058
|
+
displays the instruction for the task
|
|
1059
|
+
"""
|
|
1060
|
+
str1 = f"You will read a sentence and decide if the last word makes sense."
|
|
1061
|
+
str2 = f"If it makes sense, press {self.corr_key[1]}"
|
|
1062
|
+
str3 = f"if it doesn't make sense, press {self.corr_key[0]}"
|
|
1063
|
+
self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3}"
|
|
1064
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
1065
|
+
instr_visual.draw()
|
|
1066
|
+
self.window.flip()
|
|
1067
|
+
|
|
1068
|
+
def run_trial(self, trial):
|
|
1069
|
+
""" Runs a single trial of the semantic prediction task """
|
|
1070
|
+
|
|
1071
|
+
height_word = trial.get('text_height', 2)
|
|
1072
|
+
|
|
1073
|
+
event.clearEvents()
|
|
1074
|
+
|
|
1075
|
+
# get sentence and split into words by space
|
|
1076
|
+
sentence = trial['sentence']
|
|
1077
|
+
words = sentence.split('|')
|
|
1078
|
+
|
|
1079
|
+
#show words seqeuntially each for 800ms
|
|
1080
|
+
for word in words:
|
|
1081
|
+
word_stim = visual.TextStim(self.window, text=word, pos=(0.0, 0.0), color=(-1, -1, -1), units='deg', height=height_word)
|
|
1082
|
+
word_stim.draw()
|
|
1083
|
+
self.window.flip()
|
|
1084
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + 0.8)
|
|
1085
|
+
|
|
1086
|
+
event.clearEvents()
|
|
1087
|
+
|
|
1088
|
+
# Fixation cross
|
|
1089
|
+
self.screen.fixation_cross()
|
|
1090
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + 0.5)
|
|
1091
|
+
event.clearEvents()
|
|
1092
|
+
|
|
1093
|
+
# Display last word
|
|
1094
|
+
last_word_stim = visual.TextStim(self.window, text=trial['last_word'], pos=(0.0, 0.0), color=(-1, -1, -1), units='deg', height= height_word, wrapWidth=30)
|
|
1095
|
+
last_word_stim.draw()
|
|
1096
|
+
self.window.flip()
|
|
1097
|
+
|
|
1098
|
+
event.clearEvents()
|
|
1099
|
+
|
|
1100
|
+
# collect responses 0: no response 1-4: key pressed
|
|
1101
|
+
trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['sentence_dur'])
|
|
1102
|
+
trial['correct'] = (trial['response'] == self.corr_key[trial['trial_type']])
|
|
1103
|
+
|
|
1104
|
+
# display trial feedback
|
|
1105
|
+
self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
|
|
1106
|
+
|
|
1107
|
+
return trial
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
class VisualSearch(Task):
|
|
1111
|
+
|
|
1112
|
+
"""
|
|
1113
|
+
Look at a screen filled with shapes and identify whether an "L" is present. Click "3" if the "L" is present; click "4" if not. Be as accurate and fast as possible.
|
|
1114
|
+
"""
|
|
1115
|
+
|
|
1116
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
1117
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
1118
|
+
self.feedback_type = 'acc'
|
|
1119
|
+
|
|
1120
|
+
def init_task(self):
|
|
1121
|
+
"""
|
|
1122
|
+
Initialize task - default is to read the target information into the trial_info dataframe
|
|
1123
|
+
"""
|
|
1124
|
+
trial_info_file = self.const.task_dir / self.name / self.task_file
|
|
1125
|
+
self.trial_info = pd.read_csv(trial_info_file, sep='\t')
|
|
1126
|
+
|
|
1127
|
+
#counter initialized in order to read whether trial_type is true or not for each trial; used in generate_trial_stimuli
|
|
1128
|
+
self.trial_counter = 0
|
|
1129
|
+
|
|
1130
|
+
# Search area in deg (default 28 x 20: the x extremity of a large window is 14, y is 10, found by trial and error)
|
|
1131
|
+
screen_width = self.trial_info['search_width'].iloc[0] if 'search_width' in self.trial_info.columns else 28
|
|
1132
|
+
screen_height = self.trial_info['search_height'].iloc[0] if 'search_height' in self.trial_info.columns else 20
|
|
1133
|
+
|
|
1134
|
+
#define number of rows + col to split screen into 24 apertures
|
|
1135
|
+
num_rows = 4
|
|
1136
|
+
num_cols = 6
|
|
1137
|
+
|
|
1138
|
+
#Define aperture sizes + positions
|
|
1139
|
+
aperture_width = screen_width / num_cols
|
|
1140
|
+
aperture_height = screen_height / num_rows
|
|
1141
|
+
|
|
1142
|
+
positions_x = np.linspace(-screen_width / 2 + aperture_width / 2, screen_width / 2 - aperture_width / 2, num_cols)
|
|
1143
|
+
positions_y = np.linspace(-screen_height / 2 + aperture_height / 2, screen_height / 2 - aperture_height / 2, num_rows)
|
|
1144
|
+
|
|
1145
|
+
aperture_positions = []
|
|
1146
|
+
|
|
1147
|
+
for y in positions_y:
|
|
1148
|
+
for x in positions_x:
|
|
1149
|
+
aperture_positions.append((x, y))
|
|
1150
|
+
|
|
1151
|
+
self.apertures = []
|
|
1152
|
+
for pos in aperture_positions:
|
|
1153
|
+
apertures = visual.Aperture(self.window, size=40, shape = 'rectangle', pos=pos, units='norm')
|
|
1154
|
+
self.apertures.append(apertures)
|
|
1155
|
+
|
|
1156
|
+
def generate_trial_stimuli(self, num_stimuli):
|
|
1157
|
+
|
|
1158
|
+
stim_images = ['90.png','180.png','270.png','360.png']
|
|
1159
|
+
|
|
1160
|
+
self.stim = []
|
|
1161
|
+
|
|
1162
|
+
is_target = True #determines whether target will appear on screen
|
|
1163
|
+
|
|
1164
|
+
if self.trial_info['trial_type'][self.trial_counter] == 0:
|
|
1165
|
+
stim_images.remove('90.png') #sets a display with no target
|
|
1166
|
+
is_target = False
|
|
1167
|
+
|
|
1168
|
+
self.trial_counter+=1
|
|
1169
|
+
|
|
1170
|
+
randomly_select_apertures = random.sample(self.apertures, num_stimuli)
|
|
1171
|
+
|
|
1172
|
+
for aperture in randomly_select_apertures:
|
|
1173
|
+
if is_target:
|
|
1174
|
+
stim_current = stim_images[0] #sets a display with at least one target
|
|
1175
|
+
is_target = False
|
|
1176
|
+
else:
|
|
1177
|
+
stim_random_idx = random.randint(0, len(stim_images)-1) #chooses random stimuli from stim_images list
|
|
1178
|
+
stim_current = stim_images[stim_random_idx]
|
|
1179
|
+
|
|
1180
|
+
stim_path = ut.find_stim(self.const, self.name, stim_current)
|
|
1181
|
+
stimulus = visual.ImageStim(self.window, str(stim_path), size=(0.8,0.8))
|
|
1182
|
+
stimulus.setPos([aperture.pos[0], aperture.pos[1]]) #puts stimuli within apertures
|
|
1183
|
+
self.stim.append(stimulus) #creates list of all randomly selected stimuli
|
|
1184
|
+
|
|
1185
|
+
def display_instructions(self):
|
|
1186
|
+
"""
|
|
1187
|
+
displays the instruction for the task
|
|
1188
|
+
"""
|
|
1189
|
+
|
|
1190
|
+
self.corr_key = [self.trial_info['key_false'].iloc[0],self.trial_info['key_true'].iloc[0]]
|
|
1191
|
+
|
|
1192
|
+
str1 = f"You will survey a series of shapes and identify whether the letter ‘L’ is present."
|
|
1193
|
+
str2 = f"If 'L' is present, press {self.corr_key[1]}"
|
|
1194
|
+
str3 = f"if 'L' is not present, press {self.corr_key[0]}"
|
|
1195
|
+
self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3}"
|
|
1196
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
1197
|
+
instr_visual.draw()
|
|
1198
|
+
self.window.flip()
|
|
1199
|
+
|
|
1200
|
+
def run_trial(self,trial):
|
|
1201
|
+
"""Runs a single trial of visual search task
|
|
1202
|
+
"""
|
|
1203
|
+
|
|
1204
|
+
# Flush any keys in buffer
|
|
1205
|
+
event.clearEvents()
|
|
1206
|
+
|
|
1207
|
+
# Set size (difficulty) is stored as the condition, e.g. '8-items'.
|
|
1208
|
+
num_stimuli = int(str(trial['condition']).split('-')[0])
|
|
1209
|
+
|
|
1210
|
+
self.generate_trial_stimuli(num_stimuli)
|
|
1211
|
+
|
|
1212
|
+
self.screen.fixation_cross(flip=False) #show fixation cross in center of screen
|
|
1213
|
+
|
|
1214
|
+
# Display stimuli
|
|
1215
|
+
for stimulus in self.stim:
|
|
1216
|
+
stimulus.draw()
|
|
1217
|
+
|
|
1218
|
+
self.window.flip()
|
|
1219
|
+
|
|
1220
|
+
# collect responses
|
|
1221
|
+
trial['response'],trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['trial_dur'])
|
|
1222
|
+
trial['correct'] = (trial['response'] == self.corr_key[trial['trial_type']])
|
|
1223
|
+
|
|
1224
|
+
self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
|
|
1225
|
+
|
|
1226
|
+
return trial
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
class Movie(Task):
|
|
1230
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
1231
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
1232
|
+
self.name = 'movie'
|
|
1233
|
+
|
|
1234
|
+
def display_instructions(self):
|
|
1235
|
+
task_name = visual.TextStim(self.window, text=f'{self.descriptive_name.capitalize()}', height=self.const.instruction_text_height, color=[-1, -1, -1], bold=True, pos=(0, 3))
|
|
1236
|
+
task_name.draw()
|
|
1237
|
+
|
|
1238
|
+
self.instruction_text = f"\n\n You will watch short clips from a movie. Please keep your head still and pay attention to the screen."
|
|
1239
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width, pos=(0, 0))
|
|
1240
|
+
instr_visual.draw()
|
|
1241
|
+
self.window.flip()
|
|
1242
|
+
|
|
1243
|
+
def run_trial(self, trial):
|
|
1244
|
+
start = self.ttl_clock.get_time() # trial_dur counts from here, so loading the clip is included
|
|
1245
|
+
stim_width = deg2pix(trial.get('stim_width', 22), self.window.monitor) # MovieStim sizes are in pixels
|
|
1246
|
+
|
|
1247
|
+
# Get the file name
|
|
1248
|
+
movie_file_name = trial['stim']
|
|
1249
|
+
|
|
1250
|
+
# Construct the movie file path
|
|
1251
|
+
movie_path = ut.find_stim(self.const, self.name, 'clips', movie_file_name)
|
|
1252
|
+
|
|
1253
|
+
# Convert Path object to string for compatibility
|
|
1254
|
+
movie_path_str = str(movie_path)
|
|
1255
|
+
|
|
1256
|
+
# Create a MovieStim3 object
|
|
1257
|
+
movie_clip = visual.MovieStim(self.window, movie_path_str, loop=False, size=(stim_width, None), pos=(0, 0), noAudio=True)
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
movie_clip.play()
|
|
1261
|
+
self.window.flip()
|
|
1262
|
+
|
|
1263
|
+
# play until the clip ends or trial_dur is up (so a shorter trial cuts a longer clip)
|
|
1264
|
+
while not movie_clip.isFinished and self.ttl_clock.get_time() - start < trial['trial_dur']:
|
|
1265
|
+
movie_clip.play()
|
|
1266
|
+
movie_clip.draw()
|
|
1267
|
+
self.window.flip()
|
|
1268
|
+
self.ttl_clock.update()
|
|
1269
|
+
|
|
1270
|
+
# Flush memory: This is necessary for the script to be able to run more than 1 run. Presenting movies is very memory hungry, so do not remove!
|
|
1271
|
+
movie_clip.unload()
|
|
1272
|
+
gc.collect() # Collect garbarge
|
|
1273
|
+
|
|
1274
|
+
return trial
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
class Affective(Task):
|
|
1278
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
1279
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
1280
|
+
self.feedback_type = 'acc+rt'
|
|
1281
|
+
|
|
1282
|
+
def init_task(self):
|
|
1283
|
+
"""
|
|
1284
|
+
Initialize task
|
|
1285
|
+
"""
|
|
1286
|
+
trial_info_file = self.const.task_dir / self.name / self.task_file
|
|
1287
|
+
self.trial_info = pd.read_csv(trial_info_file, sep='\t')
|
|
1288
|
+
|
|
1289
|
+
self.stim = []
|
|
1290
|
+
stim_width = self.trial_info['stim_width'].iloc[0] if 'stim_width' in self.trial_info.columns else 18.4
|
|
1291
|
+
for stim_file in self.trial_info['stim']:
|
|
1292
|
+
stim_path = ut.find_stim(self.const, self.name, stim_file)
|
|
1293
|
+
self.stim.append(visual.ImageStim(self.window, image=str(stim_path), size=(stim_width, None))) # width in deg
|
|
1294
|
+
|
|
1295
|
+
def display_instructions(self):
|
|
1296
|
+
"""
|
|
1297
|
+
Display instructions for the affective task.
|
|
1298
|
+
"""
|
|
1299
|
+
key_pleasant = self.trial_info['key_pleasant'].iloc[0]
|
|
1300
|
+
key_unpleasant = self.trial_info['key_unpleasant'].iloc[0]
|
|
1301
|
+
|
|
1302
|
+
self.instruction_text = (
|
|
1303
|
+
f"{self.descriptive_name} Task\n\n"
|
|
1304
|
+
f"Press {key_unpleasant} if the image is UNPLEASANT.\n"
|
|
1305
|
+
f"Press {key_pleasant} if the image is PLEASANT."
|
|
1306
|
+
)
|
|
1307
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
1308
|
+
instr_visual.draw()
|
|
1309
|
+
self.window.flip()
|
|
1310
|
+
|
|
1311
|
+
def run_trial(self, trial):
|
|
1312
|
+
"""
|
|
1313
|
+
Run a single trial of the affective task.
|
|
1314
|
+
"""
|
|
1315
|
+
event.clearEvents()
|
|
1316
|
+
|
|
1317
|
+
# Display image
|
|
1318
|
+
self.stim[trial['trial_num']].draw()
|
|
1319
|
+
self.window.flip()
|
|
1320
|
+
|
|
1321
|
+
# Wait for response
|
|
1322
|
+
trial['response'], trial['rt'] = self.wait_response(self.ttl_clock.get_time(), trial['trial_dur'])
|
|
1323
|
+
|
|
1324
|
+
# Determine correct response
|
|
1325
|
+
correct_key = trial['key_pleasant'] if trial['trial_type'] == 2 else trial['key_unpleasant']
|
|
1326
|
+
trial['correct'] = (trial['response'] == correct_key)
|
|
1327
|
+
|
|
1328
|
+
# Show feedback
|
|
1329
|
+
self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
|
|
1330
|
+
|
|
1331
|
+
return trial
|
|
1332
|
+
|
|
1333
|
+
class SerialReactionTime(Task):
|
|
1334
|
+
"""
|
|
1335
|
+
Serial reaction time task
|
|
1336
|
+
"""
|
|
1337
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
1338
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
1339
|
+
|
|
1340
|
+
def init_task(self):
|
|
1341
|
+
"""
|
|
1342
|
+
Initialize task - default is to read the target information into the trial_info dataframe
|
|
1343
|
+
"""
|
|
1344
|
+
trial_info_file = self.const.task_dir / self.name / self.task_file
|
|
1345
|
+
self.trial_info = pd.read_csv(trial_info_file, sep='\t')
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def display_instructions(self): # overriding the display instruction from the parent class
|
|
1349
|
+
self.instruction_text = f"Press the buttons that match the green boxes"
|
|
1350
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width, pos=(0, 0.3))
|
|
1351
|
+
instr_visual.draw()
|
|
1352
|
+
self.window.flip()
|
|
1353
|
+
|
|
1354
|
+
def run(self):
|
|
1355
|
+
"""Override to show empty boxes during initial wait."""
|
|
1356
|
+
box_positions = [(-5, 2), (-3, 2), (3, 2), (5, 2)]
|
|
1357
|
+
self.boxes = [
|
|
1358
|
+
visual.Rect(
|
|
1359
|
+
self.window, width=2, height=2,
|
|
1360
|
+
pos=pos, fillColor='white',
|
|
1361
|
+
lineColor='black', units='deg'
|
|
1362
|
+
)
|
|
1363
|
+
for pos in box_positions
|
|
1364
|
+
]
|
|
1365
|
+
for box in self.boxes:
|
|
1366
|
+
box.draw()
|
|
1367
|
+
self.window.flip()
|
|
1368
|
+
return super().run()
|
|
1369
|
+
|
|
1370
|
+
def run_trial(self, trial):
|
|
1371
|
+
|
|
1372
|
+
event.clearEvents()
|
|
1373
|
+
|
|
1374
|
+
trial_stim = int(trial['stim'])
|
|
1375
|
+
target_index = trial_stim - 1
|
|
1376
|
+
|
|
1377
|
+
# wait until absolute stimulus time
|
|
1378
|
+
stim_onset = self.ttl_clock.get_time()
|
|
1379
|
+
|
|
1380
|
+
# show stimulus
|
|
1381
|
+
for j, box in enumerate(self.boxes):
|
|
1382
|
+
box.fillColor = 'green' if j == target_index else 'white'
|
|
1383
|
+
box.draw()
|
|
1384
|
+
self.window.flip()
|
|
1385
|
+
|
|
1386
|
+
# keep green on for trial_dur
|
|
1387
|
+
self.ttl_clock.wait_until(stim_onset + trial['trial_dur'])
|
|
1388
|
+
|
|
1389
|
+
# back to white
|
|
1390
|
+
for box in self.boxes:
|
|
1391
|
+
box.fillColor = 'white'
|
|
1392
|
+
box.draw()
|
|
1393
|
+
self.window.flip()
|
|
1394
|
+
|
|
1395
|
+
# collect responses until absolute end_time
|
|
1396
|
+
responses = []
|
|
1397
|
+
while self.ttl_clock.get_time() < (stim_onset + trial['trial_dur'] + trial['iti_dur']):
|
|
1398
|
+
keys = event.getKeys(
|
|
1399
|
+
keyList=self.const.response_keys,
|
|
1400
|
+
timeStamped=self.ttl_clock.clock
|
|
1401
|
+
)
|
|
1402
|
+
for key, timestamp in keys:
|
|
1403
|
+
rt = timestamp - stim_onset
|
|
1404
|
+
responses.append((key, rt))
|
|
1405
|
+
|
|
1406
|
+
trial['responses'] = responses
|
|
1407
|
+
return trial
|
|
1408
|
+
|
|
1409
|
+
class FingerRhythmic(Task):
|
|
1410
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
1411
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
1412
|
+
|
|
1413
|
+
def init_task(self):
|
|
1414
|
+
self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
|
|
1415
|
+
self.corr_key = [self.trial_info['key_one'].iloc[0]]
|
|
1416
|
+
|
|
1417
|
+
def display_instructions(self):
|
|
1418
|
+
"""
|
|
1419
|
+
displays the instruction for the task
|
|
1420
|
+
"""
|
|
1421
|
+
|
|
1422
|
+
str1 = f"Tap along to the tones using the {self.corr_key[0]} key."
|
|
1423
|
+
str2 = f"Keep tapping at the same pace when the tones stop."
|
|
1424
|
+
self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2}"
|
|
1425
|
+
instr_visual = visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width)
|
|
1426
|
+
instr_visual.draw()
|
|
1427
|
+
self.window.flip()
|
|
1428
|
+
|
|
1429
|
+
def run_trial(self, trial):
|
|
1430
|
+
""" Runs a single trial of the Finger Rhythmic task """
|
|
1431
|
+
|
|
1432
|
+
event.clearEvents()
|
|
1433
|
+
txt = (f"New trial starts now") # this text shows when a new trials starts
|
|
1434
|
+
visual.TextStim(self.window, text=txt,height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width).draw()
|
|
1435
|
+
self.window.flip()
|
|
1436
|
+
self.ttl_clock.wait_until(self.ttl_clock.get_time() + 2)
|
|
1437
|
+
|
|
1438
|
+
self.screen.fixation_cross()
|
|
1439
|
+
event.clearEvents()
|
|
1440
|
+
clk = self.ttl_clock.clock
|
|
1441
|
+
t0 = clk.getTime() # trial anchor (TTL)
|
|
1442
|
+
|
|
1443
|
+
# --- Play FIRST tone now, then use THIS time as the grid anchor
|
|
1444
|
+
beep = sound.Sound(value=1000, secs=0.05, sampleRate=48000, stereo=True)
|
|
1445
|
+
beep.play()
|
|
1446
|
+
t_first = clk.getTime() # when we triggered the first tone (TTL)
|
|
1447
|
+
ioi = float(trial['ioi'])
|
|
1448
|
+
expected = [(t_first - t0) + i*ioi for i in range(12)] # expected, aligned to first tone
|
|
1449
|
+
|
|
1450
|
+
# Track beep times for timing verification (quiet by default)
|
|
1451
|
+
beep_times = [t_first]
|
|
1452
|
+
|
|
1453
|
+
taps_rel = []
|
|
1454
|
+
|
|
1455
|
+
# --- Remaining 11 tones by absolute TTL deadlines; collect keys in-between
|
|
1456
|
+
for i in range(1, 12):
|
|
1457
|
+
deadline = t_first + i*ioi
|
|
1458
|
+
while True:
|
|
1459
|
+
now = clk.getTime()
|
|
1460
|
+
if now >= deadline:
|
|
1461
|
+
# Create a new Sound object for each beep to ensure it plays
|
|
1462
|
+
beep = sound.Sound(value=1000, secs=0.05, sampleRate=48000, stereo=True)
|
|
1463
|
+
beep.play()
|
|
1464
|
+
beep_time = clk.getTime()
|
|
1465
|
+
beep_times.append(beep_time)
|
|
1466
|
+
break
|
|
1467
|
+
res = event.waitKeys(maxWait=deadline - now,
|
|
1468
|
+
keyList=self.const.response_keys,
|
|
1469
|
+
timeStamped=clk)
|
|
1470
|
+
if res:
|
|
1471
|
+
for _, ts in res:
|
|
1472
|
+
taps_rel.append(ts - t0)
|
|
1473
|
+
|
|
1474
|
+
# --- Silent phase: collect until absolute end_time
|
|
1475
|
+
end_abs = self.start_time + float(trial['end_time'])
|
|
1476
|
+
while True:
|
|
1477
|
+
now = clk.getTime()
|
|
1478
|
+
if now >= end_abs:
|
|
1479
|
+
break
|
|
1480
|
+
res = event.waitKeys(maxWait=end_abs - now,
|
|
1481
|
+
keyList=self.const.response_keys,
|
|
1482
|
+
timeStamped=clk)
|
|
1483
|
+
if res:
|
|
1484
|
+
for _, ts in res:
|
|
1485
|
+
taps_rel.append(ts - t0)
|
|
1486
|
+
|
|
1487
|
+
# --- Self-paced ISIs only (strictly after last tone onset)
|
|
1488
|
+
last_tone_t = expected[-1] # relative to t0
|
|
1489
|
+
self_taps = [t for t in taps_rel if t > last_tone_t]
|
|
1490
|
+
isis = np.diff(self_taps) if len(self_taps) > 1 else np.array([], float)
|
|
1491
|
+
# Accept a window scaled to the trial's IOI: a fixed 300-900 ms window
|
|
1492
|
+
# discards real taps and keeps implausible ones when ioi is not ~0.6.
|
|
1493
|
+
isis = isis[(isis >= max(0.15, 0.2 * ioi)) & (isis <= 2.0 * ioi)]
|
|
1494
|
+
|
|
1495
|
+
trial['iri_ms_mean'] = float(np.mean(isis) * 1000.0) if isis.size else np.nan
|
|
1496
|
+
trial['iri_ms_sd'] = float(np.std(isis) * 1000.0) if isis.size else np.nan
|
|
1497
|
+
trial['iris_ms_json'] = json.dumps((isis * 1000.0).tolist())
|
|
1498
|
+
trial['expected_rel_s_json'] = json.dumps([e for e in expected]) # seconds rel to t0
|
|
1499
|
+
trial['tap_rel_s_json'] = json.dumps(taps_rel)
|
|
1500
|
+
# Save beep times relative to trial start (t0)
|
|
1501
|
+
beep_times_rel = [bt - t0 for bt in beep_times]
|
|
1502
|
+
trial['beep_times_rel_s_json'] = json.dumps(beep_times_rel)
|
|
1503
|
+
|
|
1504
|
+
return trial
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
class TimePerception(Task):
|
|
1508
|
+
def __init__(self, info, screen, ttl_clock, const, subj_id):
|
|
1509
|
+
super().__init__(info, screen, ttl_clock, const, subj_id)
|
|
1510
|
+
self.feedback_type = 'acc+rt'
|
|
1511
|
+
|
|
1512
|
+
def init_task(self):
|
|
1513
|
+
self.trial_info = pd.read_csv(self.const.task_dir / self.name / self.task_file, sep='\t')
|
|
1514
|
+
self.corr_key = [self.trial_info['key_one'].iloc[0], self.trial_info['key_two'].iloc[0]]
|
|
1515
|
+
# one tone for both modalities
|
|
1516
|
+
self.tone = sound.Sound(value=1000, secs=0.050, sampleRate=48000, stereo=True) # 1000 Hz, 50 ms
|
|
1517
|
+
|
|
1518
|
+
# PEST state per side
|
|
1519
|
+
mod = str(self.trial_info['modality'].iloc[0]).lower()
|
|
1520
|
+
if mod == 'time':
|
|
1521
|
+
# start ±120 ms from 400; step 40; min step 8; directions tracked
|
|
1522
|
+
self.stair = {
|
|
1523
|
+
'shorter': {'curr': 280.0, 'step': 40.0, 'min_step': 8.0, 'last_dir': 0, 'same_dir': 0},
|
|
1524
|
+
'longer': {'curr': 520.0, 'step': 40.0, 'min_step': 8.0, 'last_dir': 0, 'same_dir': 0},
|
|
1525
|
+
}
|
|
1526
|
+
else: # 'volume' (quieter/louder)
|
|
1527
|
+
# start ±1.62 dB from 73; step 1.08; min step 0.27
|
|
1528
|
+
self.stair = {
|
|
1529
|
+
'quieter': {'curr': 71.38, 'step': 1.08, 'min_step': 0.27, 'last_dir': 0, 'same_dir': 0},
|
|
1530
|
+
'louder': {'curr': 74.62, 'step': 1.08, 'min_step': 0.27, 'last_dir': 0, 'same_dir': 0},
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
def display_instructions(self):
|
|
1534
|
+
mod = str(self.trial_info['modality'].iloc[0]).lower()
|
|
1535
|
+
if mod == 'time':
|
|
1536
|
+
str1 = f"You will hear two pairs of tones."
|
|
1537
|
+
str2 = f"Press [{self.corr_key[0]}] if the SECOND interval is shorter."
|
|
1538
|
+
str3 = f"Press [{self.corr_key[1]}] if the SECOND interval is longer."
|
|
1539
|
+
str4 = "The first pair is always the same."
|
|
1540
|
+
self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3} \n {str4}"
|
|
1541
|
+
|
|
1542
|
+
else:
|
|
1543
|
+
str1 = f"You will hear two pairs of tones."
|
|
1544
|
+
str2 = f"Press [{self.corr_key[0]}] if the SECOND interval is quieter."
|
|
1545
|
+
str3 = f"Press [{self.corr_key[1]}] if the SECOND interval is louder."
|
|
1546
|
+
str4 = "The first pair is always the same."
|
|
1547
|
+
self.instruction_text = f"{self.descriptive_name} Task\n\n {str1} \n {str2} \n {str3} \n {str4}"
|
|
1548
|
+
visual.TextStim(self.window, text=self.instruction_text, height=self.const.instruction_text_height, color=[-1, -1, -1], wrapWidth=self.const.instruction_wrap_width).draw()
|
|
1549
|
+
self.window.flip()
|
|
1550
|
+
|
|
1551
|
+
def run_trial(self, trial):
|
|
1552
|
+
event.clearEvents()
|
|
1553
|
+
clk = self.ttl_clock
|
|
1554
|
+
mod = str(trial['modality']).lower()
|
|
1555
|
+
side = str(trial['side']).lower()
|
|
1556
|
+
|
|
1557
|
+
# fixation
|
|
1558
|
+
self.screen.fixation_cross()
|
|
1559
|
+
|
|
1560
|
+
# --- Pair 1 (standard @ 0.7 amp, 400 ms gap) ---
|
|
1561
|
+
self.tone.setVolume(0.7)
|
|
1562
|
+
t1 = clk.get_time()
|
|
1563
|
+
self.tone.play()
|
|
1564
|
+
clk.wait_until(t1 + 0.050)
|
|
1565
|
+
clk.wait_until(t1 + 0.050 + 0.400)
|
|
1566
|
+
self.tone.play()
|
|
1567
|
+
clk.wait_until(t1 + 2*0.050 + 0.400)
|
|
1568
|
+
|
|
1569
|
+
# inter-pair gap
|
|
1570
|
+
clk.wait_until(clk.get_time() + 1.000)
|
|
1571
|
+
|
|
1572
|
+
# --- Pair 2 (comparison) ---
|
|
1573
|
+
st = self.stair[side] # PEST state for this side
|
|
1574
|
+
|
|
1575
|
+
if mod == 'time':
|
|
1576
|
+
# snap to 8 ms grid within side range
|
|
1577
|
+
if side == 'shorter':
|
|
1578
|
+
comp_ms = max(160, min(392, int(round(st['curr'] / 8.0) * 8)))
|
|
1579
|
+
else:
|
|
1580
|
+
comp_ms = max(408, min(640, int(round(st['curr'] / 8.0) * 8)))
|
|
1581
|
+
|
|
1582
|
+
t2 = clk.get_time()
|
|
1583
|
+
self.tone.setVolume(0.7)
|
|
1584
|
+
self.tone.play()
|
|
1585
|
+
clk.wait_until(t2 + 0.050)
|
|
1586
|
+
clk.wait_until(t2 + 0.050 + (comp_ms / 1000.0))
|
|
1587
|
+
self.tone.play()
|
|
1588
|
+
clk.wait_until(t2 + 2*0.050 + (comp_ms / 1000.0))
|
|
1589
|
+
|
|
1590
|
+
trial['comparison_ms'] = float(comp_ms)
|
|
1591
|
+
trial['stair_step_ms'] = float(st['step']) # log step used this trial
|
|
1592
|
+
|
|
1593
|
+
else: # volume (quieter/louder), grid 0.27 dB
|
|
1594
|
+
if side == 'quieter':
|
|
1595
|
+
comp_db = max(64.9, min(72.73, float(st['curr'])))
|
|
1596
|
+
else:
|
|
1597
|
+
comp_db = min(81.1, max(73.27, float(st['curr'])))
|
|
1598
|
+
|
|
1599
|
+
comp_amp = float(min(1.0, 0.7 * (10 ** ((comp_db - 73.0) / 20.0))))
|
|
1600
|
+
|
|
1601
|
+
t2 = clk.get_time()
|
|
1602
|
+
self.tone.setVolume(comp_amp)
|
|
1603
|
+
self.tone.play()
|
|
1604
|
+
clk.wait_until(t2 + 0.050)
|
|
1605
|
+
clk.wait_until(t2 + 0.050 + 0.400)
|
|
1606
|
+
self.tone.setVolume(comp_amp)
|
|
1607
|
+
self.tone.play()
|
|
1608
|
+
clk.wait_until(t2 + 2*0.050 + 0.400)
|
|
1609
|
+
self.tone.setVolume(1.0)
|
|
1610
|
+
|
|
1611
|
+
trial['comparison_dba'] = float(comp_db)
|
|
1612
|
+
trial['stair_step_dba'] = float(st['step']) # log step used this trial
|
|
1613
|
+
|
|
1614
|
+
# --- response window ---
|
|
1615
|
+
trial['response'], trial['rt'] = self.wait_response(clk.get_time(), float(trial['question_dur']))
|
|
1616
|
+
trial['correct'] = (trial['response'] == trial['trial_type'])
|
|
1617
|
+
|
|
1618
|
+
# --- PEST update (classic + border-safe): halve on reversal; double after two same-direction moves ---
|
|
1619
|
+
# Define movement: toward standard if correct, away if incorrect.
|
|
1620
|
+
# Use unified sign for direction comparison: toward = -1, away = +1.
|
|
1621
|
+
move_dir = (-1 if trial['correct'] else +1)
|
|
1622
|
+
|
|
1623
|
+
# Store the old level so we can tell if we actually moved after clamping.
|
|
1624
|
+
old_curr = st['curr']
|
|
1625
|
+
|
|
1626
|
+
# 1. Propose + clamp
|
|
1627
|
+
if mod == 'time':
|
|
1628
|
+
if side == 'shorter':
|
|
1629
|
+
# correct => toward standard => make interval longer => +step
|
|
1630
|
+
# incorrect => away => make interval even shorter => -step
|
|
1631
|
+
st['curr'] += (+st['step'] if trial['correct'] else -st['step'])
|
|
1632
|
+
# snap to 8 ms grid and clamp to that side's allowed range
|
|
1633
|
+
st['curr'] = float(int(round(st['curr'] / 8.0) * 8))
|
|
1634
|
+
if st['curr'] < 160.0:
|
|
1635
|
+
st['curr'] = 160.0
|
|
1636
|
+
if st['curr'] > 392.0:
|
|
1637
|
+
st['curr'] = 392.0
|
|
1638
|
+
|
|
1639
|
+
else: # side == 'longer'
|
|
1640
|
+
# correct => toward standard => make interval shorter => -step
|
|
1641
|
+
# incorrect => away => make interval even longer => +step
|
|
1642
|
+
st['curr'] += (-st['step'] if trial['correct'] else +st['step'])
|
|
1643
|
+
st['curr'] = float(int(round(st['curr'] / 8.0) * 8))
|
|
1644
|
+
if st['curr'] < 408.0:
|
|
1645
|
+
st['curr'] = 408.0
|
|
1646
|
+
if st['curr'] > 640.0:
|
|
1647
|
+
st['curr'] = 640.0
|
|
1648
|
+
|
|
1649
|
+
else:
|
|
1650
|
+
# volume
|
|
1651
|
+
if side == 'quieter':
|
|
1652
|
+
# correct => toward standard (louder) => +step in dB toward 73
|
|
1653
|
+
# incorrect => away (quieter) => -step
|
|
1654
|
+
st['curr'] += (+st['step'] if trial['correct'] else -st['step'])
|
|
1655
|
+
if st['curr'] < 64.9:
|
|
1656
|
+
st['curr'] = 64.9
|
|
1657
|
+
if st['curr'] > 72.73:
|
|
1658
|
+
st['curr'] = 72.73
|
|
1659
|
+
|
|
1660
|
+
else: # side == 'louder'
|
|
1661
|
+
# correct => toward standard (quieter) => -step
|
|
1662
|
+
# incorrect => away (louder) => +step
|
|
1663
|
+
st['curr'] += (-st['step'] if trial['correct'] else +st['step'])
|
|
1664
|
+
if st['curr'] < 73.27:
|
|
1665
|
+
st['curr'] = 73.27
|
|
1666
|
+
if st['curr'] > 81.1:
|
|
1667
|
+
st['curr'] = 81.1
|
|
1668
|
+
|
|
1669
|
+
# 2. Did we actually move?
|
|
1670
|
+
actually_moved = (st['curr'] != old_curr)
|
|
1671
|
+
|
|
1672
|
+
if actually_moved:
|
|
1673
|
+
# Normal PESt adaptation only if we escaped the boundary.
|
|
1674
|
+
|
|
1675
|
+
if st['last_dir'] != 0 and move_dir != st['last_dir']:
|
|
1676
|
+
# reversal -> halve step (but not below min_step), reset consecutive counter
|
|
1677
|
+
st['step'] = max(st['min_step'], st['step'] / 2.0)
|
|
1678
|
+
st['same_dir'] = 0
|
|
1679
|
+
|
|
1680
|
+
else:
|
|
1681
|
+
# same direction as last (or first informative move)
|
|
1682
|
+
if st['last_dir'] == 0 or move_dir == st['last_dir']:
|
|
1683
|
+
st['same_dir'] += 1
|
|
1684
|
+
else:
|
|
1685
|
+
# new direction but last_dir was 0 should already be covered above,
|
|
1686
|
+
# but keep a safe fallback
|
|
1687
|
+
st['same_dir'] = 1
|
|
1688
|
+
|
|
1689
|
+
# after two same-direction moves -> double step
|
|
1690
|
+
if st['same_dir'] >= 2:
|
|
1691
|
+
st['step'] = st['step'] * 2.0
|
|
1692
|
+
st['same_dir'] = 0 # require two more same-direction moves for next doubling
|
|
1693
|
+
|
|
1694
|
+
# update last_dir ONLY when a real move happened
|
|
1695
|
+
st['last_dir'] = move_dir
|
|
1696
|
+
|
|
1697
|
+
else:
|
|
1698
|
+
# We hit a border and got clamped. Do NOT adapt step. Do NOT change same_dir. Do NOT touch last_dir.
|
|
1699
|
+
pass
|
|
1700
|
+
|
|
1701
|
+
self.stair[side] = st
|
|
1702
|
+
self.display_trial_feedback(trial['display_trial_feedback'], trial['correct'])
|
|
1703
|
+
return trial
|