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.
@@ -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