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,1833 @@
|
|
|
1
|
+
# Create TaskFile file for different tasks
|
|
2
|
+
# March 2021: First version: Ladan Shahshahani - Maedbh King - Suzanne Witt,
|
|
3
|
+
# Revised 2023: Bassel Arafat, Jorn Diedrichsen, Ince Husain
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import numpy as np
|
|
7
|
+
import random
|
|
8
|
+
import MultiTaskBattery.utils as ut
|
|
9
|
+
import itertools
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def shuffle_rows(dataframe, keep_in_middle=None):
|
|
13
|
+
"""
|
|
14
|
+
randomly shuffles rows of the dataframe
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
dataframe (dataframe): dataframe to be shuffled
|
|
18
|
+
keep_in_middle (list): list of tasks that should be kept in the middle
|
|
19
|
+
Returns:
|
|
20
|
+
dataframe (dataframe): shuffled dataframe
|
|
21
|
+
"""
|
|
22
|
+
indx = np.arange(len(dataframe.index))
|
|
23
|
+
np.random.shuffle(indx)
|
|
24
|
+
dataframe = (dataframe.iloc[indx]).reset_index(drop = True)
|
|
25
|
+
|
|
26
|
+
if keep_in_middle is not None:
|
|
27
|
+
dataframe = move_edge_tasks_to_middle(dataframe, keep_in_middle)
|
|
28
|
+
|
|
29
|
+
return dataframe
|
|
30
|
+
|
|
31
|
+
def move_edge_tasks_to_middle(dataframe, keep_in_middle):
|
|
32
|
+
"""Moves tasks that should be kept in the middle and are at the edge of the dataframe to the middle of the dataframe
|
|
33
|
+
Args:
|
|
34
|
+
dataframe (dataframe): dataframe to be shuffled
|
|
35
|
+
keep_in_middle (list): list of tasks that should be kept in the middle
|
|
36
|
+
Returns:
|
|
37
|
+
dataframe (dataframe): shuffled dataframe"""
|
|
38
|
+
|
|
39
|
+
middle_task_at_edge = np.any([edge_task in keep_in_middle for edge_task in dataframe.iloc[[0, -1]].task_name])
|
|
40
|
+
|
|
41
|
+
if middle_task_at_edge and len(keep_in_middle) < len(dataframe)-2:
|
|
42
|
+
# If the middle task is at the edge and there are enough tasks to frame the middle tasks, find a new position in the middle
|
|
43
|
+
middle_indices = list(dataframe.iloc[1:-1].index)
|
|
44
|
+
middle_indices = [i for i in middle_indices if dataframe.iloc[i].task_name not in keep_in_middle]
|
|
45
|
+
selected_middle_indices = np.random.choice(middle_indices, len(keep_in_middle), replace=False)
|
|
46
|
+
|
|
47
|
+
for i, task in enumerate(keep_in_middle):
|
|
48
|
+
# Find where the middle task is wrongly placed (edge positions)
|
|
49
|
+
for edge_idx in [0, -1]:
|
|
50
|
+
if dataframe.iloc[edge_idx].task_name == task:
|
|
51
|
+
# Swap the edge task with a chosen middle position
|
|
52
|
+
middle_idx = selected_middle_indices[i]
|
|
53
|
+
dataframe.iloc[edge_idx], dataframe.iloc[middle_idx] = dataframe.iloc[middle_idx], dataframe.iloc[edge_idx]
|
|
54
|
+
return dataframe
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def add_start_end_times(dataframe, offset, run_time=None):
|
|
58
|
+
"""
|
|
59
|
+
Lay blocks end-to-end using each row's own instruction_dur + task_dur, so
|
|
60
|
+
blocks in a run can have different lengths.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
dataframe (dataframe): the run dataframe (rows already in final order,
|
|
64
|
+
with 'instruction_dur' and 'task_dur' columns)
|
|
65
|
+
offset (float): start time of the first block
|
|
66
|
+
run_time (float): if set, the last block's end_time is extended to this
|
|
67
|
+
(e.g. to capture activity overhang from the final task in a run)
|
|
68
|
+
Returns:
|
|
69
|
+
dataframe (dataframe): dataframe with start_time and end_time columns
|
|
70
|
+
"""
|
|
71
|
+
block_dur = dataframe['instruction_dur'] + dataframe['task_dur']
|
|
72
|
+
dataframe['start_time'] = offset + block_dur.cumsum().shift(fill_value=0)
|
|
73
|
+
dataframe['end_time'] = dataframe['start_time'] + block_dur
|
|
74
|
+
if run_time:
|
|
75
|
+
if run_time < dataframe['end_time'].iloc[-1]:
|
|
76
|
+
raise ValueError('Run time is shorter than the last task')
|
|
77
|
+
# Extend the last block's end so the run lasts run_time (captures overhang).
|
|
78
|
+
dataframe.loc[dataframe.index[-1], 'end_time'] = run_time
|
|
79
|
+
return dataframe
|
|
80
|
+
|
|
81
|
+
def make_run_file(task_list,
|
|
82
|
+
tfiles,
|
|
83
|
+
offset = 0,
|
|
84
|
+
instruction_dur = 5,
|
|
85
|
+
task_dur = 30,
|
|
86
|
+
run_time = None,
|
|
87
|
+
keep_in_middle=None,
|
|
88
|
+
shuffle=True,
|
|
89
|
+
exp_dir=None,
|
|
90
|
+
task_modules=None):
|
|
91
|
+
"""
|
|
92
|
+
Make a single run file.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
task_list (list): Task names for this run (one per block).
|
|
96
|
+
tfiles (list): Task-file name for each block, matching task_list.
|
|
97
|
+
offset (float): Start time of the first block (e.g. to skip dummy scans).
|
|
98
|
+
instruction_dur (float or list): Instruction-period duration. A scalar
|
|
99
|
+
applies to every block; a per-task list gives each block its own.
|
|
100
|
+
task_dur (float or list): Task duration. A scalar applies to every block;
|
|
101
|
+
a per-task list lets a run mix blocks of different lengths
|
|
102
|
+
(e.g. task_dur=[30, 30, 70]).
|
|
103
|
+
run_time (float): If set, the last block's end_time is extended to this,
|
|
104
|
+
so the run lasts run_time (captures overhang from the final task).
|
|
105
|
+
keep_in_middle (list): Task names to keep away from the first/last block
|
|
106
|
+
(passed to shuffle_rows).
|
|
107
|
+
shuffle (bool): If False, the blocks are kept in the order given in
|
|
108
|
+
task_list (for designs that need a fixed block order).
|
|
109
|
+
exp_dir (str, Path): Experiment directory, used to load the task table.
|
|
110
|
+
task_modules (list): const.task_modules, whose task tables are read too (see ut.get_task_table).
|
|
111
|
+
|
|
112
|
+
Any list passed for instruction_dur/task_dur must have one value per task.
|
|
113
|
+
The per-task durations are stored as columns and travel with their task
|
|
114
|
+
through the row shuffle; blocks are then laid out end-to-end by their own
|
|
115
|
+
duration.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
pd.DataFrame: the run file (one row per block, with start/end times).
|
|
119
|
+
"""
|
|
120
|
+
task_table = ut.get_task_table(exp_dir, task_modules)
|
|
121
|
+
# Get rows of the task_table corresponding to the task_list
|
|
122
|
+
indx = [np.where(task_table['name']==t)[0][0] for t in task_list]
|
|
123
|
+
n = len(task_list)
|
|
124
|
+
# A single duration applies to every task; a list gives per-task durations.
|
|
125
|
+
if not isinstance(instruction_dur, (list, tuple)):
|
|
126
|
+
instruction_dur = [instruction_dur] * n
|
|
127
|
+
if not isinstance(task_dur, (list, tuple)):
|
|
128
|
+
task_dur = [task_dur] * n
|
|
129
|
+
R = {'task_name':task_list,
|
|
130
|
+
'task_code':task_table['code'].iloc[indx],
|
|
131
|
+
'task_file':tfiles,
|
|
132
|
+
'instruction_dur':instruction_dur,
|
|
133
|
+
'task_dur':task_dur}
|
|
134
|
+
R = pd.DataFrame(R)
|
|
135
|
+
if shuffle:
|
|
136
|
+
R = shuffle_rows(R, keep_in_middle=keep_in_middle)
|
|
137
|
+
R = add_start_end_times(R, offset, run_time=run_time)
|
|
138
|
+
return R
|
|
139
|
+
|
|
140
|
+
def get_task_class(name, exp_dir=None, task_modules=None):
|
|
141
|
+
"""Creates an object of the task class based on the task name
|
|
142
|
+
Args:
|
|
143
|
+
name (str): name of the task
|
|
144
|
+
exp_dir (str, path, optional): path to the experiment directory
|
|
145
|
+
task_modules (list, optional): const.task_modules, whose task tables are read too (see ut.get_task_table)
|
|
146
|
+
Returns:
|
|
147
|
+
class_name (str): class name for task
|
|
148
|
+
"""
|
|
149
|
+
task_table = ut.get_task_table(exp_dir, task_modules)
|
|
150
|
+
index = np.where(task_table['name']==name)[0][0]
|
|
151
|
+
class_name = task_table.iloc[index]['task_class']
|
|
152
|
+
return class_name
|
|
153
|
+
|
|
154
|
+
class TaskFile():
|
|
155
|
+
def __init__(self, const) :
|
|
156
|
+
""" The TaskFile class is class for creating TaskFile files for different tasks
|
|
157
|
+
Args:
|
|
158
|
+
const: module for constants
|
|
159
|
+
"""
|
|
160
|
+
self.const = const
|
|
161
|
+
self.exp_name = const.exp_name
|
|
162
|
+
self.task_dir = const.task_dir
|
|
163
|
+
self.stim_dir = getattr(const, 'stim_dir', None)
|
|
164
|
+
self.matching_stimuli = True # whether the stimuli are matching between the control and active condition or not
|
|
165
|
+
self.half_assigned = False # whether the stimuli have assigne halves or not (for assigning different stimuli to different participants)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class NBack(TaskFile): # with the 5 stimuli used here only 1-back/2-back/3-back is safe and tested
|
|
169
|
+
def __init__(self, const):
|
|
170
|
+
super().__init__(const)
|
|
171
|
+
self.name = 'n_back'
|
|
172
|
+
|
|
173
|
+
def make_task_file(self,
|
|
174
|
+
hand = 'right',
|
|
175
|
+
responses = [1,2], # 1 = match, 2 = no match
|
|
176
|
+
task_dur = 30,
|
|
177
|
+
trial_dur = 2,
|
|
178
|
+
iti_dur = 0.5,
|
|
179
|
+
stim_width = 18.4,
|
|
180
|
+
n_back = 2, # number of items back a match refers to (2 = classic 2-back)
|
|
181
|
+
stim = ['9.jpg','11.jpg','18.jpg','28.jpg'],
|
|
182
|
+
file_name = None ):
|
|
183
|
+
"""
|
|
184
|
+
Create an n-back working-memory task file.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
hand (str): Hand used for response ('right' or 'left').
|
|
188
|
+
responses (list): Response keys for [match, no-match].
|
|
189
|
+
task_dur (float): Total task duration in seconds.
|
|
190
|
+
trial_dur (float): Duration each stimulus is displayed in seconds.
|
|
191
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
192
|
+
stim_width (float): Width of the pictures / clips in degrees of visual angle (height keeps the aspect ratio).
|
|
193
|
+
n_back (int): How many items back a match refers to (2 = classic 2-back).
|
|
194
|
+
stim (list): List of stimulus image filenames to draw from.
|
|
195
|
+
file_name (str): Name of the file to save the task data.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
199
|
+
"""
|
|
200
|
+
n_trials = int(np.floor(task_dur / (trial_dur+iti_dur)))
|
|
201
|
+
trial_info = []
|
|
202
|
+
|
|
203
|
+
prev_stim = ['x'] * n_back # sliding window of the last n_back stimuli (index n_back-1 = the n-back item)
|
|
204
|
+
t = 0
|
|
205
|
+
for n in range(n_trials):
|
|
206
|
+
trial = {}
|
|
207
|
+
trial['trial_num'] = n
|
|
208
|
+
trial['hand'] = hand
|
|
209
|
+
trial['trial_dur'] = trial_dur
|
|
210
|
+
trial['iti_dur'] = iti_dur
|
|
211
|
+
trial['stim_width'] = stim_width
|
|
212
|
+
# The n-back level is recorded once, as the modeling-level condition
|
|
213
|
+
# (e.g. '2-back'). n_back itself is only a generation parameter, so it
|
|
214
|
+
# is not duplicated as its own column - the runtime derives n from here.
|
|
215
|
+
trial['condition'] = f"{n_back}-back"
|
|
216
|
+
trial['display_trial_feedback'] = True
|
|
217
|
+
trial['key_match'] = responses[0]
|
|
218
|
+
trial['key_nomatch'] = responses[1]
|
|
219
|
+
# Determine if this should be an n-back repetition trial
|
|
220
|
+
if n < n_back:
|
|
221
|
+
trial['trial_type'] = 0
|
|
222
|
+
else:
|
|
223
|
+
trial['trial_type'] = np.random.randint(0,2)
|
|
224
|
+
# Now choose the stimulus accordingly: avoid any reps within the window
|
|
225
|
+
if trial['trial_type']==0:
|
|
226
|
+
trial['stim'] = prev_stim[n_back-1]
|
|
227
|
+
while trial['stim'] in prev_stim:
|
|
228
|
+
trial['stim'] = stim[np.random.randint(0,len(stim))]
|
|
229
|
+
else:
|
|
230
|
+
trial['stim'] = prev_stim[n_back-1]
|
|
231
|
+
|
|
232
|
+
trial['display_trial_feedback'] = True
|
|
233
|
+
trial['start_time'] = t
|
|
234
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
235
|
+
trial_info.append(trial)
|
|
236
|
+
|
|
237
|
+
# Update for next trial: slide the window, newest stimulus first
|
|
238
|
+
t= trial['end_time']
|
|
239
|
+
prev_stim = [trial['stim']] + prev_stim[:-1]
|
|
240
|
+
|
|
241
|
+
trial_info = pd.DataFrame(trial_info)
|
|
242
|
+
if file_name is not None:
|
|
243
|
+
trial_info.to_csv(self.task_dir / self.name / file_name,sep='\t',index=False)
|
|
244
|
+
return trial_info
|
|
245
|
+
|
|
246
|
+
class Rest(TaskFile):
|
|
247
|
+
def __init__(self, const):
|
|
248
|
+
super().__init__(const)
|
|
249
|
+
self.name = 'rest'
|
|
250
|
+
|
|
251
|
+
def make_task_file(self,
|
|
252
|
+
task_dur = 30,
|
|
253
|
+
file_name = None):
|
|
254
|
+
"""
|
|
255
|
+
Create a rest task file (single fixation block, no stimuli or response).
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
task_dur (float): Total duration of the rest block in seconds.
|
|
259
|
+
file_name (str): Name of the file to save the task data.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
263
|
+
"""
|
|
264
|
+
trial = {}
|
|
265
|
+
trial['trial_num'] = [0]
|
|
266
|
+
trial['trial_dur'] = [task_dur]
|
|
267
|
+
trial['start_time'] = [0]
|
|
268
|
+
trial['end_time'] = [task_dur]
|
|
269
|
+
trial_info = pd.DataFrame(trial)
|
|
270
|
+
if file_name is not None:
|
|
271
|
+
trial_info.to_csv(self.task_dir / self.name / file_name,sep='\t',index=False)
|
|
272
|
+
return trial_info
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class VerbGeneration(TaskFile):
|
|
276
|
+
def __init__(self, const):
|
|
277
|
+
super().__init__(const)
|
|
278
|
+
self.name = 'verb_generation'
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def make_task_file(self,
|
|
282
|
+
condition = ['read', 'generate'],
|
|
283
|
+
task_dur = 30,
|
|
284
|
+
trial_dur = 2,
|
|
285
|
+
iti_dur = 0.5,
|
|
286
|
+
order = 'blocked',
|
|
287
|
+
file_name = None,
|
|
288
|
+
stim_file = None,
|
|
289
|
+
text_height=2):
|
|
290
|
+
"""
|
|
291
|
+
Create a verb-generation task file.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
condition (str or list): Which condition(s) to run. A single value
|
|
295
|
+
('read' or 'generate') fills the whole block with one condition.
|
|
296
|
+
A list (e.g. ['read', 'generate']) mixes conditions within the
|
|
297
|
+
block.
|
|
298
|
+
task_dur (float): Total task duration in seconds.
|
|
299
|
+
trial_dur (float): Duration each word is displayed in seconds.
|
|
300
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
301
|
+
order (str): How to arrange multiple conditions across trials:
|
|
302
|
+
'blocked' (default) runs each condition in a contiguous chunk in
|
|
303
|
+
the given order; 'interleaved' cycles through them trial by trial;
|
|
304
|
+
'random' assigns a balanced set in random order. Ignored for a
|
|
305
|
+
single condition.
|
|
306
|
+
file_name (str): Name of the file to save the task data.
|
|
307
|
+
text_height (float): Height of the task text in degrees of visual angle.
|
|
308
|
+
stim_file (str): Optional path to a custom word-list CSV. Defaults to
|
|
309
|
+
the packaged verb_generation.csv.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
313
|
+
"""
|
|
314
|
+
conditions = [condition] if isinstance(condition, str) else list(condition)
|
|
315
|
+
for c in conditions:
|
|
316
|
+
if c not in ('read', 'generate'):
|
|
317
|
+
raise ValueError(f"VerbGeneration: condition must be 'read' or 'generate', got {c!r}")
|
|
318
|
+
|
|
319
|
+
n_trials = int(np.floor(task_dur / (trial_dur+iti_dur)))
|
|
320
|
+
|
|
321
|
+
# Assign a condition to each trial.
|
|
322
|
+
if len(conditions) == 1:
|
|
323
|
+
trial_conditions = [conditions[0]] * n_trials
|
|
324
|
+
elif order == 'interleaved':
|
|
325
|
+
trial_conditions = [conditions[i % len(conditions)] for i in range(n_trials)]
|
|
326
|
+
elif order == 'random':
|
|
327
|
+
balanced = (conditions * (n_trials // len(conditions) + 1))[:n_trials]
|
|
328
|
+
trial_conditions = [str(c) for c in np.random.permutation(balanced)]
|
|
329
|
+
elif order == 'blocked':
|
|
330
|
+
per = n_trials // len(conditions)
|
|
331
|
+
trial_conditions = []
|
|
332
|
+
for i, c in enumerate(conditions):
|
|
333
|
+
count = per if i < len(conditions) - 1 else n_trials - per * (len(conditions) - 1)
|
|
334
|
+
trial_conditions += [c] * count
|
|
335
|
+
else:
|
|
336
|
+
raise ValueError(f"VerbGeneration: order must be 'blocked', 'interleaved' or 'random', got {order!r}")
|
|
337
|
+
|
|
338
|
+
trial_info = []
|
|
339
|
+
|
|
340
|
+
if stim_file:
|
|
341
|
+
stim = pd.read_csv(stim_file)
|
|
342
|
+
else:
|
|
343
|
+
stim = pd.read_csv(ut.find_stim(self.const, 'verb_generation', 'verb_generation.csv'))
|
|
344
|
+
|
|
345
|
+
stim = stim.sample(frac=1).reset_index(drop=True)
|
|
346
|
+
|
|
347
|
+
t = 0
|
|
348
|
+
|
|
349
|
+
for n in range(n_trials):
|
|
350
|
+
selected_stim = stim.iloc[n]['word']
|
|
351
|
+
trial = {}
|
|
352
|
+
trial['trial_num'] = n
|
|
353
|
+
trial['text_height'] = text_height
|
|
354
|
+
trial['condition'] = trial_conditions[n]
|
|
355
|
+
trial['trial_dur'] = trial_dur
|
|
356
|
+
trial['iti_dur'] = iti_dur
|
|
357
|
+
trial['start_time'] = t
|
|
358
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
359
|
+
trial['stim'] = selected_stim
|
|
360
|
+
trial_info.append(trial)
|
|
361
|
+
|
|
362
|
+
# Update for next trial:
|
|
363
|
+
t= trial['end_time']
|
|
364
|
+
|
|
365
|
+
trial_info = pd.DataFrame(trial_info)
|
|
366
|
+
if file_name is not None:
|
|
367
|
+
ut.dircheck(self.task_dir / self.name)
|
|
368
|
+
trial_info.to_csv(self.task_dir / self.name / file_name,sep='\t',index=False)
|
|
369
|
+
|
|
370
|
+
return trial_info
|
|
371
|
+
|
|
372
|
+
class TongueMovement(TaskFile):
|
|
373
|
+
def __init__(self, const):
|
|
374
|
+
super().__init__(const)
|
|
375
|
+
self.name = 'tongue_movement'
|
|
376
|
+
|
|
377
|
+
def make_task_file(self,
|
|
378
|
+
task_dur = 30,
|
|
379
|
+
trial_dur = 1,
|
|
380
|
+
iti_dur = 0,
|
|
381
|
+
file_name = None):
|
|
382
|
+
"""
|
|
383
|
+
Create a tongue-movement task file.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
task_dur (float): Total task duration in seconds.
|
|
387
|
+
trial_dur (float): Duration of each tongue-movement cycle in seconds.
|
|
388
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
389
|
+
file_name (str): Name of the file to save the task data.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
393
|
+
"""
|
|
394
|
+
n_trials = int(np.floor(task_dur / (trial_dur+iti_dur)))
|
|
395
|
+
trial_info = []
|
|
396
|
+
|
|
397
|
+
t = 0
|
|
398
|
+
|
|
399
|
+
for n in range(n_trials):
|
|
400
|
+
trial = {}
|
|
401
|
+
trial['trial_num'] = n
|
|
402
|
+
trial['trial_dur'] = trial_dur
|
|
403
|
+
trial['iti_dur'] = iti_dur
|
|
404
|
+
# Alternate between 'right' and 'left' for each trial
|
|
405
|
+
trial['trial_type'] = 'right' if n % 2 == 0 else 'left'
|
|
406
|
+
trial['start_time'] = t
|
|
407
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
408
|
+
trial_info.append(trial)
|
|
409
|
+
|
|
410
|
+
# Update for next trial:
|
|
411
|
+
t = trial['end_time']
|
|
412
|
+
|
|
413
|
+
trial_info = pd.DataFrame(trial_info)
|
|
414
|
+
if file_name is not None:
|
|
415
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
416
|
+
return trial_info
|
|
417
|
+
|
|
418
|
+
class MotorLocalizer(TaskFile):
|
|
419
|
+
def __init__(self, const):
|
|
420
|
+
super().__init__(const)
|
|
421
|
+
self.name = 'motor_localizer'
|
|
422
|
+
|
|
423
|
+
def make_task_file(self,
|
|
424
|
+
task_dur=30,
|
|
425
|
+
trial_dur=1,
|
|
426
|
+
condition=['hand', 'foot', 'tongue'],
|
|
427
|
+
file_name=None,
|
|
428
|
+
text_height=1.5,
|
|
429
|
+
label_y=6):
|
|
430
|
+
"""
|
|
431
|
+
Create a motor-localizer task file. The block is split into one equal
|
|
432
|
+
segment per condition (segment_dur = task_dur / len(condition), e.g.
|
|
433
|
+
10 s each for three conditions in a 30 s block) and the condition order
|
|
434
|
+
is RANDOMISED for the block. Within a condition's segment the circle
|
|
435
|
+
toggles present (trial_type=1) / absent (trial_type=0) every trial_dur
|
|
436
|
+
seconds, which paces the movement.
|
|
437
|
+
|
|
438
|
+
What circle-present vs circle-absent means (e.g. move right vs move
|
|
439
|
+
left, or move vs hold) is decided per experiment and explained to the
|
|
440
|
+
participant in training -- it is deliberately NOT encoded here. This is
|
|
441
|
+
only the default layout; the runtime renders whatever the file
|
|
442
|
+
specifies, so a hand-written file can use any condition order, segment
|
|
443
|
+
length or on/off pattern.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
task_dur (float): Total block duration in seconds.
|
|
447
|
+
trial_dur (float): Circle on/off toggle interval within a segment (seconds).
|
|
448
|
+
condition (str or list): Condition label(s), e.g. body parts; each gets an equal, randomly ordered share of the block.
|
|
449
|
+
file_name (str): Name of the file to save the task data.
|
|
450
|
+
text_height (float): Height of the task text in degrees of visual angle.
|
|
451
|
+
label_y (float): Height of the condition label above the centre, in degrees of visual angle.
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
455
|
+
"""
|
|
456
|
+
order = [condition] if isinstance(condition, str) else list(condition)
|
|
457
|
+
random.shuffle(order) # random condition order for this block
|
|
458
|
+
segment_dur = task_dur / len(order) # equal time per condition
|
|
459
|
+
n_phases = int(round(segment_dur / trial_dur)) # circle toggles within each segment
|
|
460
|
+
|
|
461
|
+
trial_info = []
|
|
462
|
+
t = 0
|
|
463
|
+
n = 0
|
|
464
|
+
for cond in order:
|
|
465
|
+
for p in range(n_phases):
|
|
466
|
+
trial = {}
|
|
467
|
+
trial['trial_num'] = n
|
|
468
|
+
trial['text_height'] = text_height
|
|
469
|
+
trial['label_y'] = label_y
|
|
470
|
+
trial['condition'] = cond
|
|
471
|
+
trial['trial_type'] = 1 - (p % 2) # 1 = circle present, 0 = circle absent
|
|
472
|
+
trial['trial_dur'] = trial_dur
|
|
473
|
+
trial['start_time'] = t
|
|
474
|
+
trial['end_time'] = t + trial_dur
|
|
475
|
+
trial_info.append(trial)
|
|
476
|
+
t = trial['end_time']
|
|
477
|
+
n += 1
|
|
478
|
+
|
|
479
|
+
trial_info = pd.DataFrame(trial_info)
|
|
480
|
+
if file_name is not None:
|
|
481
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
482
|
+
return trial_info
|
|
483
|
+
|
|
484
|
+
class AuditoryNarrative(TaskFile):
|
|
485
|
+
def __init__(self, const):
|
|
486
|
+
super().__init__(const)
|
|
487
|
+
self.name = 'auditory_narrative'
|
|
488
|
+
|
|
489
|
+
def make_task_file(self,
|
|
490
|
+
task_dur=30,
|
|
491
|
+
trial_dur=30,
|
|
492
|
+
iti_dur=0,
|
|
493
|
+
file_name=None,
|
|
494
|
+
run_number=None):
|
|
495
|
+
"""
|
|
496
|
+
Create an auditory-narrative task file. Each run plays a distinct
|
|
497
|
+
narrative clip (narrative_NN.wav) selected by run_number.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
task_dur (float): Total task duration in seconds.
|
|
501
|
+
trial_dur (float): Duration of each audio clip in seconds.
|
|
502
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
503
|
+
file_name (str): Name of the file to save the task data.
|
|
504
|
+
run_number (int): Run number, used to select which audio clip to play.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
508
|
+
|
|
509
|
+
Raises:
|
|
510
|
+
ValueError: If run_number is None or exceeds the number of available
|
|
511
|
+
narrative clips.
|
|
512
|
+
"""
|
|
513
|
+
n_trials = int(np.floor(task_dur / (trial_dur + iti_dur)))
|
|
514
|
+
trial_info = []
|
|
515
|
+
|
|
516
|
+
# Each run uses a distinct narrative (narrative_NN.wav) - novelty matters error if no enough files
|
|
517
|
+
available = sorted(ut.find_stim_dir(self.const, self.name).glob('narrative_[0-9][0-9].wav'))
|
|
518
|
+
if run_number is None or run_number > len(available):
|
|
519
|
+
raise ValueError(
|
|
520
|
+
f"AuditoryNarrative: only {len(available)} narratives available; "
|
|
521
|
+
f"cannot generate run {run_number}. Add more narrative_NN.wav files "
|
|
522
|
+
f"or reduce the number of runs.")
|
|
523
|
+
|
|
524
|
+
t = 0
|
|
525
|
+
|
|
526
|
+
for n in range(n_trials):
|
|
527
|
+
trial = {}
|
|
528
|
+
trial['trial_num'] = n
|
|
529
|
+
trial['trial_dur'] = trial_dur
|
|
530
|
+
trial['iti_dur'] = iti_dur
|
|
531
|
+
trial['stim'] = f'narrative_{run_number:02d}.wav'
|
|
532
|
+
trial['start_time'] = t
|
|
533
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
534
|
+
trial_info.append(trial)
|
|
535
|
+
|
|
536
|
+
# Update for next trial:
|
|
537
|
+
t = trial['end_time']
|
|
538
|
+
|
|
539
|
+
trial_info = pd.DataFrame(trial_info)
|
|
540
|
+
if file_name is not None:
|
|
541
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
542
|
+
|
|
543
|
+
return trial_info
|
|
544
|
+
|
|
545
|
+
class SpatialNavigation(TaskFile):
|
|
546
|
+
def __init__(self, const):
|
|
547
|
+
super().__init__(const)
|
|
548
|
+
self.name = 'spatial_navigation'
|
|
549
|
+
self.location_pairs = [('FRONT-DOOR', 'LIVING-ROOM'), ('WASHROOM', 'LIVING-ROOM'), ('BEDROOM', 'LIVING-ROOM'),
|
|
550
|
+
('KITCHEN', 'LIVING-ROOM'), ('FRONT-DOOR', 'WASHROOM'), ('BEDROOM', 'FRONT-DOOR'),
|
|
551
|
+
('KITCHEN', 'FRONT-DOOR'), ('KITCHEN', 'BEDROOM'), ('KITCHEN', 'WASHROOM'),
|
|
552
|
+
('BEDROOM', 'WASHROOM')]
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def make_task_file(self,
|
|
556
|
+
task_dur=30,
|
|
557
|
+
trial_dur=30,
|
|
558
|
+
iti_dur=0,
|
|
559
|
+
file_name=None,
|
|
560
|
+
run_number=None):
|
|
561
|
+
"""
|
|
562
|
+
Create a spatial-navigation task file (imagined navigation between two
|
|
563
|
+
remembered locations).
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
task_dur (float): Total task duration in seconds.
|
|
567
|
+
trial_dur (float): Duration of the imagination period in seconds.
|
|
568
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
569
|
+
file_name (str): Name of the file to save the task data.
|
|
570
|
+
run_number (int): Run number, used to select which location pair to use.
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
574
|
+
"""
|
|
575
|
+
n_trials = int(np.floor(task_dur / (trial_dur + iti_dur)))
|
|
576
|
+
trial_info = []
|
|
577
|
+
|
|
578
|
+
t = 0
|
|
579
|
+
|
|
580
|
+
for n in range(n_trials):
|
|
581
|
+
trial = {}
|
|
582
|
+
#loc1, loc2 = self.location_pairs[run_number - 1]
|
|
583
|
+
loc1, loc2 = self.location_pairs[(run_number - 1) % len(self.location_pairs)]
|
|
584
|
+
trial['trial_num'] = n
|
|
585
|
+
trial['trial_dur'] = trial_dur
|
|
586
|
+
trial['iti_dur'] = iti_dur
|
|
587
|
+
trial['start_time'] = t
|
|
588
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
589
|
+
trial['location_1'] = loc1
|
|
590
|
+
trial['location_2'] = loc2
|
|
591
|
+
trial_info.append(trial)
|
|
592
|
+
t = trial['end_time']
|
|
593
|
+
|
|
594
|
+
trial_info = pd.DataFrame(trial_info)
|
|
595
|
+
if file_name is not None:
|
|
596
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
597
|
+
|
|
598
|
+
return trial_info
|
|
599
|
+
|
|
600
|
+
class TheoryOfMind(TaskFile):
|
|
601
|
+
def __init__(self, const):
|
|
602
|
+
super().__init__(const)
|
|
603
|
+
self.name = 'theory_of_mind'
|
|
604
|
+
self.matching_stimuli = False # stimuli for active condition (belief) are different from stimuli for passive condition (photo)
|
|
605
|
+
|
|
606
|
+
def make_task_file(self, hand='right',
|
|
607
|
+
responses = [1,2], # 1 = True, 2 = False
|
|
608
|
+
run_number=None,
|
|
609
|
+
task_dur=30,
|
|
610
|
+
trial_dur=14,
|
|
611
|
+
iti_dur=1,
|
|
612
|
+
story_dur=10,
|
|
613
|
+
question_dur=4,
|
|
614
|
+
text_height=1.25,
|
|
615
|
+
wrap_width=25,
|
|
616
|
+
file_name=None,
|
|
617
|
+
stim_file=None,
|
|
618
|
+
condition=None):
|
|
619
|
+
"""
|
|
620
|
+
Create a theory-of-mind task file (story followed by a true/false statement).
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
hand (str): Hand used for response ('right' or 'left').
|
|
624
|
+
responses (list): Response keys for [True, False].
|
|
625
|
+
run_number (int): Run number, used to select stimuli for that run.
|
|
626
|
+
task_dur (float): Total task duration in seconds.
|
|
627
|
+
trial_dur (float): Total duration of each trial in seconds.
|
|
628
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
629
|
+
story_dur (float): Duration the story is displayed in seconds.
|
|
630
|
+
question_dur (float): Duration the question is displayed in seconds.
|
|
631
|
+
text_height (float): Height of the story/question text in degrees of visual angle.
|
|
632
|
+
wrap_width (float): Width the story/question text wraps at, in degrees of visual angle.
|
|
633
|
+
file_name (str): Name of the file to save the task data.
|
|
634
|
+
stim_file (str): Optional path to a custom stimulus CSV.
|
|
635
|
+
condition (str): If set, only trials of this condition are included
|
|
636
|
+
('belief' or 'photo').
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
640
|
+
"""
|
|
641
|
+
# Count number of trials based on timing; may be overridden below when an
|
|
642
|
+
# explicit stimulus list is provided (so distribution, not timing, sets
|
|
643
|
+
# the exact trial count).
|
|
644
|
+
n_trials = int(np.floor(task_dur / (trial_dur + iti_dur)))
|
|
645
|
+
trial_info = []
|
|
646
|
+
t = 0
|
|
647
|
+
|
|
648
|
+
if stim_file:
|
|
649
|
+
stim = pd.read_csv(stim_file)
|
|
650
|
+
else:
|
|
651
|
+
stim = pd.read_csv(ut.find_stim(self.const, 'theory_of_mind', 'theory_of_mind.csv'))
|
|
652
|
+
|
|
653
|
+
if condition:
|
|
654
|
+
stim = stim[stim['condition'] == condition]
|
|
655
|
+
else:
|
|
656
|
+
stim = stim.loc[
|
|
657
|
+
~stim['condition'].str.contains('practice', na=False)
|
|
658
|
+
& (stim['condition'].astype(str).str.lower() != 'exclude')
|
|
659
|
+
]
|
|
660
|
+
|
|
661
|
+
# Ignore stim_list and stimulus_seed: selection is driven entirely by
|
|
662
|
+
# the provided stim_file (if any) or by run_number-based slicing.
|
|
663
|
+
start_row = (run_number - 1) * n_trials
|
|
664
|
+
end_row = run_number * n_trials - 1
|
|
665
|
+
stim = stim.iloc[start_row:end_row + 1].reset_index(drop=True)
|
|
666
|
+
|
|
667
|
+
n_actual = min(n_trials, len(stim))
|
|
668
|
+
for n in range(n_actual):
|
|
669
|
+
trial = {}
|
|
670
|
+
trial['key_true'] = responses[0]
|
|
671
|
+
trial['key_false'] = responses[1]
|
|
672
|
+
if str(stim['answer'][n]) == 'True':
|
|
673
|
+
trial['trial_type'] = 1
|
|
674
|
+
else:
|
|
675
|
+
trial['trial_type'] = 0
|
|
676
|
+
trial['trial_num'] = n
|
|
677
|
+
trial['hand'] = hand
|
|
678
|
+
trial['trial_dur'] = trial_dur
|
|
679
|
+
trial['iti_dur'] = iti_dur
|
|
680
|
+
trial['story'] = stim['story'][n]
|
|
681
|
+
trial['question'] = stim['question'][n]
|
|
682
|
+
trial['condition'] = stim['condition'][n]
|
|
683
|
+
trial['story_dur'] = story_dur
|
|
684
|
+
trial['question_dur'] = question_dur
|
|
685
|
+
trial['text_height'] = text_height
|
|
686
|
+
trial['wrap_width'] = wrap_width
|
|
687
|
+
trial['display_trial_feedback'] = True
|
|
688
|
+
trial['start_time'] = t
|
|
689
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
690
|
+
trial_info.append(trial)
|
|
691
|
+
|
|
692
|
+
# Update for next trial:
|
|
693
|
+
t = trial['end_time']
|
|
694
|
+
|
|
695
|
+
trial_info = pd.DataFrame(trial_info)
|
|
696
|
+
if file_name is not None:
|
|
697
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
698
|
+
return trial_info
|
|
699
|
+
|
|
700
|
+
class PassageListening(TaskFile):
|
|
701
|
+
def __init__(self, const):
|
|
702
|
+
super().__init__(const)
|
|
703
|
+
self.name = 'passage_listening'
|
|
704
|
+
|
|
705
|
+
def make_task_file(self,
|
|
706
|
+
run_number,
|
|
707
|
+
condition='intact',
|
|
708
|
+
task_dur=30,
|
|
709
|
+
trial_dur=14.5,
|
|
710
|
+
iti_dur=0.5,
|
|
711
|
+
file_name=None,
|
|
712
|
+
stim_file=None,):
|
|
713
|
+
"""
|
|
714
|
+
Create a passage-listening task file (intact vs degraded speech).
|
|
715
|
+
|
|
716
|
+
Args:
|
|
717
|
+
run_number (int): Run number, used to select which passages to play.
|
|
718
|
+
condition (str): Which condition to include ('intact' or 'degraded').
|
|
719
|
+
task_dur (float): Total task duration in seconds.
|
|
720
|
+
trial_dur (float): Duration of each passage in seconds.
|
|
721
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
722
|
+
file_name (str): Name of the file to save the task data.
|
|
723
|
+
stim_file (str): Optional path to a custom stimulus CSV.
|
|
724
|
+
|
|
725
|
+
Returns:
|
|
726
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
727
|
+
"""
|
|
728
|
+
n_trials = int(np.floor(task_dur / (trial_dur + iti_dur)))
|
|
729
|
+
|
|
730
|
+
# Load the audio/condition table and keep only the requested condition
|
|
731
|
+
stim = pd.read_csv(ut.find_stim(self.const, self.name, stim_file or f'{self.name}.csv'))
|
|
732
|
+
valid = sorted (stim['condition'].unique()) # check if the condition is there
|
|
733
|
+
if condition not in valid:
|
|
734
|
+
raise ValueError(f"PassageListening: unknown condition {condition!r} (expected one of {valid})")
|
|
735
|
+
stim = stim[stim['condition'] == condition].reset_index(drop=True)
|
|
736
|
+
# Wrap around the available passages if more runs are requested than there are.
|
|
737
|
+
total = len(stim)
|
|
738
|
+
if (run_number - 1) * n_trials >= total:
|
|
739
|
+
print(f"Warning: PassageListening only has {total} '{condition}' passages; "
|
|
740
|
+
f"run {run_number} wraps around and repeats stimuli.")
|
|
741
|
+
idx = [((run_number - 1) * n_trials + k) % total for k in range(n_trials)]
|
|
742
|
+
stim = stim.iloc[idx].reset_index(drop=True)
|
|
743
|
+
|
|
744
|
+
trial_info = []
|
|
745
|
+
t = 0
|
|
746
|
+
for n in range(len(stim)):
|
|
747
|
+
trial = {}
|
|
748
|
+
trial['trial_num'] = n
|
|
749
|
+
trial['trial_dur'] = trial_dur
|
|
750
|
+
trial['iti_dur'] = iti_dur
|
|
751
|
+
trial['condition'] = stim['condition'][n]
|
|
752
|
+
trial['stim'] = stim['audio'][n]
|
|
753
|
+
trial['start_time'] = t
|
|
754
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
755
|
+
trial_info.append(trial)
|
|
756
|
+
|
|
757
|
+
# Update for next trial:
|
|
758
|
+
t = trial['end_time']
|
|
759
|
+
|
|
760
|
+
trial_info = pd.DataFrame(trial_info)
|
|
761
|
+
if file_name is not None:
|
|
762
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
763
|
+
|
|
764
|
+
return trial_info
|
|
765
|
+
|
|
766
|
+
class ActionObservation(TaskFile):
|
|
767
|
+
def __init__(self, const):
|
|
768
|
+
super().__init__(const)
|
|
769
|
+
self.name = 'action_observation'
|
|
770
|
+
self.matching_stimuli = False # stimuli for active condition (knot tying) are different from stimuli for passive condition (knot watching)
|
|
771
|
+
|
|
772
|
+
# Medium/bad knot vids
|
|
773
|
+
# self.knot_names = [
|
|
774
|
+
# 'Ampere', 'Arbor', 'Baron', 'Belfry', 'Bramble', 'Chamois', 'Coffer',
|
|
775
|
+
# 'Farthing', 'Fissure', 'Gentry', 'Henchman', 'Magnate', 'Perry', 'Phial', 'Polka',
|
|
776
|
+
# 'Rosin', 'Shilling', 'Simper', 'Spangle', 'Squire', 'Vestment', 'Wampum', 'Wicket'
|
|
777
|
+
# ]
|
|
778
|
+
|
|
779
|
+
# good knot vids
|
|
780
|
+
self.knot_names = ['Adage',
|
|
781
|
+
'Brigand', 'Brocade', 'Casement', 'Cornice',\
|
|
782
|
+
'Flora', 'Frontage', 'Gadfly', 'Garret', \
|
|
783
|
+
'Mutton','Placard', 'Purser']
|
|
784
|
+
|
|
785
|
+
def make_task_file(self,
|
|
786
|
+
run_number = None,
|
|
787
|
+
task_dur=30,
|
|
788
|
+
trial_dur=14,
|
|
789
|
+
iti_dur=1,
|
|
790
|
+
file_name=None,
|
|
791
|
+
stim_width=18.4):
|
|
792
|
+
"""
|
|
793
|
+
Create an action-observation task file (knot-tying videos).
|
|
794
|
+
|
|
795
|
+
Args:
|
|
796
|
+
run_number (int): Run number, used to select which knot stimulus to show.
|
|
797
|
+
task_dur (float): Total task duration in seconds.
|
|
798
|
+
trial_dur (float): Duration of each video trial in seconds.
|
|
799
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
800
|
+
file_name (str): Name of the file to save the task data.
|
|
801
|
+
stim_width (float): Width of the pictures / clips in degrees of visual angle (height keeps the aspect ratio).
|
|
802
|
+
|
|
803
|
+
Returns:
|
|
804
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
805
|
+
"""
|
|
806
|
+
n_trials = int(np.floor(task_dur / (trial_dur + iti_dur)))
|
|
807
|
+
trial_info = []
|
|
808
|
+
|
|
809
|
+
t = 0
|
|
810
|
+
|
|
811
|
+
for n in range(n_trials):
|
|
812
|
+
trial = {}
|
|
813
|
+
trial['trial_num'] = n
|
|
814
|
+
trial['stim_width'] = stim_width
|
|
815
|
+
trial['trial_dur'] = trial_dur
|
|
816
|
+
trial['iti_dur'] = iti_dur
|
|
817
|
+
knot_index = (run_number - 1) % len(self.knot_names)
|
|
818
|
+
# 'condition' is a real per-trial column, so a hand-written task file
|
|
819
|
+
# can order the conditions however it likes. The make_task_file default
|
|
820
|
+
# is the action clip first, then control.
|
|
821
|
+
if n == 0:
|
|
822
|
+
trial['condition'] = 'action'
|
|
823
|
+
trial['stim'] = f'knotAction{self.knot_names[knot_index]}.mov'
|
|
824
|
+
else:
|
|
825
|
+
trial['condition'] = 'control'
|
|
826
|
+
trial['stim'] = f'knotControl{self.knot_names[knot_index]}.mov'
|
|
827
|
+
trial['start_time'] = t
|
|
828
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
829
|
+
trial_info.append(trial)
|
|
830
|
+
t = trial['end_time']
|
|
831
|
+
|
|
832
|
+
trial_info = pd.DataFrame(trial_info)
|
|
833
|
+
if file_name is not None:
|
|
834
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
835
|
+
|
|
836
|
+
return trial_info
|
|
837
|
+
|
|
838
|
+
class DemandGrid(TaskFile):
|
|
839
|
+
def __init__(self, const):
|
|
840
|
+
super().__init__(const)
|
|
841
|
+
self.name = 'demand_grid'
|
|
842
|
+
|
|
843
|
+
def get_adjacent_positions(self,pos, grid_size):
|
|
844
|
+
"""
|
|
845
|
+
Get all adjacent positions within the grid boundaries.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
pos (tuple): Current position (x, y).
|
|
849
|
+
grid_size (tuple): Size of the grid (rows, cols).
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
list: List of adjacent positions.
|
|
853
|
+
"""
|
|
854
|
+
x, y = pos
|
|
855
|
+
adjacent = [
|
|
856
|
+
(x + dx, y + dy)
|
|
857
|
+
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]
|
|
858
|
+
if 0 <= x + dx < grid_size[0] and 0 <= y + dy < grid_size[1]
|
|
859
|
+
]
|
|
860
|
+
return adjacent
|
|
861
|
+
|
|
862
|
+
def generate_sequence(self, grid_size, num_steps, num_boxes_lit):
|
|
863
|
+
"""
|
|
864
|
+
Generate the original sequence of lit-up boxes, ensuring adjacency between boxes
|
|
865
|
+
in each step and no reuse of positions across the entire sequence.
|
|
866
|
+
|
|
867
|
+
Args:
|
|
868
|
+
grid_size (tuple): Size of the grid (rows, cols).
|
|
869
|
+
num_steps (int): Number of steps in the sequence.
|
|
870
|
+
num_boxes_lit (int): Number of boxes lit up per step.
|
|
871
|
+
|
|
872
|
+
Returns:
|
|
873
|
+
list: Original sequence of steps, where each step is a list of positions.
|
|
874
|
+
"""
|
|
875
|
+
sequence = []
|
|
876
|
+
used_positions = set() # Tracks all positions that have been used
|
|
877
|
+
|
|
878
|
+
for _ in range(num_steps):
|
|
879
|
+
step = []
|
|
880
|
+
if not sequence:
|
|
881
|
+
# First step: Randomly choose starting positions
|
|
882
|
+
while len(step) < num_boxes_lit:
|
|
883
|
+
pos = (
|
|
884
|
+
random.randint(0, grid_size[0] - 1),
|
|
885
|
+
random.randint(0, grid_size[1] - 1)
|
|
886
|
+
)
|
|
887
|
+
if pos not in used_positions:
|
|
888
|
+
step.append(pos)
|
|
889
|
+
used_positions.add(pos)
|
|
890
|
+
else:
|
|
891
|
+
# Subsequent steps: Ensure adjacency and uniqueness
|
|
892
|
+
available_positions = {
|
|
893
|
+
adj
|
|
894
|
+
for prev_step in sequence
|
|
895
|
+
for pos in prev_step
|
|
896
|
+
for adj in self.get_adjacent_positions(pos, grid_size)
|
|
897
|
+
}
|
|
898
|
+
available_positions -= used_positions # Remove already-used positions
|
|
899
|
+
|
|
900
|
+
while len(step) < num_boxes_lit:
|
|
901
|
+
if not available_positions:
|
|
902
|
+
raise ValueError("not enough valid adjacent positions available")
|
|
903
|
+
pos = random.choice(list(available_positions))
|
|
904
|
+
step.append(pos)
|
|
905
|
+
used_positions.add(pos)
|
|
906
|
+
available_positions.remove(pos) # Avoid duplicates in the current step
|
|
907
|
+
|
|
908
|
+
sequence.append(step)
|
|
909
|
+
|
|
910
|
+
return sequence
|
|
911
|
+
|
|
912
|
+
@staticmethod
|
|
913
|
+
def _count_connected_components(positions):
|
|
914
|
+
"""Count connected components in a set of grid positions (4-directional)."""
|
|
915
|
+
from collections import deque
|
|
916
|
+
if not positions:
|
|
917
|
+
return 0
|
|
918
|
+
remaining = set(positions)
|
|
919
|
+
components = 0
|
|
920
|
+
while remaining:
|
|
921
|
+
components += 1
|
|
922
|
+
start = next(iter(remaining))
|
|
923
|
+
queue = deque([start])
|
|
924
|
+
remaining.remove(start)
|
|
925
|
+
while queue:
|
|
926
|
+
x, y = queue.popleft()
|
|
927
|
+
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
|
|
928
|
+
neighbor = (x + dx, y + dy)
|
|
929
|
+
if neighbor in remaining:
|
|
930
|
+
remaining.remove(neighbor)
|
|
931
|
+
queue.append(neighbor)
|
|
932
|
+
return components
|
|
933
|
+
|
|
934
|
+
def modify_sequence(self, sequence, grid_size):
|
|
935
|
+
"""
|
|
936
|
+
Modify the original sequence to create a new sequence for comparison,
|
|
937
|
+
ensuring adjacency and uniqueness within the modified step. If a step
|
|
938
|
+
cannot be modified due to lack of valid adjacent positions, try another step.
|
|
939
|
+
|
|
940
|
+
The modified sequence is constrained to have the same number of connected
|
|
941
|
+
components as the original, so the distractor is not visually distinguishable
|
|
942
|
+
by spatial continuity alone.
|
|
943
|
+
|
|
944
|
+
Args:
|
|
945
|
+
sequence (list): Original sequence of steps.
|
|
946
|
+
grid_size (tuple): Size of the grid (rows, cols).
|
|
947
|
+
|
|
948
|
+
Returns:
|
|
949
|
+
list: Modified sequence of steps.
|
|
950
|
+
"""
|
|
951
|
+
original_positions = [pos for step in sequence for pos in step]
|
|
952
|
+
original_cc = self._count_connected_components(original_positions)
|
|
953
|
+
|
|
954
|
+
modified_sequence = sequence[:]
|
|
955
|
+
available_step_indices = list(range(len(sequence))) # List of all step indices to try
|
|
956
|
+
|
|
957
|
+
while available_step_indices:
|
|
958
|
+
random_step_index = random.choice(available_step_indices) # Choose a random step
|
|
959
|
+
original_step = sequence[random_step_index]
|
|
960
|
+
|
|
961
|
+
# Gather all used positions from the entire sequence
|
|
962
|
+
used_positions = {t for step in sequence for t in step}
|
|
963
|
+
|
|
964
|
+
# Use all remaining steps (not the step being replaced) as the adjacency
|
|
965
|
+
# source, matching the rule used by generate_sequence().
|
|
966
|
+
remaining_steps = [s for i, s in enumerate(sequence) if i != random_step_index]
|
|
967
|
+
|
|
968
|
+
new_step = []
|
|
969
|
+
try:
|
|
970
|
+
while len(new_step) < len(original_step):
|
|
971
|
+
available_positions = {
|
|
972
|
+
adj
|
|
973
|
+
for step in remaining_steps
|
|
974
|
+
for pos in step
|
|
975
|
+
for adj in self.get_adjacent_positions(pos, grid_size)
|
|
976
|
+
}
|
|
977
|
+
available_positions -= used_positions # Exclude already-used positions
|
|
978
|
+
available_positions -= set(new_step) # Exclude positions already added to the new step
|
|
979
|
+
|
|
980
|
+
if not available_positions:
|
|
981
|
+
raise ValueError("Not enough valid adjacent positions available for modification.")
|
|
982
|
+
|
|
983
|
+
# Choose a random position from the available ones
|
|
984
|
+
new_pos = random.choice(list(available_positions))
|
|
985
|
+
new_step.append(new_pos)
|
|
986
|
+
used_positions.add(new_pos) # Mark the position as used
|
|
987
|
+
|
|
988
|
+
# Check that the modified grid has the same connectivity as the original.
|
|
989
|
+
# Reject if the replacement creates extra disconnected components.
|
|
990
|
+
candidate = sequence[:]
|
|
991
|
+
candidate[random_step_index] = new_step
|
|
992
|
+
candidate_positions = [pos for step in candidate for pos in step]
|
|
993
|
+
candidate_cc = self._count_connected_components(candidate_positions)
|
|
994
|
+
|
|
995
|
+
if candidate_cc <= original_cc:
|
|
996
|
+
modified_sequence[random_step_index] = new_step
|
|
997
|
+
return modified_sequence
|
|
998
|
+
else:
|
|
999
|
+
raise ValueError("Modified sequence has more connected components than original.")
|
|
1000
|
+
|
|
1001
|
+
except ValueError:
|
|
1002
|
+
# Remove this step index from the list of available steps to try
|
|
1003
|
+
available_step_indices.remove(random_step_index)
|
|
1004
|
+
continue # Try another step
|
|
1005
|
+
|
|
1006
|
+
# If all steps fail, raise an error
|
|
1007
|
+
raise ValueError("No valid step could be modified with the given constraints.")
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def make_task_file(self,
|
|
1011
|
+
hand='right',
|
|
1012
|
+
responses=[1, 2], # 1 = Left, 2 = Right
|
|
1013
|
+
grid_size=(3, 4),
|
|
1014
|
+
num_steps=3,
|
|
1015
|
+
num_boxes_lit=2,
|
|
1016
|
+
task_dur=30,
|
|
1017
|
+
trial_dur=7,
|
|
1018
|
+
question_dur=3,
|
|
1019
|
+
sequence_dur=4,
|
|
1020
|
+
iti_dur=0.5,
|
|
1021
|
+
condition=None,
|
|
1022
|
+
file_name=None,
|
|
1023
|
+
square_size=1.5,
|
|
1024
|
+
answer_offset=5,
|
|
1025
|
+
display_feedback=True):
|
|
1026
|
+
"""
|
|
1027
|
+
Create a task file with the specified parameters.
|
|
1028
|
+
|
|
1029
|
+
Args:
|
|
1030
|
+
hand (str): Hand used for response ('right' or 'left').
|
|
1031
|
+
responses (list): Response keys for left and right.
|
|
1032
|
+
grid_size (tuple): Size of the grid (rows, cols).
|
|
1033
|
+
num_steps (int): Number of steps in the sequence.
|
|
1034
|
+
num_boxes_lit (int): Number of boxes lit up per step.
|
|
1035
|
+
task_dur (float): Total task duration in seconds.
|
|
1036
|
+
trial_dur (float): Duration of each trial.
|
|
1037
|
+
question_dur (float): Duration of the question phase.
|
|
1038
|
+
sequence_dur (float): Duration of the sequence presentation phase.
|
|
1039
|
+
iti_dur (float): Inter-trial interval duration.
|
|
1040
|
+
file_name (str): Name of the file to save the task data.
|
|
1041
|
+
square_size (float): Side of one grid square in degrees of visual angle.
|
|
1042
|
+
answer_offset (float): Horizontal distance of each answer grid's centre from the screen centre, in degrees (increase it with square_size so the grids don't overlap).
|
|
1043
|
+
display_feedback (bool): Show a check mark / cross after each response; if False, a white fixation cross.
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
1047
|
+
"""
|
|
1048
|
+
n_trials = int(task_dur / (trial_dur + iti_dur))
|
|
1049
|
+
trial_info = []
|
|
1050
|
+
current_time = 0
|
|
1051
|
+
|
|
1052
|
+
for n in range(n_trials):
|
|
1053
|
+
while True: # Retry logic
|
|
1054
|
+
try:
|
|
1055
|
+
# Generate the original sequence
|
|
1056
|
+
original_sequence = self.generate_sequence(grid_size, num_steps, num_boxes_lit)
|
|
1057
|
+
# Attempt to create a modified sequence
|
|
1058
|
+
modified_sequence = self.modify_sequence(original_sequence, grid_size=grid_size)
|
|
1059
|
+
break
|
|
1060
|
+
except ValueError:
|
|
1061
|
+
continue
|
|
1062
|
+
|
|
1063
|
+
correct_side = random.choice(['left', 'right'])
|
|
1064
|
+
trial_type = 0 if correct_side == 'left' else 1
|
|
1065
|
+
trial = {
|
|
1066
|
+
'key_left': responses[0],
|
|
1067
|
+
'key_right': responses[1],
|
|
1068
|
+
'correct_side': correct_side,
|
|
1069
|
+
'trial_type': trial_type,
|
|
1070
|
+
'trial_num': n,
|
|
1071
|
+
'hand': hand,
|
|
1072
|
+
'grid_size': grid_size,
|
|
1073
|
+
'square_size': square_size,
|
|
1074
|
+
'answer_offset': answer_offset,
|
|
1075
|
+
'num_steps': num_steps,
|
|
1076
|
+
'original_sequence': list(itertools.chain.from_iterable(original_sequence)),
|
|
1077
|
+
'modified_sequence': list(itertools.chain.from_iterable(modified_sequence)) ,
|
|
1078
|
+
**{f'original_step_{i+1}': step for i, step in enumerate(original_sequence)}, # Unpack original steps (each grid shown in the exp)
|
|
1079
|
+
'display_trial_feedback': display_feedback,
|
|
1080
|
+
'trial_dur': trial_dur,
|
|
1081
|
+
'sequence_dur': sequence_dur,
|
|
1082
|
+
'question_dur': question_dur,
|
|
1083
|
+
'iti_dur': iti_dur,
|
|
1084
|
+
'start_time': current_time,
|
|
1085
|
+
'end_time': current_time + trial_dur + iti_dur
|
|
1086
|
+
}
|
|
1087
|
+
# Optional difficulty label (e.g. 'easy'/'hard' for the MD localizer),
|
|
1088
|
+
# written only when provided so existing task files are unchanged.
|
|
1089
|
+
if condition is not None:
|
|
1090
|
+
trial['condition'] = condition
|
|
1091
|
+
trial_info.append(trial)
|
|
1092
|
+
|
|
1093
|
+
# Update for the next trial:
|
|
1094
|
+
current_time = trial['end_time']
|
|
1095
|
+
|
|
1096
|
+
trial_info = pd.DataFrame(trial_info)
|
|
1097
|
+
if file_name is not None:
|
|
1098
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
1099
|
+
|
|
1100
|
+
return trial_info
|
|
1101
|
+
|
|
1102
|
+
class Reading(TaskFile):
|
|
1103
|
+
def __init__(self, const):
|
|
1104
|
+
super().__init__(const)
|
|
1105
|
+
self.name = 'reading'
|
|
1106
|
+
|
|
1107
|
+
def make_task_file(self,
|
|
1108
|
+
run_number = None,
|
|
1109
|
+
condition = 'sentences', # 'sentence' or 'nonwords'
|
|
1110
|
+
task_dur=30,
|
|
1111
|
+
trial_dur=5.8,
|
|
1112
|
+
iti_dur=0.2,
|
|
1113
|
+
file_name=None,
|
|
1114
|
+
stim_file=None,
|
|
1115
|
+
text_height=2,
|
|
1116
|
+
stim_width=14.4):
|
|
1117
|
+
"""
|
|
1118
|
+
Create a reading task file (sentences or nonwords, shown word by word).
|
|
1119
|
+
|
|
1120
|
+
Args:
|
|
1121
|
+
run_number (int): Run number, used to select which sentences to show.
|
|
1122
|
+
condition (str): 'sentences' or 'nonwords'.
|
|
1123
|
+
task_dur (float): Total task duration in seconds.
|
|
1124
|
+
trial_dur (float): Duration of each sentence presentation in seconds.
|
|
1125
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
1126
|
+
file_name (str): Name of the file to save the task data.
|
|
1127
|
+
text_height (float): Height of the task text in degrees of visual angle.
|
|
1128
|
+
stim_width (float): Width of the pictures / clips in degrees of visual angle (height keeps the aspect ratio).
|
|
1129
|
+
stim_file (str): Optional path to a custom stimulus CSV.
|
|
1130
|
+
|
|
1131
|
+
Returns:
|
|
1132
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
1133
|
+
"""
|
|
1134
|
+
n_trials = int(np.floor(task_dur / (trial_dur + iti_dur)))
|
|
1135
|
+
trial_info = []
|
|
1136
|
+
|
|
1137
|
+
# Select the stimulus list for the requested condition (sentences vs nonwords).
|
|
1138
|
+
if stim_file:
|
|
1139
|
+
stim = pd.read_csv(stim_file)
|
|
1140
|
+
else:
|
|
1141
|
+
if condition == 'sentences':
|
|
1142
|
+
csv = 'sentences_shuffled.csv'
|
|
1143
|
+
|
|
1144
|
+
elif condition == 'nonwords':
|
|
1145
|
+
csv = 'nonwords_shuffled.csv'
|
|
1146
|
+
else:
|
|
1147
|
+
raise ValueError( F" task Reading: unknown condition {condition!r} (expected 'sentences' or 'nonwords')")
|
|
1148
|
+
stim = pd.read_csv(ut.find_stim(self.const, self.name, csv))
|
|
1149
|
+
|
|
1150
|
+
t = 0
|
|
1151
|
+
|
|
1152
|
+
for n in range(n_trials):
|
|
1153
|
+
trial = {}
|
|
1154
|
+
trial['trial_num'] = n
|
|
1155
|
+
trial['text_height'] = text_height
|
|
1156
|
+
trial['stim_width'] = stim_width
|
|
1157
|
+
trial['trial_dur'] = trial_dur
|
|
1158
|
+
trial['iti_dur'] = iti_dur
|
|
1159
|
+
trial['condition'] = condition
|
|
1160
|
+
sentence_index = (run_number - 1) * n_trials + n
|
|
1161
|
+
trial['stim'] = stim['sentence'][sentence_index]
|
|
1162
|
+
trial['start_time'] = t
|
|
1163
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
1164
|
+
trial_info.append(trial)
|
|
1165
|
+
|
|
1166
|
+
# Update for next trial:
|
|
1167
|
+
t = trial['end_time']
|
|
1168
|
+
|
|
1169
|
+
trial_info = pd.DataFrame(trial_info)
|
|
1170
|
+
if file_name is not None:
|
|
1171
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
1172
|
+
|
|
1173
|
+
return trial_info
|
|
1174
|
+
|
|
1175
|
+
class OddBall(TaskFile):
|
|
1176
|
+
def __init__(self, const):
|
|
1177
|
+
super().__init__(const)
|
|
1178
|
+
self.name = 'oddball'
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def make_task_file(self,
|
|
1182
|
+
hand = 'right',
|
|
1183
|
+
responses = [1,2], # 1,2 any press is ok
|
|
1184
|
+
task_dur=30,
|
|
1185
|
+
trial_dur=0.15,
|
|
1186
|
+
iti_dur=0.85,
|
|
1187
|
+
file_name=None,
|
|
1188
|
+
text_height=1.5):
|
|
1189
|
+
"""
|
|
1190
|
+
Create an oddball-detection task file (respond only to a red 'K').
|
|
1191
|
+
|
|
1192
|
+
Args:
|
|
1193
|
+
hand (str): Hand used for response ('right' or 'left').
|
|
1194
|
+
responses (list): Response keys.
|
|
1195
|
+
task_dur (float): Total task duration in seconds.
|
|
1196
|
+
trial_dur (float): Duration the stimulus is displayed in seconds.
|
|
1197
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
1198
|
+
file_name (str): Name of the file to save the task data.
|
|
1199
|
+
text_height (float): Height of the task text in degrees of visual angle.
|
|
1200
|
+
|
|
1201
|
+
Returns:
|
|
1202
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
1203
|
+
"""
|
|
1204
|
+
n_trials = int(np.floor(task_dur / (trial_dur + iti_dur)))
|
|
1205
|
+
trial_info = []
|
|
1206
|
+
|
|
1207
|
+
# 10% red K (targets), 10% red O, rest split between black K and black O
|
|
1208
|
+
n_red = round(n_trials * 0.1)
|
|
1209
|
+
n_black = n_trials - 2 * n_red
|
|
1210
|
+
stimuli = ['black_K'] * (n_black - n_black // 2) + ['black_O'] * (n_black // 2) + ['red_K'] * n_red + ['red_O'] * n_red
|
|
1211
|
+
|
|
1212
|
+
# shuffle stimuli
|
|
1213
|
+
random.shuffle(stimuli)
|
|
1214
|
+
|
|
1215
|
+
t = 0
|
|
1216
|
+
|
|
1217
|
+
for n in range(len(stimuli)):
|
|
1218
|
+
trial = {}
|
|
1219
|
+
trial['key_one'] = responses[0]
|
|
1220
|
+
trial['trial_num'] = n
|
|
1221
|
+
trial['text_height'] = text_height
|
|
1222
|
+
trial['hand'] = hand
|
|
1223
|
+
trial['trial_dur'] = trial_dur
|
|
1224
|
+
trial['iti_dur'] = iti_dur
|
|
1225
|
+
trial['trial_type'] = 1 if stimuli[n] == 'red_K' else 0
|
|
1226
|
+
trial['stim'] = stimuli[n]
|
|
1227
|
+
trial['start_time'] = t
|
|
1228
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
1229
|
+
trial_info.append(trial)
|
|
1230
|
+
|
|
1231
|
+
# Update for next trial:
|
|
1232
|
+
t = trial['end_time']
|
|
1233
|
+
|
|
1234
|
+
trial_info = pd.DataFrame(trial_info)
|
|
1235
|
+
if file_name is not None:
|
|
1236
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
1237
|
+
|
|
1238
|
+
return trial_info
|
|
1239
|
+
|
|
1240
|
+
class FingerSequence(TaskFile):
|
|
1241
|
+
def __init__(self, const):
|
|
1242
|
+
super().__init__(const)
|
|
1243
|
+
self.name = 'finger_sequence'
|
|
1244
|
+
self.matching_stimuli = False # sequence of numbers are different for easy and hard sequence condition
|
|
1245
|
+
|
|
1246
|
+
def generate_sequence(self):
|
|
1247
|
+
sequence = [random.choice([1, 2, 3, 4])]
|
|
1248
|
+
while len(sequence) < 6:
|
|
1249
|
+
next_digit = random.choice([d for d in [1, 2, 3, 4] if d != sequence[-1]])
|
|
1250
|
+
sequence.append(next_digit)
|
|
1251
|
+
return ' '.join(map(str, sequence))
|
|
1252
|
+
|
|
1253
|
+
def make_task_file(self,
|
|
1254
|
+
hand = 'bimanual',
|
|
1255
|
+
task_dur=30,
|
|
1256
|
+
trial_dur=3.25,
|
|
1257
|
+
iti_dur=0.5,
|
|
1258
|
+
file_name=None,
|
|
1259
|
+
text_height=1.5):
|
|
1260
|
+
"""
|
|
1261
|
+
Create a finger-sequence task file (press a 6-digit sequence in order).
|
|
1262
|
+
Each digit (1-4) is the finger/key to press; scoring compares the pressed
|
|
1263
|
+
key number directly to the sequence digit.
|
|
1264
|
+
|
|
1265
|
+
Args:
|
|
1266
|
+
hand (str): Hand(s) used for response ('bimanual', 'right', or 'left').
|
|
1267
|
+
task_dur (float): Total task duration in seconds.
|
|
1268
|
+
trial_dur (float): Duration of each trial in seconds.
|
|
1269
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
1270
|
+
file_name (str): Name of the file to save the task data.
|
|
1271
|
+
text_height (float): Height of the task text in degrees of visual angle.
|
|
1272
|
+
|
|
1273
|
+
Returns:
|
|
1274
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
1275
|
+
"""
|
|
1276
|
+
n_trials = int(np.floor(task_dur / (trial_dur + iti_dur)))
|
|
1277
|
+
trial_info = []
|
|
1278
|
+
|
|
1279
|
+
t = 0
|
|
1280
|
+
|
|
1281
|
+
for n in range(n_trials):
|
|
1282
|
+
trial = {}
|
|
1283
|
+
trial['trial_num'] = n
|
|
1284
|
+
trial['text_height'] = text_height
|
|
1285
|
+
trial['hand'] = hand
|
|
1286
|
+
trial['trial_dur'] = trial_dur
|
|
1287
|
+
trial['iti_dur'] = iti_dur
|
|
1288
|
+
trial['display_trial_feedback'] = True
|
|
1289
|
+
# choose random sequence
|
|
1290
|
+
trial['stim'] = self.generate_sequence()
|
|
1291
|
+
trial['start_time'] = t
|
|
1292
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
1293
|
+
trial_info.append(trial)
|
|
1294
|
+
t = trial['end_time']
|
|
1295
|
+
|
|
1296
|
+
trial_info = pd.DataFrame(trial_info)
|
|
1297
|
+
if file_name is not None:
|
|
1298
|
+
ut.dircheck(self.task_dir / self.name)
|
|
1299
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
1300
|
+
|
|
1301
|
+
return trial_info
|
|
1302
|
+
|
|
1303
|
+
class FlexionExtension(TaskFile):
|
|
1304
|
+
def __init__(self, const):
|
|
1305
|
+
super().__init__(const)
|
|
1306
|
+
# Must match the 'name' column in task_table.tsv: the runtime reads the
|
|
1307
|
+
# generated file from task_dir/<name>/, i.e. task_dir/toe_movement/.
|
|
1308
|
+
self.name = 'toe_movement'
|
|
1309
|
+
|
|
1310
|
+
def make_task_file(self,
|
|
1311
|
+
task_dur = 30,
|
|
1312
|
+
stim_dur = 2,
|
|
1313
|
+
file_name = None,
|
|
1314
|
+
text_height=1.5):
|
|
1315
|
+
"""
|
|
1316
|
+
Create a flexion-extension (toe movement) task file. The block is paced
|
|
1317
|
+
by a cue that alternates between 'flexion' and 'extension' every stim_dur
|
|
1318
|
+
seconds, written as one row per cue. The runtime just shows each cue for
|
|
1319
|
+
its duration.
|
|
1320
|
+
|
|
1321
|
+
Args:
|
|
1322
|
+
task_dur (float): Total duration of the block in seconds.
|
|
1323
|
+
stim_dur (float): Duration each cue ('flexion'/'extension') is shown.
|
|
1324
|
+
file_name (str): Name of the file to save the task data.
|
|
1325
|
+
text_height (float): Height of the task text in degrees of visual angle.
|
|
1326
|
+
|
|
1327
|
+
Returns:
|
|
1328
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
1329
|
+
"""
|
|
1330
|
+
cues = ['flexion', 'extension']
|
|
1331
|
+
n_phases = int(np.floor(task_dur / stim_dur))
|
|
1332
|
+
trial_info = []
|
|
1333
|
+
t = 0
|
|
1334
|
+
for n in range(n_phases):
|
|
1335
|
+
trial_info.append({
|
|
1336
|
+
'trial_num': n,
|
|
1337
|
+
'stim': cues[n % 2],
|
|
1338
|
+
'trial_dur': stim_dur,
|
|
1339
|
+
'text_height': text_height,
|
|
1340
|
+
'start_time': t,
|
|
1341
|
+
'end_time': t + stim_dur,
|
|
1342
|
+
})
|
|
1343
|
+
t += stim_dur
|
|
1344
|
+
|
|
1345
|
+
trial_info = pd.DataFrame(trial_info)
|
|
1346
|
+
if file_name is not None:
|
|
1347
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
1348
|
+
return trial_info
|
|
1349
|
+
|
|
1350
|
+
class SemanticPrediction(TaskFile):
|
|
1351
|
+
def __init__(self, const):
|
|
1352
|
+
super().__init__(const)
|
|
1353
|
+
self.name = 'semantic_prediction'
|
|
1354
|
+
|
|
1355
|
+
def make_task_file(self, hand='right',
|
|
1356
|
+
responses = [1,2], # 1 = True, 2 = False
|
|
1357
|
+
run_number=None,
|
|
1358
|
+
task_dur=30,
|
|
1359
|
+
trial_dur=15,
|
|
1360
|
+
sentence_dur=2,
|
|
1361
|
+
file_name=None,
|
|
1362
|
+
stim_file=None,
|
|
1363
|
+
stim=None,
|
|
1364
|
+
text_height=2):
|
|
1365
|
+
"""
|
|
1366
|
+
Create a semantic-prediction task file (judge whether the final word
|
|
1367
|
+
makes the sentence meaningful).
|
|
1368
|
+
|
|
1369
|
+
Args:
|
|
1370
|
+
hand (str): Hand used for response ('right' or 'left').
|
|
1371
|
+
responses (list): Response keys for [meaningful, meaningless].
|
|
1372
|
+
run_number (int): Run number, used to select which sentences to present.
|
|
1373
|
+
task_dur (float): Total task duration in seconds.
|
|
1374
|
+
trial_dur (float): Total duration budgeted for each trial in seconds.
|
|
1375
|
+
sentence_dur (float): Response window for the final word, in seconds.
|
|
1376
|
+
file_name (str): Name of the file to save the task data.
|
|
1377
|
+
text_height (float): Height of the task text in degrees of visual angle.
|
|
1378
|
+
stim_file (str): Optional path to a custom stimulus CSV.
|
|
1379
|
+
stim (pd.DataFrame): Optional pre-loaded stimulus table.
|
|
1380
|
+
|
|
1381
|
+
Returns:
|
|
1382
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
1383
|
+
"""
|
|
1384
|
+
# count number of trials
|
|
1385
|
+
n_trials = int(np.floor(task_dur / (trial_dur)))
|
|
1386
|
+
trial_info = []
|
|
1387
|
+
t = 0
|
|
1388
|
+
|
|
1389
|
+
if stim_file:
|
|
1390
|
+
stim = pd.read_csv(stim_file)
|
|
1391
|
+
else:
|
|
1392
|
+
stim = pd.read_csv(ut.find_stim(self.const, 'semantic_prediction', 'semantic_prediction.csv'))
|
|
1393
|
+
|
|
1394
|
+
# Ignore stim_list and stimulus_seed: selection is driven entirely by
|
|
1395
|
+
# the provided stim_file (if any) or by run_number-based slicing.
|
|
1396
|
+
start_row = (run_number - 1) * n_trials
|
|
1397
|
+
end_row = run_number * n_trials - 1
|
|
1398
|
+
stim = stim.iloc[start_row:end_row + 1].reset_index(drop=True)
|
|
1399
|
+
|
|
1400
|
+
n_actual = min(n_trials, len(stim))
|
|
1401
|
+
for n in range(n_actual):
|
|
1402
|
+
trial = {}
|
|
1403
|
+
trial['key_true'] = responses[0]
|
|
1404
|
+
trial['key_false'] = responses[1]
|
|
1405
|
+
trial['trial_num'] = n
|
|
1406
|
+
trial['text_height'] = text_height
|
|
1407
|
+
trial['hand'] = hand
|
|
1408
|
+
trial['trial_dur'] = trial_dur
|
|
1409
|
+
trial['sentence_dur'] = sentence_dur
|
|
1410
|
+
trial['sentence'] = stim['sentence'].iloc[n]
|
|
1411
|
+
trial['trial_type'] = random.choice([0,1]) # 0 = meaningless, 1 = meaningful; randomize trial type for each sentence
|
|
1412
|
+
last_word = [stim['wrong_word'].iloc[n], stim['right_word'].iloc[n]]
|
|
1413
|
+
trial['last_word'] = last_word[trial['trial_type']]
|
|
1414
|
+
trial['display_trial_feedback'] = True
|
|
1415
|
+
trial['start_time'] = t
|
|
1416
|
+
trial['end_time'] = t + trial_dur
|
|
1417
|
+
|
|
1418
|
+
trial_info.append(trial)
|
|
1419
|
+
|
|
1420
|
+
# Update for next trial:
|
|
1421
|
+
t = trial['end_time']
|
|
1422
|
+
|
|
1423
|
+
trial_info = pd.DataFrame(trial_info)
|
|
1424
|
+
if file_name is not None:
|
|
1425
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
1426
|
+
return trial_info
|
|
1427
|
+
|
|
1428
|
+
class VisualSearch(TaskFile):
|
|
1429
|
+
def __init__(self, const):
|
|
1430
|
+
super().__init__(const)
|
|
1431
|
+
self.name = 'visual_search'
|
|
1432
|
+
|
|
1433
|
+
def make_task_file(self,
|
|
1434
|
+
hand = 'right', #to recode for alternating hands: put left here, and put 3,4 in responses
|
|
1435
|
+
responses = [1,2], # 1 = match, 2 = no match
|
|
1436
|
+
task_dur = 30,
|
|
1437
|
+
trial_dur = 2,
|
|
1438
|
+
iti_dur = 0.5,
|
|
1439
|
+
easy_prob=0.5,
|
|
1440
|
+
search_width=28,
|
|
1441
|
+
search_height=20,
|
|
1442
|
+
file_name = None ):
|
|
1443
|
+
"""
|
|
1444
|
+
Create a visual-search task file (find a canonically-oriented 'L').
|
|
1445
|
+
|
|
1446
|
+
Args:
|
|
1447
|
+
hand (str): Hand used for response ('right' or 'left').
|
|
1448
|
+
responses (list): Response keys for [target present, target absent].
|
|
1449
|
+
task_dur (float): Total task duration in seconds.
|
|
1450
|
+
trial_dur (float): Duration of each trial in seconds.
|
|
1451
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
1452
|
+
easy_prob (float): Probability of an easy trial (4 stimuli vs. 8).
|
|
1453
|
+
search_width, search_height (float): Size of the area the items are spread over, in degrees of visual angle.
|
|
1454
|
+
file_name (str): Name of the file to save the task data.
|
|
1455
|
+
|
|
1456
|
+
Returns:
|
|
1457
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
1458
|
+
"""
|
|
1459
|
+
n_trials = int(np.floor(task_dur / (trial_dur+iti_dur)))
|
|
1460
|
+
trial_info = []
|
|
1461
|
+
t = 0
|
|
1462
|
+
|
|
1463
|
+
for n in range(n_trials):
|
|
1464
|
+
trial = {}
|
|
1465
|
+
trial['key_true'] = responses[0]
|
|
1466
|
+
trial['key_false'] = responses[1]
|
|
1467
|
+
trial['trial_num'] = n
|
|
1468
|
+
trial['hand'] = hand
|
|
1469
|
+
trial['trial_dur'] = trial_dur
|
|
1470
|
+
trial['iti_dur'] = iti_dur
|
|
1471
|
+
trial['display_trial_feedback'] = True
|
|
1472
|
+
trial['trial_type'] = random.choice([0,1])
|
|
1473
|
+
# Difficulty is the condition: the number of search items (set size).
|
|
1474
|
+
trial['condition'] = '4-items' if random.random() < easy_prob else '8-items'
|
|
1475
|
+
trial['search_width'] = search_width
|
|
1476
|
+
trial['search_height'] = search_height
|
|
1477
|
+
trial['start_time'] = t
|
|
1478
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
1479
|
+
trial_info.append(trial)
|
|
1480
|
+
|
|
1481
|
+
# Update for next trial:
|
|
1482
|
+
t = trial['end_time']
|
|
1483
|
+
|
|
1484
|
+
trial_info = pd.DataFrame(trial_info)
|
|
1485
|
+
if file_name is not None:
|
|
1486
|
+
trial_info.to_csv(self.task_dir / self.name / file_name,sep='\t',index=False)
|
|
1487
|
+
return trial_info
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
class Movie(TaskFile):
|
|
1491
|
+
def __init__(self, const):
|
|
1492
|
+
super().__init__(const)
|
|
1493
|
+
self.name = 'movie'
|
|
1494
|
+
self.matching_stimuli = False # Romance movie clips are different from nature movie clips and landscape movie clips
|
|
1495
|
+
|
|
1496
|
+
def make_task_file(self,
|
|
1497
|
+
run_number = None ,
|
|
1498
|
+
task_dur=30,
|
|
1499
|
+
trial_dur=30,
|
|
1500
|
+
iti_dur=0,
|
|
1501
|
+
file_name=None,
|
|
1502
|
+
stim_file=None,
|
|
1503
|
+
condition=None,
|
|
1504
|
+
stim_width=22):
|
|
1505
|
+
"""
|
|
1506
|
+
Create a movie-watching task file (passive viewing of a 30s clip).
|
|
1507
|
+
|
|
1508
|
+
Args:
|
|
1509
|
+
run_number (int): Run number, used to select which clip to show.
|
|
1510
|
+
task_dur (float): Total task duration in seconds.
|
|
1511
|
+
trial_dur (float): Duration of each clip in seconds.
|
|
1512
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
1513
|
+
file_name (str): Name of the file to save the task data.
|
|
1514
|
+
stim_file (str): Optional path to a custom stimulus CSV.
|
|
1515
|
+
condition (str): Which clips to use ('romance', 'nature', "animation" or
|
|
1516
|
+
'landscape'). If None, all non-practice clips are used.
|
|
1517
|
+
stim_width (float): Width of the pictures / clips in degrees of visual angle (height keeps the aspect ratio).
|
|
1518
|
+
|
|
1519
|
+
Returns:
|
|
1520
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
1521
|
+
"""
|
|
1522
|
+
n_trials = int(np.floor(task_dur / (trial_dur + iti_dur)))
|
|
1523
|
+
trial_info = []
|
|
1524
|
+
|
|
1525
|
+
t = 0
|
|
1526
|
+
|
|
1527
|
+
if stim_file:
|
|
1528
|
+
stim = pd.read_csv(ut.find_stim(self.const, self.name, stim_file))
|
|
1529
|
+
else:
|
|
1530
|
+
stim = pd.read_csv(ut.find_stim(self.const, self.name, f'{self.name}.csv'))
|
|
1531
|
+
|
|
1532
|
+
if condition:
|
|
1533
|
+
stim = stim[stim['condition'] == condition]
|
|
1534
|
+
else:
|
|
1535
|
+
stim = stim.loc[
|
|
1536
|
+
~stim['condition'].str.contains('practice', na=False)
|
|
1537
|
+
& (stim['condition'].astype(str).str.lower() != 'exclude')
|
|
1538
|
+
]
|
|
1539
|
+
|
|
1540
|
+
# Wrap around the available clips if more runs are requested than there are
|
|
1541
|
+
# stimuli (e.g. landscape has only 10 clips).
|
|
1542
|
+
total = len(stim)
|
|
1543
|
+
if (run_number - 1) * n_trials >= total:
|
|
1544
|
+
cond_label = condition if condition else 'selected'
|
|
1545
|
+
print(f"Warning: Movie only has {total} '{cond_label}' clips; "
|
|
1546
|
+
f"run {run_number} wraps around and repeats clips.")
|
|
1547
|
+
idx = [((run_number - 1) * n_trials + k) % total for k in range(n_trials)]
|
|
1548
|
+
stim = stim.iloc[idx].reset_index(drop=True)
|
|
1549
|
+
|
|
1550
|
+
for n in range(n_trials):
|
|
1551
|
+
trial = {}
|
|
1552
|
+
trial['trial_num'] = n
|
|
1553
|
+
trial['trial_dur'] = trial_dur
|
|
1554
|
+
trial['iti_dur'] = iti_dur
|
|
1555
|
+
trial['stim'] = stim['video'][n]
|
|
1556
|
+
trial['condition'] = stim['condition'][n]
|
|
1557
|
+
trial['stim_width'] = stim_width
|
|
1558
|
+
trial['start_time'] = t
|
|
1559
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
1560
|
+
trial_info.append(trial)
|
|
1561
|
+
t = trial['end_time']
|
|
1562
|
+
|
|
1563
|
+
trial_info = pd.DataFrame(trial_info)
|
|
1564
|
+
if file_name is not None:
|
|
1565
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
1566
|
+
|
|
1567
|
+
return trial_info
|
|
1568
|
+
|
|
1569
|
+
class Affective(TaskFile):
|
|
1570
|
+
def __init__(self, const):
|
|
1571
|
+
super().__init__(const)
|
|
1572
|
+
self.name = 'affective'
|
|
1573
|
+
|
|
1574
|
+
def make_task_file(self,
|
|
1575
|
+
task_dur=30,
|
|
1576
|
+
trial_dur=1.6,
|
|
1577
|
+
iti_dur=0.4,
|
|
1578
|
+
file_name=None,
|
|
1579
|
+
hand='right',
|
|
1580
|
+
responses=[1,2],
|
|
1581
|
+
stim_width=18.4,
|
|
1582
|
+
run_number=1):
|
|
1583
|
+
"""
|
|
1584
|
+
Create an affective-picture task file (judge pleasant vs unpleasant).
|
|
1585
|
+
Each run takes the next unused images of each valence, wrapping around
|
|
1586
|
+
once all have been shown (64 per valence: 8 runs of 30 s without repeats).
|
|
1587
|
+
|
|
1588
|
+
Args:
|
|
1589
|
+
task_dur (float): Total task duration in seconds.
|
|
1590
|
+
trial_dur (float): Duration each image is displayed in seconds.
|
|
1591
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
1592
|
+
file_name (str): Name of the file to save the task data.
|
|
1593
|
+
stim_width (float): Width of the pictures / clips in degrees of visual angle (height keeps the aspect ratio).
|
|
1594
|
+
hand (str): Hand used for response ('right' or 'left').
|
|
1595
|
+
responses (list): Response keys for [unpleasant, pleasant].
|
|
1596
|
+
run_number (int): Run number, used to select which images to show.
|
|
1597
|
+
|
|
1598
|
+
Returns:
|
|
1599
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
1600
|
+
"""
|
|
1601
|
+
# check how many trials to include
|
|
1602
|
+
n_trials = int(np.floor(task_dur / (trial_dur + iti_dur)))
|
|
1603
|
+
n_pleasant = n_trials // 2
|
|
1604
|
+
n_unpleasant = n_trials - n_pleasant
|
|
1605
|
+
|
|
1606
|
+
# Next unused image numbers of each valence for this run (images are numbered from 1)
|
|
1607
|
+
stim_dir = ut.find_stim_dir(self.const, self.name)
|
|
1608
|
+
def pick(valence, n):
|
|
1609
|
+
total = len(list(stim_dir.glob(f'{valence}*.jpg')))
|
|
1610
|
+
return [((run_number - 1) * n + i) % total + 1 for i in range(n)]
|
|
1611
|
+
pleasant_nums = pick('pleasant', n_pleasant)
|
|
1612
|
+
unpleasant_nums = pick('unpleasant', n_unpleasant)
|
|
1613
|
+
|
|
1614
|
+
# Create stim list
|
|
1615
|
+
stim = [{'imgName': f'pleasant{n}.jpg', 'trialType': 2} for n in pleasant_nums] + \
|
|
1616
|
+
[{'imgName': f'unpleasant{n}.jpg', 'trialType': 1} for n in unpleasant_nums]
|
|
1617
|
+
|
|
1618
|
+
random.shuffle(stim) # mix trial order
|
|
1619
|
+
|
|
1620
|
+
# Build trial list
|
|
1621
|
+
trial_info = []
|
|
1622
|
+
t = 0
|
|
1623
|
+
for n in range(n_trials):
|
|
1624
|
+
trial = {}
|
|
1625
|
+
trial['trial_num'] = n
|
|
1626
|
+
trial['stim_width'] = stim_width
|
|
1627
|
+
trial['stim'] = stim[n]['imgName']
|
|
1628
|
+
trial['trial_type'] = stim[n]['trialType']
|
|
1629
|
+
trial['hand'] = hand
|
|
1630
|
+
trial['trial_dur'] = trial_dur
|
|
1631
|
+
trial['iti_dur'] = iti_dur
|
|
1632
|
+
trial['key_unpleasant'] = responses[0]
|
|
1633
|
+
trial['key_pleasant'] = responses[1]
|
|
1634
|
+
trial['display_trial_feedback'] = True
|
|
1635
|
+
trial['start_time'] = t
|
|
1636
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
1637
|
+
trial_info.append(trial)
|
|
1638
|
+
t = trial['end_time']
|
|
1639
|
+
|
|
1640
|
+
trial_info = pd.DataFrame(trial_info)
|
|
1641
|
+
|
|
1642
|
+
if file_name is not None:
|
|
1643
|
+
ut.dircheck(self.task_dir / self.name)
|
|
1644
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
1645
|
+
|
|
1646
|
+
return trial_info
|
|
1647
|
+
|
|
1648
|
+
class SerialReactionTime(TaskFile):
|
|
1649
|
+
def __init__(self, const):
|
|
1650
|
+
super().__init__(const)
|
|
1651
|
+
self.name = 'serial_reaction_time'
|
|
1652
|
+
|
|
1653
|
+
def make_task_file(self,
|
|
1654
|
+
hand='bimanual',
|
|
1655
|
+
task_dur=30,
|
|
1656
|
+
initial_wait=1.0,
|
|
1657
|
+
trial_dur=0.5,
|
|
1658
|
+
iti_dur=1.0,
|
|
1659
|
+
file_name=None):
|
|
1660
|
+
# wait one second at the start
|
|
1661
|
+
effective_dur = task_dur - initial_wait
|
|
1662
|
+
n_trials = int(np.floor(effective_dur / (trial_dur + iti_dur)))
|
|
1663
|
+
trial_info = []
|
|
1664
|
+
|
|
1665
|
+
t = initial_wait
|
|
1666
|
+
prev_stim = 0
|
|
1667
|
+
|
|
1668
|
+
for n in range(n_trials):
|
|
1669
|
+
trial = {}
|
|
1670
|
+
trial['trial_num'] = n
|
|
1671
|
+
trial['hand'] = hand
|
|
1672
|
+
trial['trial_dur'] = trial_dur
|
|
1673
|
+
trial['iti_dur'] = iti_dur
|
|
1674
|
+
|
|
1675
|
+
# Ensure the same stimulus doesn't appear on consecutive trials
|
|
1676
|
+
stim = prev_stim
|
|
1677
|
+
while stim == prev_stim:
|
|
1678
|
+
stim = random.randint(1, 4)
|
|
1679
|
+
trial['stim'] = stim
|
|
1680
|
+
prev_stim = stim
|
|
1681
|
+
|
|
1682
|
+
trial['start_time'] = t
|
|
1683
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
1684
|
+
trial_info.append(trial)
|
|
1685
|
+
t = trial['end_time']
|
|
1686
|
+
|
|
1687
|
+
trial_info = pd.DataFrame(trial_info)
|
|
1688
|
+
if file_name is not None:
|
|
1689
|
+
ut.dircheck(self.task_dir / self.name)
|
|
1690
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
1691
|
+
|
|
1692
|
+
return trial_info
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
class FingerRhythmic(TaskFile):
|
|
1696
|
+
def __init__(self, const):
|
|
1697
|
+
super().__init__(const)
|
|
1698
|
+
self.name = 'finger_rhythmic'
|
|
1699
|
+
|
|
1700
|
+
def make_task_file(self,
|
|
1701
|
+
hand='right',
|
|
1702
|
+
responses=[1],
|
|
1703
|
+
run_number= None,
|
|
1704
|
+
task_dur = 70,
|
|
1705
|
+
trial_dur=35, # 2 sec trial start text, 27.95 sec tone train, ~5 sec buffer
|
|
1706
|
+
iti_dur=0,
|
|
1707
|
+
ioi=0.6, # Inter-onset interval in seconds (default: 600ms). Can be single value or list for different pace levels
|
|
1708
|
+
file_name=None):
|
|
1709
|
+
|
|
1710
|
+
# count number of trials
|
|
1711
|
+
n_trials = int(np.floor(task_dur / (trial_dur + iti_dur)))
|
|
1712
|
+
if n_trials < 1:
|
|
1713
|
+
raise ValueError(
|
|
1714
|
+
f"FingerRhythmic: task_dur ({task_dur}s) is shorter than one trial "
|
|
1715
|
+
f"(trial_dur+iti_dur = {trial_dur + iti_dur}s), so no trials would be generated. "
|
|
1716
|
+
f"Give this task a longer block (the default task_dur=70s fits two 35s trials).")
|
|
1717
|
+
trial_info = []
|
|
1718
|
+
t = 0
|
|
1719
|
+
|
|
1720
|
+
# Handle ioi as single value or list
|
|
1721
|
+
if isinstance(ioi, (list, np.ndarray)):
|
|
1722
|
+
ioi_list = list(ioi)
|
|
1723
|
+
# Expand to cover all trials by repeating the list
|
|
1724
|
+
ioi_list_expanded = (ioi_list * ((n_trials // len(ioi_list)) + 1))[:n_trials]
|
|
1725
|
+
# Randomize the order
|
|
1726
|
+
random.shuffle(ioi_list_expanded)
|
|
1727
|
+
else:
|
|
1728
|
+
ioi_list_expanded = [ioi] * n_trials # Repeat single value for all trials
|
|
1729
|
+
|
|
1730
|
+
for n in range(n_trials):
|
|
1731
|
+
trial = {}
|
|
1732
|
+
trial['key_one'] = responses[0]
|
|
1733
|
+
trial['trial_num'] = n
|
|
1734
|
+
trial['hand'] = hand
|
|
1735
|
+
trial['trial_dur'] = trial_dur
|
|
1736
|
+
trial['iti_dur'] = iti_dur
|
|
1737
|
+
trial['ioi'] = ioi_list_expanded[n] # Use randomized IOI assignment
|
|
1738
|
+
trial['stim'] = 'generated'
|
|
1739
|
+
trial['display_trial_feedback'] = False
|
|
1740
|
+
trial['start_time'] = t
|
|
1741
|
+
trial['end_time'] = t + trial_dur + iti_dur
|
|
1742
|
+
trial_info.append(trial)
|
|
1743
|
+
# Update for next trial:
|
|
1744
|
+
t = trial['end_time']
|
|
1745
|
+
|
|
1746
|
+
trial_info = pd.DataFrame(trial_info)
|
|
1747
|
+
if file_name is not None:
|
|
1748
|
+
ut.dircheck(self.task_dir / self.name)
|
|
1749
|
+
trial_info.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
1750
|
+
|
|
1751
|
+
return trial_info
|
|
1752
|
+
|
|
1753
|
+
class TimePerception(TaskFile):
|
|
1754
|
+
def __init__(self, const):
|
|
1755
|
+
super().__init__(const)
|
|
1756
|
+
self.name = 'time_perception' # folder: stimuli/perception/, tasks/perception/
|
|
1757
|
+
|
|
1758
|
+
def make_task_file(self,
|
|
1759
|
+
modality='time', # 'time' or 'volume'
|
|
1760
|
+
responses=[1, 2], # code 1 = left option, 2 = right option
|
|
1761
|
+
task_dur=30, # total block duration in seconds
|
|
1762
|
+
n_trials=None, # if None, derived from task_dur; forced even
|
|
1763
|
+
trial_dur=4, # tone + question window duration
|
|
1764
|
+
iti_dur=1.0,
|
|
1765
|
+
question_dur=2.0,
|
|
1766
|
+
display_feedback= True,
|
|
1767
|
+
run_number=None,
|
|
1768
|
+
file_name=None,
|
|
1769
|
+
**unused):
|
|
1770
|
+
"""
|
|
1771
|
+
Create a time/volume-perception task file (2AFC discrimination).
|
|
1772
|
+
|
|
1773
|
+
Args:
|
|
1774
|
+
modality (str): Dimension judged: 'time' (shorter/longer) or 'volume' (quieter/louder).
|
|
1775
|
+
responses (list): Response keys for [left option, right option].
|
|
1776
|
+
task_dur (float): Total block duration in seconds. Used to derive n_trials when n_trials is None.
|
|
1777
|
+
n_trials (int): Number of trials. If None, computed from task_dur as
|
|
1778
|
+
floor(task_dur / (trial_dur + iti_dur)); rounded down to an even number
|
|
1779
|
+
so the two response sides stay balanced.
|
|
1780
|
+
trial_dur (float): Tone + question window duration per trial in seconds.
|
|
1781
|
+
iti_dur (float): Inter-trial interval duration in seconds.
|
|
1782
|
+
question_dur (float): Response window after the tones in seconds.
|
|
1783
|
+
display_feedback (bool): Whether to show green/red trial feedback.
|
|
1784
|
+
run_number (int): Run number; seeds the side-order shuffle.
|
|
1785
|
+
file_name (str): Name of the file to save the task data.
|
|
1786
|
+
|
|
1787
|
+
Returns:
|
|
1788
|
+
pd.DataFrame: Task information as a DataFrame.
|
|
1789
|
+
"""
|
|
1790
|
+
# Derive the trial count from the block duration when not given explicitly,
|
|
1791
|
+
# so the block fits task_dur like every other task. Force an even count so
|
|
1792
|
+
# the two response sides are balanced.
|
|
1793
|
+
if n_trials is None:
|
|
1794
|
+
n_trials = int(np.floor(task_dur / (trial_dur + iti_dur)))
|
|
1795
|
+
n_trials -= n_trials % 2
|
|
1796
|
+
|
|
1797
|
+
# sides per modality
|
|
1798
|
+
if modality == 'time':
|
|
1799
|
+
left_label, right_label = 'shorter', 'longer'
|
|
1800
|
+
elif modality == 'volume':
|
|
1801
|
+
left_label, right_label = 'quieter', 'louder'
|
|
1802
|
+
|
|
1803
|
+
sides = [left_label] * (n_trials // 2) + [right_label] * (n_trials // 2)
|
|
1804
|
+
np.random.default_rng(run_number).shuffle(sides)
|
|
1805
|
+
|
|
1806
|
+
rows, t = [], 0.0
|
|
1807
|
+
for i, side in enumerate(sides):
|
|
1808
|
+
rows.append(dict(
|
|
1809
|
+
trial_num=i,
|
|
1810
|
+
modality=modality, # drives Task branching
|
|
1811
|
+
side=side, # shorter/longer or softer/louder
|
|
1812
|
+
key_one=int(responses[0]), # instruction mapping only
|
|
1813
|
+
key_two=int(responses[1]),
|
|
1814
|
+
trial_type=1 if side in (left_label,) else 2, # correct code
|
|
1815
|
+
question_dur=float(question_dur),
|
|
1816
|
+
trial_dur=float(trial_dur),
|
|
1817
|
+
iti_dur=float(iti_dur),
|
|
1818
|
+
display_trial_feedback= display_feedback,
|
|
1819
|
+
|
|
1820
|
+
# runtime logs (filled in Task)
|
|
1821
|
+
comparison_ms=np.nan,
|
|
1822
|
+
comparison_dba=np.nan,
|
|
1823
|
+
|
|
1824
|
+
start_time=float(t),
|
|
1825
|
+
end_time=float(t + trial_dur + iti_dur),
|
|
1826
|
+
))
|
|
1827
|
+
t = rows[-1]['end_time']
|
|
1828
|
+
|
|
1829
|
+
df = pd.DataFrame(rows)
|
|
1830
|
+
if file_name:
|
|
1831
|
+
ut.dircheck(self.task_dir / self.name)
|
|
1832
|
+
df.to_csv(self.task_dir / self.name / file_name, sep='\t', index=False)
|
|
1833
|
+
return df
|