MultiTaskBattery 1.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multitaskbattery-1.3.0/LICENSE +21 -0
- multitaskbattery-1.3.0/MultiTaskBattery/__init__.py +4 -0
- multitaskbattery-1.3.0/MultiTaskBattery/battery.py +223 -0
- multitaskbattery-1.3.0/MultiTaskBattery/experiment_block.py +340 -0
- multitaskbattery-1.3.0/MultiTaskBattery/fetch.py +77 -0
- multitaskbattery-1.3.0/MultiTaskBattery/screen.py +152 -0
- multitaskbattery-1.3.0/MultiTaskBattery/task_blocks.py +1703 -0
- multitaskbattery-1.3.0/MultiTaskBattery/task_details.json +1047 -0
- multitaskbattery-1.3.0/MultiTaskBattery/task_file.py +1833 -0
- multitaskbattery-1.3.0/MultiTaskBattery/task_table.tsv +23 -0
- multitaskbattery-1.3.0/MultiTaskBattery/ttl_clock.py +73 -0
- multitaskbattery-1.3.0/MultiTaskBattery/utils.py +201 -0
- multitaskbattery-1.3.0/MultiTaskBattery.egg-info/PKG-INFO +92 -0
- multitaskbattery-1.3.0/MultiTaskBattery.egg-info/SOURCES.txt +21 -0
- multitaskbattery-1.3.0/MultiTaskBattery.egg-info/dependency_links.txt +1 -0
- multitaskbattery-1.3.0/MultiTaskBattery.egg-info/entry_points.txt +2 -0
- multitaskbattery-1.3.0/MultiTaskBattery.egg-info/requires.txt +29 -0
- multitaskbattery-1.3.0/MultiTaskBattery.egg-info/top_level.txt +1 -0
- multitaskbattery-1.3.0/PKG-INFO +92 -0
- multitaskbattery-1.3.0/README.md +46 -0
- multitaskbattery-1.3.0/pyproject.toml +68 -0
- multitaskbattery-1.3.0/setup.cfg +4 -0
- multitaskbattery-1.3.0/tests/test_tasks.py +58 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Joern Diedrichsen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -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()
|