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,4 @@
1
+ """MultiTaskBattery: a toolbox for running multi-domain cognitive and motor
2
+ tasks during fMRI sessions, built on PsychoPy."""
3
+
4
+ __version__ = "1.3.0"
@@ -0,0 +1,223 @@
1
+ import os
2
+ import numpy as np
3
+ import pandas as pd
4
+ import nibabel as nb
5
+ import pooch
6
+ from numba import njit
7
+ from time import time
8
+
9
+
10
+ ZENODO_RECORD = "18793343"
11
+ ZENODO_URL = f"https://zenodo.org/api/records/{ZENODO_RECORD}/files"
12
+ CACHE_DIR = pooch.os_cache("MTB_task_library")
13
+
14
+
15
+ def fetch_task_library(version='V1', atlas='multiatlasHCP', structures=None):
16
+ """
17
+ Fetch task activation library from Zenodo.
18
+
19
+ Args:
20
+ version: Library version (e.g., 'V1')
21
+ atlas: Atlas name (e.g., 'multiatlasHCP')
22
+ structures: List of CIFTI structure names to include. If None, loads all.
23
+
24
+ Returns:
25
+ library_data: ndarray (n_conditions, n_measurement_channels)
26
+ library_info: DataFrame with condition info
27
+ """
28
+ data_fname = f"desc-tasklibrary_space-{atlas}_{version}.dscalar.nii"
29
+ info_fname = f"desc-tasklibrary_{version}_info.tsv"
30
+
31
+ data_path = pooch.retrieve(
32
+ url=f"https://zenodo.org/records/{ZENODO_RECORD}/files/{data_fname}",
33
+ known_hash=None,
34
+ path=CACHE_DIR,
35
+ fname=data_fname,
36
+ )
37
+ info_path = pooch.retrieve(
38
+ url=f"https://zenodo.org/records/{ZENODO_RECORD}/files/{info_fname}",
39
+ known_hash=None,
40
+ path=CACHE_DIR,
41
+ fname=info_fname,
42
+ )
43
+
44
+ return load_library(data_path, info_path, structures=structures)
45
+
46
+
47
+ def load_library(data_path, info_path, structures=None):
48
+ """
49
+ Load task library_data from local files.
50
+
51
+ Args:
52
+ data_path: Path to library data file (.nii, .nii.gz, .dscalar.nii), must be shape (n_conditions, n_measurement_channels)
53
+ info_path: Path to library info file (.tsv) with 'full_code' column
54
+ structures: List of CIFTI structure names to include (e.g., ['CIFTI_STRUCTURE_CORTEX_LEFT',
55
+ 'CIFTI_STRUCTURE_CORTEX_RIGHT']). If None, loads all data.
56
+
57
+ Returns:
58
+ library_data: ndarray (n_conditions, n_measurement_channels)
59
+ library_info: DataFrame with condition info
60
+ """
61
+ img = nb.load(data_path)
62
+ library_data = img.get_fdata()
63
+ library_info = pd.read_csv(info_path, sep='\t')
64
+
65
+ if 'full_code' not in library_info.columns:
66
+ raise ValueError("info file must have 'full_code' column")
67
+
68
+ # Filter by structures if specified (CIFTI files only)
69
+ if structures is not None:
70
+ brain_models = img.header.get_axis(1)
71
+
72
+ # Convert short names to full CIFTI structure names
73
+ cifti_structures = []
74
+ for s in structures:
75
+ name = s.upper()
76
+ if not name.startswith('CIFTI_STRUCTURE_'):
77
+ name = f'CIFTI_STRUCTURE_{name}'
78
+ cifti_structures.append(name)
79
+
80
+ indices = []
81
+ for struct_name, idx, bm in brain_models.iter_structures():
82
+ if struct_name in cifti_structures:
83
+ indices.extend(range(idx.start, idx.stop))
84
+ if not indices:
85
+ available = [name for name, _, _ in brain_models.iter_structures()]
86
+ raise ValueError(f"No matching structures found. Available: {available}")
87
+ library_data = library_data[:, indices]
88
+
89
+ print(f"Loaded {library_data.shape[0]} conditions, {library_data.shape[1]} greyordinates")
90
+ return library_data, library_info
91
+
92
+
93
+ @njit
94
+ def _compute_nit(G):
95
+ """
96
+ Computes negative inverse trace of the battery task-by-task covariance matrix
97
+ """
98
+ N = G.shape[0]
99
+
100
+ # Center the matrix
101
+ H = np.eye(N) - np.ones((N, N)) / N
102
+ G_mc = H @ G @ H
103
+
104
+ # Eigenvalues
105
+ l_mc = np.linalg.eigvalsh(G_mc)
106
+
107
+ # Negative inverse trace (exclude zero eigenvalue from centering, otherwise it will add an offset to all batteries that makes the numbers huge and ugly)
108
+ l_mc = l_mc[::-1]
109
+ l_mc = l_mc[l_mc > 1e-12]
110
+ nit = -np.sum(1 / l_mc)
111
+ return nit
112
+
113
+
114
+ def evaluate_battery(library_data, library_info, battery_full_codes):
115
+ """
116
+ Evaluate a task battery by computing the negative inverse trace (NIT)
117
+ of the task-by-task covariance matrix.
118
+
119
+ Args:
120
+ library_data: Task activation data (n_conditions, n_measurement_channels)
121
+ library_info: DataFrame with condition info (must have 'full_code' column)
122
+ battery_full_codes: List of task condition codes from the 'full_code' column in the info file (e.g., ['task1_cond1', 'task2_cond2'])
123
+
124
+ Returns: NIT score (float) - higher values indicate more separable task patterns and more optimal battery
125
+ """
126
+ full_codes = library_info['full_code'].str.lower().tolist()
127
+ comb_names_lower = [name.lower() for name in battery_full_codes]
128
+
129
+ # Check for missing tasks
130
+ missing = [name for name in comb_names_lower if name not in full_codes]
131
+ if missing:
132
+ raise ValueError(f"Tasks not found in info: {missing}")
133
+
134
+ idx = np.array([full_codes.index(name) for name in comb_names_lower])
135
+ sub_data = library_data[idx]
136
+ G = sub_data @ sub_data.T
137
+ return _compute_nit(G)
138
+
139
+
140
+ def get_top_batteries(library_data, library_info, n_samples,
141
+ battery_size=8, n_top_batteries=10, forced_tasks=None,
142
+ verbose=True):
143
+ """
144
+ Random search over task combinations to find highest NIT.
145
+
146
+ Args:
147
+ library_data: Task activation data (n_conditions, n_measurement_channels)
148
+ library_info: DataFrame with condition info (must have 'full_code' column)
149
+ n_samples: Total number of random batteries to test
150
+ battery_size: Number of task conditions in each battery
151
+ n_top_batteries: Number of top batteries to keep
152
+ forced_tasks: List of task names to include in every battery
153
+
154
+ Returns: DataFrame with columns 'rank', 'evaluation', 'battery'
155
+ """
156
+ condition_labels = library_info['full_code'].tolist()
157
+ n_lib_task = library_data.shape[0]
158
+
159
+ # Compute full G matrix once
160
+ G_full = library_data @ library_data.T
161
+
162
+ # Handle forced tasks - convert labels to indices
163
+ if forced_tasks is None:
164
+ forced_tasks = []
165
+ label_to_idx = {label: i for i, label in enumerate(condition_labels)}
166
+ forced_indices = np.array([label_to_idx[t] for t in forced_tasks], dtype=np.int64)
167
+ n_forced = len(forced_indices)
168
+ n_random = battery_size - n_forced
169
+
170
+ # Available tasks for random selection (exclude forced)
171
+ available_tasks = np.array([i for i in range(n_lib_task) if i not in forced_indices])
172
+
173
+ # Track top N results
174
+ top_results = []
175
+ min_nit = float('-inf')
176
+
177
+ if verbose:
178
+ print(f"Starting computation... (forced: {n_forced}, random: {n_random})")
179
+ start_time = time()
180
+
181
+ for i in range(n_samples):
182
+ # Generate random combination
183
+ random_part = np.random.choice(available_tasks, size=n_random, replace=False)
184
+ idx = np.sort(np.concatenate([forced_indices, random_part]))
185
+
186
+ # Compute NIT
187
+ G = G_full[np.ix_(idx, idx)]
188
+ nit = _compute_nit(G)
189
+
190
+ # Keep top N
191
+ if len(top_results) < n_top_batteries:
192
+ top_results.append((nit, tuple(idx)))
193
+ if len(top_results) == n_top_batteries:
194
+ top_results.sort()
195
+ min_nit = top_results[0][0]
196
+ elif nit > min_nit:
197
+ top_results[0] = (nit, tuple(idx))
198
+ top_results.sort()
199
+ min_nit = top_results[0][0]
200
+
201
+ if verbose:
202
+ # Progress updates every 10%
203
+ if (i + 1) % (n_samples // 10) == 0:
204
+ elapsed = time() - start_time
205
+ print(f"Processed {i + 1:,} / {n_samples:,} ({100 * (i + 1) / n_samples:.0f}%) | "
206
+ f"Rate: {(i + 1) / elapsed:.0f}/s")
207
+
208
+ # Sort results by NIT descending
209
+ top_results.sort(reverse=True)
210
+
211
+ df = pd.DataFrame({
212
+ 'rank': range(1, len(top_results) + 1),
213
+ 'evaluation': [r[0] for r in top_results],
214
+ 'battery': [[condition_labels[i] for i in r[1]] for r in top_results]
215
+ })
216
+ return df
217
+
218
+
219
+ if __name__ == "__main__":
220
+ structures = None
221
+ data, info = fetch_task_library(version='V1',structures=structures)
222
+ # batteries = get_top_batteries(data, info, n_samples=1000000, battery_size=8, n_top_batteries=10, forced_tasks=None)
223
+ # print(batteries)
@@ -0,0 +1,340 @@
1
+ # Defines the Experiment as a class
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 sys
7
+ import numpy as np
8
+ from datetime import datetime
9
+ from psychopy import visual, gui, event, core
10
+ import MultiTaskBattery.utils as ut
11
+ import MultiTaskBattery.task_blocks as tasks
12
+ from MultiTaskBattery.ttl_clock import TTLClock
13
+ from MultiTaskBattery.screen import Screen
14
+ # import pylink as pl # to connect to eyelink
15
+
16
+
17
+ class Experiment:
18
+ def __init__(self, const, subj_id, screen=None):
19
+ """ A general class with attributes common to experiments
20
+
21
+ Args:
22
+ const (module):
23
+ local constants.py module (see example_experiment/constants.py) as example
24
+ subj_id (str):
25
+ id for the subject
26
+ screen (Screen, optional):
27
+ an existing Screen to reuse; if None, a new one is opened
28
+ Returns:
29
+ self (object):
30
+ an instance of the Experiment class
31
+ """
32
+
33
+ self.exp_name = const.exp_name
34
+ self.subj_id = subj_id
35
+ self.run_number = 0
36
+ self.const = const
37
+ self.ttl_clock = TTLClock()
38
+ self.ttl_clock.ttl_button = getattr(const, 'ttl_key', 't') # key the scanner trigger sends; differs by site
39
+ # open screen and display fixation cross
40
+ ### set the resolution of the subject screen here:
41
+ self.screen = screen if screen is not None else Screen(const.screen)
42
+
43
+ # quit key: works at any point of the experiment (checked on every flip / key read); set quit_key = None in constants.py to disable
44
+ quit_key = getattr(const, 'quit_key', 'escape')
45
+ if quit_key:
46
+ try:
47
+ event.globalKeys.remove(quit_key) # set by an earlier Experiment in this session
48
+ except KeyError:
49
+ pass
50
+ event.globalKeys.add(key=quit_key, func=self.quit, name='quit')
51
+
52
+ # connect to the eyetracker already
53
+ if self.const.eye_tracker:
54
+ import pylink as pl
55
+ self.pl = pl # keep a handle so run() can call pylink (optional import, only when eye_tracker=True)
56
+ # create an Eyelink class
57
+ ## the default ip address is 100.1.1.1.
58
+ ## in the ethernet settings of the laptop,
59
+ ## set the ip address of the EyeLink ethernet connection
60
+ ## to 100.1.1.2 and the subnet mask to 255.255.255.0
61
+ self.tk = pl.EyeLink('100.1.1.1')
62
+
63
+ def quit(self):
64
+ """Closes the window and quits the experiment (bound to const.quit_key)."""
65
+ self.screen.window.close()
66
+ core.quit()
67
+
68
+ def confirm_run_info(self):
69
+ """
70
+ Presents a GUI to confirm the settings for the run:
71
+
72
+ The following parameters will be set:
73
+ run_number - run number
74
+ subj_id - id assigned to the subject
75
+ ttl_flag - should the program wait for the ttl pulse or not? For scanning THIS FLAG HAS TO BE SET TO TRUE
76
+
77
+ Args:
78
+ """
79
+ if not self.const.debug:
80
+ # Step 1: Create a temporary small window for user feedback
81
+ self.screen.start_screen(height=getattr(self.const, 'instruction_text_height', None),
82
+ wrap_width=getattr(self.const, 'instruction_wrap_width', None))
83
+
84
+ # a dialog box pops up so you can enter info
85
+ #Set up input box
86
+ inputDlg = gui.Dlg(title = f"{self.exp_name}")
87
+ inputDlg.addField('Enter Subject id (str):',initial = self.subj_id) # run number (int)
88
+ inputDlg.addField('Enter Run Number (int):',initial = self.run_number+1) # run number (int)
89
+ inputDlg.addField('Run File name (str):',initial = self.const.default_run_filename.format(f'{self.run_number + 1:02d}')) # run number (int)
90
+ inputDlg.addField('Wait for TTL pulse?', initial = True) # a checkbox for ttl pulse (set it true for scanning)
91
+
92
+ inputDlg.show()
93
+
94
+ if inputDlg.OK:
95
+ self.subj_id = str(inputDlg.data[0])
96
+ self.run_number = int(inputDlg.data[1])
97
+ self.run_filename = str(inputDlg.data[2])
98
+ self.wait_ttl = bool(inputDlg.data[3])
99
+ else:
100
+ sys.exit()
101
+
102
+ else:
103
+ print("running in debug mode")
104
+ # pass on the values for your debugging with the following keywords
105
+ self.subj_id = 'test00'
106
+ self.run_number = self.run_number+1
107
+ self.run_filename = 'run_01.tsv'
108
+ self.wait_ttl = True
109
+
110
+ def init_run(self):
111
+ """initializing the run:
112
+ making sure a directory is created for the behavioral results
113
+ getting run file
114
+ Initializes all the tasks for a run
115
+ """
116
+
117
+ # 1. get the run file info: creates self.run_info
118
+ self.run_info = pd.read_csv(self.const.run_dir / self.run_filename,sep='\t')
119
+
120
+ # 2. Initialize the all tasks that we need
121
+ self.task_obj_list = [] # a list containing task objects in the run
122
+ task_table = ut.get_task_table(self.const.exp_dir, getattr(self.const, 'task_modules', None)) # Get the task table for the experiment: next to the task modules, the experiment's own table (if it exists), then the general task table
123
+ for t_num, task_info in self.run_info.iterrows():
124
+ # create a task object for the current task, reads the trial file, and append it to the list
125
+ t = task_table[task_table['name']== task_info.task_name]
126
+ task_info['code'] = t.code
127
+ task_info['descriptive_name'] = t.descriptive_name.iloc[0].capitalize()
128
+ class_name = t.task_class.iloc[0]
129
+ # Get the task class from the task modules
130
+ TaskClass = ut.get_task_class(self.const,class_name)
131
+ Task_obj = TaskClass(task_info,
132
+ screen = self.screen,
133
+ ttl_clock = self.ttl_clock,
134
+ const = self.const,
135
+ subj_id = self.subj_id)
136
+ Task_obj.init_task()
137
+ self.task_obj_list.append(Task_obj)
138
+
139
+ # 3. make subject folder in data/raw/<subj_id>
140
+ subj_dir = self.const.data_dir / self.subj_id
141
+ ut.dircheck(subj_dir) # making sure the directory is created!
142
+ self.run_data_file = self.const.data_dir / self.subj_id / f"{self.subj_id}.tsv"
143
+
144
+ def run(self):
145
+ """
146
+ run a run of the experiment
147
+ """
148
+ print(f"Running the experiment - run {self.run_number}")
149
+ self.screen.fixation_cross()
150
+ self.ttl_clock.reset()
151
+ self.ttl_clock.wait_for_first_ttl(wait = self.wait_ttl)
152
+ run_data = []
153
+ run_end_timestamp = None
154
+
155
+ # Start the eyetracker
156
+ if self.const.eye_tracker:
157
+ self.start_eyetracker()
158
+
159
+ for t_num, task in enumerate(self.task_obj_list):
160
+ # clear keys in buffer
161
+ event.clearEvents()
162
+
163
+ # padded, to overwrite the TR counter line (printed with end='\r')
164
+ print(f"Starting block {t_num+1}: {task.name}".ljust(60))
165
+
166
+ # Take the task data from the run_info dataframe
167
+ r_data = self.run_info.iloc[t_num].copy()
168
+
169
+ # wait till it's time to start the task
170
+ r_data['real_start_time'],r_data['start_ttl'],r_data['start_ttl_time'] = self.ttl_clock.wait_until(r_data.start_time)
171
+
172
+ ## sending a message to the edf file specifying task name
173
+ if self.const.eye_tracker:
174
+ self.pl.sendMessageToFile(f"task_name: {task.name} start_track: {self.pl.currentUsec()} real start time {r_data.real_start_time} TR count {self.ttl_clock.ttl_count}")
175
+
176
+ # display the instruction text for the task. (instructions are task specific)
177
+ if r_data.instruction_dur > 0:
178
+ task.display_instructions()
179
+
180
+ # wait for a time period equal to instruction duration
181
+ self.ttl_clock.wait_until(r_data.start_time + r_data.instruction_dur)
182
+
183
+ # Run the task (which saves its data to the target)
184
+ task.start_time = self.ttl_clock.get_time()
185
+ r_data['acc'],r_data['rt'] = task.run()
186
+
187
+ # Add the end time of the task
188
+ r_data['real_end_time'] = self.ttl_clock.get_time()
189
+ if getattr(self.const, 'record_task_end_timestamp', False):
190
+ r_data['task_end_timestamp'] = datetime.now().isoformat()
191
+ # Run-level stamp: a single wall-clock time taken after the last task.
192
+ # Also written onto the last row so it survives the save_per_task path.
193
+ if getattr(self.const, 'record_run_end_timestamp', False) and t_num == len(self.task_obj_list) - 1:
194
+ run_end_timestamp = datetime.now().isoformat()
195
+ r_data['run_end_timestamp'] = run_end_timestamp
196
+ run_data.append(r_data)
197
+
198
+ # Optional: persist this task's data immediately so completed tasks survive a mid-run crash
199
+ if getattr(self.const, 'save_per_task', False):
200
+ task.save_data(self.subj_id, self.run_number)
201
+ one_row = pd.DataFrame([r_data])
202
+ one_row.insert(0, 'run_num', self.run_number)
203
+ ut.append_data_to_file(self.run_data_file, one_row)
204
+
205
+ self.screen.fixation_cross()
206
+
207
+ # If last task, wait until the endtime for the last task, which for imaging could be longer than the task duration
208
+ # Note that endtime is not used for any task but the last one
209
+ if t_num == len(self.task_obj_list)-1:
210
+ self.ttl_clock.wait_until(self.ttl_clock.get_time() + 1.5)
211
+ self.screen.fixation_cross()
212
+ self.ttl_clock.wait_until(r_data.end_time)
213
+
214
+ # Stop the eyetracker
215
+ if self.const.eye_tracker:
216
+ self.stop_eyetracker()
217
+ self.tk.receiveDataFile(self.tk_filename, self.tk_filename)
218
+
219
+ run_data = pd.DataFrame(run_data)
220
+ if not getattr(self.const, 'save_per_task', False):
221
+ # save the run data to the run file
222
+ run_data.insert(0,'run_num',[self.run_number]*len(run_data))
223
+ if getattr(self.const, 'record_run_end_timestamp', False) and run_end_timestamp is not None:
224
+ run_data['run_end_timestamp'] = run_end_timestamp
225
+ ut.append_data_to_file(self.run_data_file, run_data )
226
+
227
+ for task in self.task_obj_list:
228
+ task.save_data(self.subj_id, self.run_number)
229
+
230
+ # show the scoreboard (set show_scoreboard = False in constants.py to skip it)
231
+ if getattr(self.const, 'show_scoreboard', True):
232
+ self.display_run_feedback(run_data)
233
+
234
+
235
+ def display_run_feedback(self, run_data):
236
+ """ Displays a score board for the tasks in the task_list
237
+
238
+ Args:
239
+ run_data (pd.DataFrame): a dataframe containing the run data
240
+ """
241
+ # Optional: Summarize the behavioural data from different conditions of the same task
242
+ ignore_conditions = True
243
+ if ignore_conditions:
244
+ # Ignore Nans
245
+ run_data = run_data.groupby('task_name', as_index=False).agg({'acc': lambda x: x.mean(skipna=True), 'rt': lambda x: x.mean(skipna=True)})
246
+
247
+ # Define column headers
248
+ scores = [["Score:", "", ""], ["", "Correct", "Time"]]
249
+ # Add each task's data to the score text
250
+ task_object_names = [obj.name for obj in self.task_obj_list]
251
+ for r, name in enumerate(run_data.task_name.values):
252
+ task_idx = task_object_names.index(name)
253
+ if self.task_obj_list[task_idx].feedback_type.lower() != 'none':
254
+ task_name = f"{self.task_obj_list[task_idx].descriptive_name}"
255
+ acc = run_data.acc[r]
256
+ accuracy = f"{int(acc*100)} %" if not np.isnan(acc) else "-"
257
+ rt = f"{run_data.rt[r]:.2f} s" if not np.isnan(run_data.rt[r]) else "-"
258
+ scores.append([task_name, accuracy, rt])
259
+
260
+ # Display settings
261
+ # Height of the text elements: allow experiment-specific override via
262
+ # const.scoreboard_text_height; fall back to original default (1.3 deg).
263
+ height = getattr(self.const, 'scoreboard_text_height', None) or 1.3
264
+ gap = [8 * height / 1.3, 1.6 * height / 1.3] # Gap between entries [between_columns, between_rows]; scales with height so the whole board shrinks together
265
+ cols=len(scores[0])
266
+ rws=len(scores)
267
+
268
+ # flatten scores
269
+ scores_flat = [item for sublist in scores for item in sublist]
270
+
271
+ # Generate position points
272
+ points = np.array([[(1 + i - (cols + 1) / 2) * gap[0], (1 + j - (rws + 1) / 2) * gap[1]]
273
+ for j in range(rws) for i in range(cols)])
274
+ # Reverse the order of the rows (to run from top to bottom)
275
+ points = points.reshape(rws, cols, 2)[::-1].reshape(-1, 2)
276
+
277
+ # Make the score display elements
278
+ elements = []
279
+ for Idx in range(len(scores_flat)):
280
+ # The task names are the first column. Anchor them on the right so that a
281
+ # long name grows away from the scores instead of running into them.
282
+ name_column = Idx % cols == 0
283
+ element = visual.TextStim(win=self.screen.window,
284
+ text = str(scores_flat[Idx]), # Variable text
285
+ font = 'Ubuntu Mono',
286
+ pos = points[Idx],
287
+ height = height,
288
+ wrapWidth = 100,
289
+ color=[-1, -1, -1],
290
+ ori = 0,
291
+ anchorHoriz = 'right' if name_column else 'center',
292
+ alignText = 'right' if name_column else 'center',
293
+ units='deg')
294
+ elements.append(element)
295
+ # Draw the scores
296
+ [el.draw() for el in elements]
297
+ self.screen.window.flip()
298
+
299
+ # Wait for a key press to continue.
300
+ # By default, *any* key will dismiss the scoreboard.
301
+ # If `continue_key` is defined in `constants.py`,
302
+ # only that key will be accepted (useful for tasks where participants press keys accidentally).
303
+ continue_key = getattr(self.const, "continue_key", None)
304
+ if continue_key:
305
+ event.waitKeys(keyList=[continue_key])
306
+ else:
307
+ event.waitKeys()
308
+
309
+
310
+ def start_eyetracker(self):
311
+ """
312
+ sets up a connection with the eyetracker and start recording eye position
313
+ """
314
+
315
+ # opening an edf file to store eye recordings
316
+ ## the file name should not have too many characters (<=8?)
317
+ ### get the run number
318
+ self.tk_filename = f"s_{self.run_number}.edf"
319
+ # self.tk_filename = f"{self.subj_id}_r{self.run_number}.edf"
320
+ self.tk.openDataFile(self.tk_filename)
321
+ # set the sampling rate for the eyetracker
322
+ ## you can set it to 500 or 250
323
+ self.tk.sendCommand("sample_rate 500")
324
+ # start eyetracking and send a text message to tag the start of the file
325
+ self.tk.startRecording(1, 1, 1, 1)
326
+ # pl.sendMessageToFile(f"task_name: {self.task_name} start_track: {pl.currentUsec()}")
327
+ return
328
+
329
+ def stop_eyetracker(self):
330
+ """
331
+ Stop recording and close the EDF file.
332
+
333
+ - Receiving the EDF file takes time and might be problematic during scanning.
334
+ - It may be better to copy the EDF files from the EyeLink host computer afterwards.
335
+ """
336
+ self.tk.stopRecording()
337
+ self.tk.closeDataFile()
338
+ # self.tk.receiveDataFile(self.tk_filename, self.tk_filename)
339
+ self.tk.close()
340
+ return
@@ -0,0 +1,77 @@
1
+ """Download task stimuli from Zenodo into a local cache folder.
2
+
3
+ mtb fetch # stimuli of every task
4
+ mtb fetch reading movie # only these tasks
5
+
6
+ Run it once while online (e.g. before going to an offline scanner computer).
7
+ Set the MTB_STIMULI environment variable to keep the stimuli somewhere else.
8
+ """
9
+ import argparse
10
+ import json
11
+ import os
12
+ import shutil
13
+ import urllib.request
14
+ import zipfile
15
+ from pathlib import Path
16
+ import pooch
17
+
18
+ # MultiTaskBattery stimuli v1.0.0 (doi:10.5281/zenodo.23749589). Each version of
19
+ # the record has its own ID, so this pins the stimuli to the ones MTB was tested with.
20
+ ZENODO_RECORD = '23749589'
21
+
22
+ # The OS's per-user cache folder (e.g. ~/.cache/MultiTaskBattery on Linux), unless MTB_STIMULI is set
23
+ CACHE_DIR = Path(os.environ.get('MTB_STIMULI') or pooch.os_cache('MultiTaskBattery') / 'stimuli')
24
+
25
+
26
+ def record_files():
27
+ """Return {task: md5} for the zips in the Zenodo record (one zip per task)."""
28
+ with urllib.request.urlopen(f'https://zenodo.org/api/records/{ZENODO_RECORD}') as r:
29
+ files = json.load(r)['files']
30
+ return {f['key'][:-len('.zip')]: f['checksum'].split(':')[1] for f in files if f['key'].endswith('.zip')}
31
+
32
+
33
+ def fetch(tasks=None):
34
+ """Download and unpack the stimuli of the given tasks (default: all) into CACHE_DIR.
35
+
36
+ A task already unpacked from the same zip is skipped, so fetch can be rerun
37
+ safely; a task unpacked from an older zip is replaced.
38
+
39
+ Args:
40
+ tasks (list of str): task names; tasks that use no stimuli are skipped.
41
+ Returns:
42
+ Path: the cache folder.
43
+ """
44
+ available = record_files()
45
+ if tasks is None:
46
+ tasks = list(available)
47
+ elif unknown := [t for t in tasks if t not in available]:
48
+ print(f'No stimuli to fetch for: {", ".join(unknown)} (these tasks need none, or are not in MTB)')
49
+ tasks = [t for t in tasks if t in available]
50
+ for task in tasks:
51
+ md5 = available[task]
52
+ marker = CACHE_DIR / task / '.zenodo_md5'
53
+ if marker.exists() and marker.read_text() == md5:
54
+ continue
55
+ print(f'Fetching {task} stimuli ...')
56
+ zip_path = pooch.retrieve(
57
+ f'https://zenodo.org/records/{ZENODO_RECORD}/files/{task}.zip?download=1',
58
+ known_hash=f'md5:{md5}', fname=f'{task}.zip', path=CACHE_DIR / '_zips')
59
+ shutil.rmtree(CACHE_DIR / task, ignore_errors=True)
60
+ with zipfile.ZipFile(zip_path) as zf:
61
+ zf.extractall(CACHE_DIR)
62
+ marker.write_text(md5)
63
+ os.remove(zip_path)
64
+ return CACHE_DIR
65
+
66
+
67
+ def main():
68
+ parser = argparse.ArgumentParser(prog='mtb', description='MultiTaskBattery command line tools')
69
+ sub = parser.add_subparsers(dest='command', required=True)
70
+ p = sub.add_parser('fetch', help='download task stimuli from Zenodo')
71
+ p.add_argument('tasks', nargs='*', help='tasks to fetch (default: all)')
72
+ args = parser.parse_args()
73
+ print(f'Stimuli are in {fetch(args.tasks or None)}')
74
+
75
+
76
+ if __name__ == '__main__':
77
+ main()