bagel 0.5.1.dev7__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.
- bagel/__init__.py +0 -0
- bagel/cli.py +520 -0
- bagel/dictionary_models.py +164 -0
- bagel/logger.py +63 -0
- bagel/mappings.py +102 -0
- bagel/models.py +97 -0
- bagel/pipeline-catalog/.git +1 -0
- bagel/pipeline-catalog/.pre-commit-config.yaml +16 -0
- bagel/pipeline-catalog/LICENSE +21 -0
- bagel/pipeline-catalog/README.md +2 -0
- bagel/pipeline-catalog/conversion/conversion.json +20 -0
- bagel/pipeline-catalog/processing/processing.json +36 -0
- bagel/utilities/__init__.py +0 -0
- bagel/utilities/bids_utils.py +74 -0
- bagel/utilities/derivative_utils.py +138 -0
- bagel/utilities/file_utils.py +96 -0
- bagel/utilities/model_utils.py +119 -0
- bagel/utilities/pheno_utils.py +590 -0
- bagel-0.5.1.dev7.dist-info/METADATA +146 -0
- bagel-0.5.1.dev7.dist-info/RECORD +23 -0
- bagel-0.5.1.dev7.dist-info/WHEEL +4 -0
- bagel-0.5.1.dev7.dist-info/entry_points.txt +2 -0
- bagel-0.5.1.dev7.dist-info/licenses/LICENSE +21 -0
bagel/__init__.py
ADDED
|
File without changes
|
bagel/cli.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from bids import BIDSLayout
|
|
5
|
+
|
|
6
|
+
from bagel import mappings, models
|
|
7
|
+
|
|
8
|
+
from .logger import VerbosityLevel, configure_logger, log_error, logger
|
|
9
|
+
from .utilities import (
|
|
10
|
+
bids_utils,
|
|
11
|
+
derivative_utils,
|
|
12
|
+
file_utils,
|
|
13
|
+
model_utils,
|
|
14
|
+
pheno_utils,
|
|
15
|
+
)
|
|
16
|
+
from .utilities.derivative_utils import PROC_STATUS_COLS
|
|
17
|
+
|
|
18
|
+
CUSTOM_SESSION_LABEL = "ses-unnamed"
|
|
19
|
+
|
|
20
|
+
bagel = typer.Typer(
|
|
21
|
+
help="""
|
|
22
|
+
A command-line tool for creating valid, subject-level instances of the Neurobagel graph data model.\n
|
|
23
|
+
The 'pheno' command must always be run first to generate the input .jsonld file required for the 'bids' command.
|
|
24
|
+
|
|
25
|
+
To view the arguments for a specific command, run: bagel [COMMAND] --help
|
|
26
|
+
""",
|
|
27
|
+
# From https://github.com/tiangolo/typer/issues/201#issuecomment-744151303
|
|
28
|
+
context_settings={"help_option_names": ["--help", "-h"]},
|
|
29
|
+
rich_markup_mode="rich",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# NOTE: We use a reusable option instead of a callback to avoid the complexity
|
|
34
|
+
# of needing to specify the flag before the actual CLI command names
|
|
35
|
+
def verbosity_option():
|
|
36
|
+
"""Create a reusable verbosity option for commands."""
|
|
37
|
+
return typer.Option(
|
|
38
|
+
VerbosityLevel.INFO,
|
|
39
|
+
"--verbosity",
|
|
40
|
+
"-v",
|
|
41
|
+
callback=configure_logger,
|
|
42
|
+
help="Set the verbosity level of the output. 0 = show errors only; 1 = show errors, warnings, and informational messages; 3 = show all logs, including debug messages.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def overwrite_option():
|
|
47
|
+
"""Create a reusable overwrite option for commands."""
|
|
48
|
+
return typer.Option(
|
|
49
|
+
False,
|
|
50
|
+
"--overwrite",
|
|
51
|
+
"-f",
|
|
52
|
+
help="Overwrite output file if it already exists.",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# TODO: Look into whitespace for command docstring - seems to be preserved in the help text.
|
|
57
|
+
@bagel.command()
|
|
58
|
+
def pheno(
|
|
59
|
+
pheno: Path = typer.Option( # TODO: Rename argument to something clearer, like --tabular.
|
|
60
|
+
...,
|
|
61
|
+
"--pheno",
|
|
62
|
+
"-t", # for tabular
|
|
63
|
+
help="The path to a phenotypic .tsv file",
|
|
64
|
+
exists=True,
|
|
65
|
+
file_okay=True,
|
|
66
|
+
dir_okay=False,
|
|
67
|
+
resolve_path=True,
|
|
68
|
+
),
|
|
69
|
+
dictionary: Path = typer.Option(
|
|
70
|
+
...,
|
|
71
|
+
"--dictionary",
|
|
72
|
+
"-d",
|
|
73
|
+
help="The path to the .json data dictionary corresponding to the phenotypic .tsv file.",
|
|
74
|
+
exists=True,
|
|
75
|
+
file_okay=True,
|
|
76
|
+
dir_okay=False,
|
|
77
|
+
resolve_path=True,
|
|
78
|
+
),
|
|
79
|
+
name: str = typer.Option(
|
|
80
|
+
...,
|
|
81
|
+
"--name",
|
|
82
|
+
"-n",
|
|
83
|
+
callback=pheno_utils.validate_dataset_name,
|
|
84
|
+
help="The full name of the dataset. "
|
|
85
|
+
"This name will be displayed when users discover the dataset in a Neurobagel query. "
|
|
86
|
+
"For a dataset with BIDS data, the name should ideally match the dataset_description.json 'name' field. "
|
|
87
|
+
'Enclose in quotes, e.g.: --name "my dataset name"',
|
|
88
|
+
),
|
|
89
|
+
portal: str = typer.Option(
|
|
90
|
+
None,
|
|
91
|
+
"--portal",
|
|
92
|
+
"-u", # for URL
|
|
93
|
+
callback=pheno_utils.validate_portal_uri,
|
|
94
|
+
help="URL (HTTP/HTTPS) to a website or page that describes the dataset and access instructions (if available).",
|
|
95
|
+
),
|
|
96
|
+
output: Path = typer.Option(
|
|
97
|
+
"pheno.jsonld",
|
|
98
|
+
"--output",
|
|
99
|
+
"-o",
|
|
100
|
+
help="The path to the output .jsonld file.",
|
|
101
|
+
file_okay=True,
|
|
102
|
+
dir_okay=False,
|
|
103
|
+
resolve_path=True,
|
|
104
|
+
),
|
|
105
|
+
overwrite: bool = overwrite_option(),
|
|
106
|
+
verbosity: VerbosityLevel = verbosity_option(),
|
|
107
|
+
):
|
|
108
|
+
"""
|
|
109
|
+
Process a tabular phenotypic file (.tsv) that has been successfully annotated
|
|
110
|
+
with the Neurobagel annotation tool. The annotations are expected to be stored
|
|
111
|
+
in a data dictionary (.json).
|
|
112
|
+
|
|
113
|
+
This command will create a valid, subject-level instance of the Neurobagel
|
|
114
|
+
graph data model for the provided phenotypic file in the .jsonld format.
|
|
115
|
+
You can upload this .jsonld file to the Neurobagel graph.
|
|
116
|
+
"""
|
|
117
|
+
file_utils.check_overwrite(output, overwrite)
|
|
118
|
+
|
|
119
|
+
data_dictionary = file_utils.load_json(dictionary)
|
|
120
|
+
pheno_df = file_utils.load_tabular(pheno)
|
|
121
|
+
|
|
122
|
+
logger.info("Running initial checks of inputs...")
|
|
123
|
+
# NOTE: `width` determines the amount of padding (in num. characters) before the file paths in the print statement.
|
|
124
|
+
# It is calculated as = length of the longer string + 2 extra spaces
|
|
125
|
+
width = 26
|
|
126
|
+
logger.info("%-*s%s", width, "Tabular file (.tsv):", pheno)
|
|
127
|
+
logger.info("%-*s%s", width, "Data dictionary (.json):", dictionary)
|
|
128
|
+
pheno_utils.validate_inputs(data_dictionary, pheno_df)
|
|
129
|
+
|
|
130
|
+
# TODO: Remove once we no longer support annotation tool v1 data dictionaries
|
|
131
|
+
data_dictionary = pheno_utils.convert_transformation_to_format(
|
|
132
|
+
data_dictionary
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
logger.info("Processing phenotypic annotations...")
|
|
136
|
+
subject_list = []
|
|
137
|
+
|
|
138
|
+
column_mapping = pheno_utils.map_categories_to_columns(data_dictionary)
|
|
139
|
+
tool_mapping = pheno_utils.map_tools_to_columns(data_dictionary)
|
|
140
|
+
|
|
141
|
+
# TODO: needs refactoring once we handle multiple participant IDs
|
|
142
|
+
participants = column_mapping["participant"][0]
|
|
143
|
+
|
|
144
|
+
# Note that `session_column will be None if there is no session column in the pheno.tsv
|
|
145
|
+
session_column = column_mapping.get("session")
|
|
146
|
+
|
|
147
|
+
for participant in pheno_df[participants].unique():
|
|
148
|
+
_sub_pheno = pheno_df.query(
|
|
149
|
+
f"`{participants}` == '{str(participant)}'"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
sessions = []
|
|
153
|
+
for session_row_idx, session_row in _sub_pheno.iterrows():
|
|
154
|
+
# Our data model requires a session. To support phenotypic data without sessions,
|
|
155
|
+
# we create a session with a fixed, but unusual CUSTOM_SESSION_LABEL and add the
|
|
156
|
+
# phenotypic data to that session.
|
|
157
|
+
if session_column is None:
|
|
158
|
+
session_label = CUSTOM_SESSION_LABEL
|
|
159
|
+
else:
|
|
160
|
+
# NOTE: We take the name from the first session column - we don't know how to handle multiple session columns yet
|
|
161
|
+
session_label = session_row[session_column[0]]
|
|
162
|
+
|
|
163
|
+
session = models.PhenotypicSession(hasLabel=str(session_label))
|
|
164
|
+
_ses_pheno = session_row
|
|
165
|
+
|
|
166
|
+
if "sex" in column_mapping.keys():
|
|
167
|
+
_sex_vals = pheno_utils.get_transformed_values(
|
|
168
|
+
column_mapping["sex"], _ses_pheno, data_dictionary
|
|
169
|
+
)
|
|
170
|
+
if _sex_vals:
|
|
171
|
+
# NOTE: Our data model only allows a single sex value, so we only take the first instance if multiple columns are about sex
|
|
172
|
+
session.hasSex = models.Sex(identifier=_sex_vals[0])
|
|
173
|
+
|
|
174
|
+
if "diagnosis" in column_mapping.keys():
|
|
175
|
+
_dx_vals = pheno_utils.get_transformed_values(
|
|
176
|
+
column_mapping["diagnosis"], _ses_pheno, data_dictionary
|
|
177
|
+
)
|
|
178
|
+
if not _dx_vals:
|
|
179
|
+
pass
|
|
180
|
+
# NOTE: If the subject has both a diagnosis value and a value of healthy control, we assume the healthy control designation is more important
|
|
181
|
+
# and do not assign diagnoses to the subject
|
|
182
|
+
elif mappings.NEUROBAGEL["healthy_control"] in _dx_vals:
|
|
183
|
+
session.isSubjectGroup = models.SubjectGroup(
|
|
184
|
+
identifier=mappings.NEUROBAGEL["healthy_control"],
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
session.hasDiagnosis = [
|
|
188
|
+
models.Diagnosis(identifier=_dx_val)
|
|
189
|
+
for _dx_val in _dx_vals
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
if "age" in column_mapping.keys():
|
|
193
|
+
# NOTE: At the moment, our data model only supports a single age value per subject.
|
|
194
|
+
# To achieve this, we transform the values from ALL columns annotated as about age
|
|
195
|
+
# (so we expect each of them to be valid according to the data dictionary model),
|
|
196
|
+
# but we take and store only the first instance in the graph data.
|
|
197
|
+
_age_vals = pheno_utils.get_transformed_values(
|
|
198
|
+
column_mapping["age"], _ses_pheno, data_dictionary
|
|
199
|
+
)
|
|
200
|
+
if _age_vals:
|
|
201
|
+
session.hasAge = _age_vals[0]
|
|
202
|
+
|
|
203
|
+
if tool_mapping:
|
|
204
|
+
_assessments = [
|
|
205
|
+
models.Assessment(identifier=tool)
|
|
206
|
+
for tool, columns in tool_mapping.items()
|
|
207
|
+
if pheno_utils.are_any_available(
|
|
208
|
+
columns, _ses_pheno, data_dictionary
|
|
209
|
+
)
|
|
210
|
+
]
|
|
211
|
+
if _assessments:
|
|
212
|
+
# Only set assessments for the subject if at least one has a non-missing item
|
|
213
|
+
session.hasAssessment = _assessments
|
|
214
|
+
sessions.append(session)
|
|
215
|
+
|
|
216
|
+
subject = models.Subject(
|
|
217
|
+
hasLabel=str(participant), hasSession=sessions
|
|
218
|
+
)
|
|
219
|
+
subject_list.append(subject)
|
|
220
|
+
|
|
221
|
+
dataset = models.Dataset(
|
|
222
|
+
hasLabel=name,
|
|
223
|
+
hasPortalURI=portal,
|
|
224
|
+
hasSamples=subject_list,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
file_utils.save_jsonld(
|
|
228
|
+
data=model_utils.add_context_to_graph_dataset(dataset),
|
|
229
|
+
filename=output,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@bagel.command()
|
|
234
|
+
def bids(
|
|
235
|
+
# TODO: If we wanted to make this option simpler for the user when the CLI is running in a container,
|
|
236
|
+
# we could add a sensible default file name and fix the container mount path in the Docker command
|
|
237
|
+
jsonld_path: Path = typer.Option(
|
|
238
|
+
...,
|
|
239
|
+
"--jsonld-path",
|
|
240
|
+
"-p", # for pheno
|
|
241
|
+
help="The path to the .jsonld file containing the phenotypic data for your dataset, created by the bagel pheno command. "
|
|
242
|
+
"This file may optionally also include the processing pipeline metadata for the dataset (created by the bagel derivatives command).",
|
|
243
|
+
exists=True,
|
|
244
|
+
file_okay=True,
|
|
245
|
+
dir_okay=False,
|
|
246
|
+
resolve_path=True,
|
|
247
|
+
),
|
|
248
|
+
input_bids_dir: Path = typer.Option(
|
|
249
|
+
Path.cwd() / "bids",
|
|
250
|
+
"--input-bids-dir",
|
|
251
|
+
"-i",
|
|
252
|
+
help="The path to the BIDS dataset directory to read and parse the BIDS data from. "
|
|
253
|
+
"[bold red]NOTE: Leave unset if using the Docker/Singularity version of bagel-cli.[/bold red]",
|
|
254
|
+
exists=True,
|
|
255
|
+
file_okay=False,
|
|
256
|
+
dir_okay=True,
|
|
257
|
+
resolve_path=True,
|
|
258
|
+
),
|
|
259
|
+
# TODO: Should we include a tip in the help text for using the repository root for DataLad datasets?
|
|
260
|
+
source_bids_dir: Path = typer.Option(
|
|
261
|
+
...,
|
|
262
|
+
"--source-bids-dir",
|
|
263
|
+
"-b",
|
|
264
|
+
callback=bids_utils.check_absolute_bids_path,
|
|
265
|
+
help="The absolute path to the original BIDS dataset directory location. This will be used to derive and record data source paths. "
|
|
266
|
+
"[bold red]NOTE: If running bagel-cli directly in a Python environment (not in a container), this value may be the same as --input-bids-dir.[/bold red]",
|
|
267
|
+
file_okay=False,
|
|
268
|
+
dir_okay=True,
|
|
269
|
+
),
|
|
270
|
+
# TODO: Should we rename the default output file to something more generic to account for the fact that
|
|
271
|
+
# the file may also include derivatives data? e.g., dataset_bids.jsonld
|
|
272
|
+
output: Path = typer.Option(
|
|
273
|
+
"pheno_bids.jsonld",
|
|
274
|
+
"--output",
|
|
275
|
+
"-o",
|
|
276
|
+
help="The path to the output .jsonld file.",
|
|
277
|
+
file_okay=True,
|
|
278
|
+
dir_okay=False,
|
|
279
|
+
resolve_path=True,
|
|
280
|
+
),
|
|
281
|
+
overwrite: bool = overwrite_option(),
|
|
282
|
+
verbosity: VerbosityLevel = verbosity_option(),
|
|
283
|
+
):
|
|
284
|
+
"""
|
|
285
|
+
Extract imaging metadata from a valid BIDS dataset and integrate it with
|
|
286
|
+
subjects' harmonized phenotypic data (from the bagel pheno command) and, optionally,
|
|
287
|
+
processing pipeline metadata (from the bagel derivatives command) in a single .jsonld file.
|
|
288
|
+
NOTE: Must be run AFTER the pheno command.
|
|
289
|
+
|
|
290
|
+
This command will create a valid, subject-level instance of the Neurobagel
|
|
291
|
+
graph data model for the combined metadata in the .jsonld format.
|
|
292
|
+
You can upload this .jsonld file to the Neurobagel graph.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
file_utils.check_overwrite(output, overwrite)
|
|
296
|
+
|
|
297
|
+
width = 51
|
|
298
|
+
logger.info("Running initial checks of inputs...")
|
|
299
|
+
logger.info(
|
|
300
|
+
"%-*s%s",
|
|
301
|
+
width,
|
|
302
|
+
"Existing subject graph data to augment (.jsonld):",
|
|
303
|
+
jsonld_path,
|
|
304
|
+
)
|
|
305
|
+
logger.info("%-*s%s", width, "Input BIDS directory:", input_bids_dir)
|
|
306
|
+
logger.info("%-*s%s", width, "Source BIDS directory:", source_bids_dir)
|
|
307
|
+
|
|
308
|
+
jsonld_dataset = model_utils.extract_and_validate_jsonld_dataset(
|
|
309
|
+
jsonld_path
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
existing_subs_dict = model_utils.get_subject_instances(jsonld_dataset)
|
|
313
|
+
|
|
314
|
+
# TODO: Revert to using Layout.get_subjects() to get BIDS subjects once pybids performance is improved
|
|
315
|
+
model_utils.confirm_subs_match_pheno_data(
|
|
316
|
+
subjects=bids_utils.get_bids_subjects_simple(input_bids_dir),
|
|
317
|
+
subject_source_for_err="BIDS directory",
|
|
318
|
+
pheno_subjects=existing_subs_dict.keys(),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
logger.info("Initial checks of inputs passed.")
|
|
322
|
+
|
|
323
|
+
logger.info(
|
|
324
|
+
"Parsing and validating BIDS dataset. This may take a while..."
|
|
325
|
+
)
|
|
326
|
+
# NOTE: If there are no subjects in the BIDS dataset, the validation should fail.
|
|
327
|
+
# The rest of this workflow assumes there's at least one subject in the BIDS dataset.
|
|
328
|
+
layout = BIDSLayout(input_bids_dir, validate=True)
|
|
329
|
+
logger.info("BIDS parsing completed.")
|
|
330
|
+
|
|
331
|
+
logger.info("Merging BIDS metadata with existing subject annotations...")
|
|
332
|
+
for bids_sub_id in layout.get_subjects():
|
|
333
|
+
existing_subject = existing_subs_dict[f"sub-{bids_sub_id}"]
|
|
334
|
+
existing_sessions_dict = model_utils.get_imaging_session_instances(
|
|
335
|
+
existing_subject
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
bids_sessions = layout.get_sessions(subject=bids_sub_id)
|
|
339
|
+
if not bids_sessions:
|
|
340
|
+
if not layout.get_datatypes(subject=bids_sub_id):
|
|
341
|
+
continue
|
|
342
|
+
bids_sessions = [None]
|
|
343
|
+
|
|
344
|
+
# For some reason .get_sessions() doesn't always follow alphanumeric order
|
|
345
|
+
# By default (without sorting) the session lists look like ["02", "01"] per subject
|
|
346
|
+
for session_id in sorted(bids_sessions):
|
|
347
|
+
image_list = bids_utils.create_acquisitions(
|
|
348
|
+
layout=layout,
|
|
349
|
+
bids_sub_id=bids_sub_id,
|
|
350
|
+
session=session_id,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
if not image_list:
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
# TODO: Currently if a subject has BIDS data but no "ses-" directories (e.g., only 1 session),
|
|
357
|
+
# we create a session with a fixed, but unusual CUSTOM_SESSION_LABEL
|
|
358
|
+
# and add the imaging data info to a session with that label (or create it first).
|
|
359
|
+
# However, we still provide the BIDS SUBJECT directory as the session path, instead of making up a path.
|
|
360
|
+
# This should be revisited in the future as for these cases the resulting dataset object is not
|
|
361
|
+
# an exact representation of what's on disk.
|
|
362
|
+
# Here, we also need to add back "ses" prefix because pybids stripped it
|
|
363
|
+
session_label = (
|
|
364
|
+
CUSTOM_SESSION_LABEL
|
|
365
|
+
if session_id is None
|
|
366
|
+
else f"ses-{session_id}"
|
|
367
|
+
)
|
|
368
|
+
session_path = bids_utils.get_session_path(
|
|
369
|
+
source_bids_dir=source_bids_dir,
|
|
370
|
+
bids_sub_id=bids_sub_id,
|
|
371
|
+
session=session_id,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# If a custom Neurobagel-created session already exists (if `bagel derivatives` was run first),
|
|
375
|
+
# we add to that session when there is no session layer in the BIDS directory
|
|
376
|
+
if session_label in existing_sessions_dict:
|
|
377
|
+
existing_img_session = existing_sessions_dict[session_label]
|
|
378
|
+
existing_img_session.hasAcquisition = image_list
|
|
379
|
+
existing_img_session.hasFilePath = session_path
|
|
380
|
+
else:
|
|
381
|
+
new_imaging_session = models.ImagingSession(
|
|
382
|
+
hasLabel=session_label,
|
|
383
|
+
hasFilePath=session_path,
|
|
384
|
+
hasAcquisition=image_list,
|
|
385
|
+
)
|
|
386
|
+
existing_subject.hasSession.append(new_imaging_session)
|
|
387
|
+
|
|
388
|
+
file_utils.save_jsonld(
|
|
389
|
+
data=model_utils.add_context_to_graph_dataset(jsonld_dataset),
|
|
390
|
+
filename=output,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@bagel.command()
|
|
395
|
+
def derivatives(
|
|
396
|
+
tabular: Path = typer.Option(
|
|
397
|
+
...,
|
|
398
|
+
"--tabular",
|
|
399
|
+
"-t",
|
|
400
|
+
help="The path to a .tsv containing subject-level processing pipeline status info. Expected to comply with the Nipoppy processing status file schema.",
|
|
401
|
+
exists=True,
|
|
402
|
+
file_okay=True,
|
|
403
|
+
dir_okay=False,
|
|
404
|
+
resolve_path=True,
|
|
405
|
+
),
|
|
406
|
+
# TODO: Remove _path?
|
|
407
|
+
jsonld_path: Path = typer.Option(
|
|
408
|
+
...,
|
|
409
|
+
"--jsonld-path",
|
|
410
|
+
"-p", # for pheno
|
|
411
|
+
help="The path to a .jsonld file containing the phenotypic data for your dataset, created by the bagel pheno command. This JSONLD may optionally also include the BIDS metadata for the dataset (created by the bagel bids command).",
|
|
412
|
+
exists=True,
|
|
413
|
+
file_okay=True,
|
|
414
|
+
dir_okay=False,
|
|
415
|
+
resolve_path=True,
|
|
416
|
+
),
|
|
417
|
+
output: Path = typer.Option(
|
|
418
|
+
"pheno_derivatives.jsonld",
|
|
419
|
+
"--output",
|
|
420
|
+
"-o",
|
|
421
|
+
help="The path to the output .jsonld file.",
|
|
422
|
+
file_okay=True,
|
|
423
|
+
dir_okay=False,
|
|
424
|
+
resolve_path=True,
|
|
425
|
+
),
|
|
426
|
+
overwrite: bool = overwrite_option(),
|
|
427
|
+
verbosity: VerbosityLevel = verbosity_option(),
|
|
428
|
+
):
|
|
429
|
+
"""
|
|
430
|
+
Extract subject processing pipeline and derivative metadata from a tabular processing status file and
|
|
431
|
+
integrate them in a single .jsonld with subjects' harmonized phenotypic data (from the bagel pheno command) and optionally,
|
|
432
|
+
BIDS metadata (from the bagel bids command).
|
|
433
|
+
NOTE: Must be run AFTER the pheno command.
|
|
434
|
+
|
|
435
|
+
This command will create a valid, subject-level instance of the Neurobagel
|
|
436
|
+
graph data model for the combined metadata in the .jsonld format.
|
|
437
|
+
You can upload this .jsonld file to the Neurobagel graph.
|
|
438
|
+
"""
|
|
439
|
+
|
|
440
|
+
file_utils.check_overwrite(output, overwrite)
|
|
441
|
+
|
|
442
|
+
width = 51
|
|
443
|
+
logger.info("Processing subject-level derivative metadata...")
|
|
444
|
+
logger.info(
|
|
445
|
+
"%-*s%s",
|
|
446
|
+
width,
|
|
447
|
+
"Existing subject graph data to augment (.jsonld):",
|
|
448
|
+
jsonld_path,
|
|
449
|
+
)
|
|
450
|
+
logger.info("%-*s%s", width, "Processing status file (.tsv):", tabular)
|
|
451
|
+
|
|
452
|
+
status_df = file_utils.load_tabular(
|
|
453
|
+
tabular, input_type="processing status"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# We don't allow empty values in the participant ID column
|
|
457
|
+
if row_indices := pheno_utils.get_rows_with_empty_strings(
|
|
458
|
+
status_df, [PROC_STATUS_COLS["participant"]]
|
|
459
|
+
):
|
|
460
|
+
log_error(
|
|
461
|
+
logger,
|
|
462
|
+
f"Your processing status file contains missing values in the column '{PROC_STATUS_COLS['participant']}'. "
|
|
463
|
+
"Please ensure that every row has a non-empty participant id. "
|
|
464
|
+
f"We found missing values in the following rows (first row is zero): {row_indices}.",
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
derivative_utils.check_at_least_one_pipeline_version_is_recognized(
|
|
468
|
+
status_df=status_df
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
jsonld_dataset = model_utils.extract_and_validate_jsonld_dataset(
|
|
472
|
+
jsonld_path
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
existing_subs_dict = model_utils.get_subject_instances(jsonld_dataset)
|
|
476
|
+
|
|
477
|
+
model_utils.confirm_subs_match_pheno_data(
|
|
478
|
+
subjects=status_df[PROC_STATUS_COLS["participant"]].unique(),
|
|
479
|
+
subject_source_for_err="processing status file",
|
|
480
|
+
pheno_subjects=existing_subs_dict.keys(),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# Create sub-dataframes for each subject
|
|
484
|
+
for subject, sub_proc_df in status_df.groupby(
|
|
485
|
+
PROC_STATUS_COLS["participant"]
|
|
486
|
+
):
|
|
487
|
+
existing_subject = existing_subs_dict[subject]
|
|
488
|
+
|
|
489
|
+
# Note: Dictionary of existing imaging sessions can be empty if only bagel pheno was run
|
|
490
|
+
existing_sessions_dict = model_utils.get_imaging_session_instances(
|
|
491
|
+
existing_subject
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
for session_label, sub_ses_proc_df in sub_proc_df.groupby(
|
|
495
|
+
PROC_STATUS_COLS["session"]
|
|
496
|
+
):
|
|
497
|
+
completed_pipelines = derivative_utils.create_completed_pipelines(
|
|
498
|
+
sub_ses_proc_df
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
if not completed_pipelines:
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
session_label = (
|
|
505
|
+
CUSTOM_SESSION_LABEL if session_label == "" else session_label
|
|
506
|
+
)
|
|
507
|
+
if session_label in existing_sessions_dict:
|
|
508
|
+
existing_img_session = existing_sessions_dict[session_label]
|
|
509
|
+
existing_img_session.hasCompletedPipeline = completed_pipelines
|
|
510
|
+
else:
|
|
511
|
+
new_img_session = models.ImagingSession(
|
|
512
|
+
hasLabel=session_label,
|
|
513
|
+
hasCompletedPipeline=completed_pipelines,
|
|
514
|
+
)
|
|
515
|
+
existing_subject.hasSession.append(new_img_session)
|
|
516
|
+
|
|
517
|
+
file_utils.save_jsonld(
|
|
518
|
+
data=model_utils.add_context_to_graph_dataset(jsonld_dataset),
|
|
519
|
+
filename=output,
|
|
520
|
+
)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from typing import Dict, List, Union
|
|
2
|
+
|
|
3
|
+
from pydantic import (
|
|
4
|
+
AfterValidator,
|
|
5
|
+
BaseModel,
|
|
6
|
+
ConfigDict,
|
|
7
|
+
Field,
|
|
8
|
+
RootModel,
|
|
9
|
+
)
|
|
10
|
+
from pydantic_core import PydanticCustomError
|
|
11
|
+
from typing_extensions import Annotated
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_unique_list(values: List[str]) -> List[str]:
|
|
15
|
+
"""
|
|
16
|
+
Check that provided list only has unique elements.
|
|
17
|
+
|
|
18
|
+
This custom validator is needed because constrained dtypes and their `unique_items` parameter
|
|
19
|
+
were deprecated in Pydantic v2. This function was adapted from https://github.com/pydantic/pydantic-core/pull/820#issuecomment-1656228704
|
|
20
|
+
and https://docs.pydantic.dev/latest/concepts/validators/#annotated-validators.
|
|
21
|
+
|
|
22
|
+
See also:
|
|
23
|
+
- https://docs.pydantic.dev/latest/migration/#changes-to-pydanticfield
|
|
24
|
+
- https://docs.pydantic.dev/latest/api/types/#pydantic.types.conlist)
|
|
25
|
+
"""
|
|
26
|
+
if len(values) != len(set(values)):
|
|
27
|
+
raise PydanticCustomError(
|
|
28
|
+
"unique_list", f"{values} is not a unique list"
|
|
29
|
+
)
|
|
30
|
+
return values
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Identifier(BaseModel):
|
|
34
|
+
"""An identifier of a controlled term with an IRI"""
|
|
35
|
+
|
|
36
|
+
termURL: str = Field(
|
|
37
|
+
...,
|
|
38
|
+
description="An unambiguous identifier for the term, concept or entity that is referenced",
|
|
39
|
+
alias="TermURL",
|
|
40
|
+
)
|
|
41
|
+
label: str = Field(
|
|
42
|
+
...,
|
|
43
|
+
description="A human readable label. If more than one label exists for the term, "
|
|
44
|
+
"then the preferred label should be used.",
|
|
45
|
+
alias="Label",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Neurobagel(BaseModel):
|
|
50
|
+
"""The base model for a Neurobagel column annotation"""
|
|
51
|
+
|
|
52
|
+
isAbout: Identifier = Field(
|
|
53
|
+
...,
|
|
54
|
+
description="The concept or controlled term that describes this column",
|
|
55
|
+
alias="IsAbout",
|
|
56
|
+
)
|
|
57
|
+
missingValues: Annotated[
|
|
58
|
+
List[str],
|
|
59
|
+
AfterValidator(validate_unique_list),
|
|
60
|
+
Field(
|
|
61
|
+
[],
|
|
62
|
+
description="A list of unique values that represent "
|
|
63
|
+
"invalid responses, typos, or missing data",
|
|
64
|
+
alias="MissingValues",
|
|
65
|
+
json_schema_extra={"uniqueItems": True},
|
|
66
|
+
),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
model_config = ConfigDict(extra="forbid")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class CategoricalNeurobagel(Neurobagel):
|
|
73
|
+
"""A Neurobagel annotation for a categorical column"""
|
|
74
|
+
|
|
75
|
+
levels: Dict[str, Identifier] = Field(
|
|
76
|
+
...,
|
|
77
|
+
description="For categorical variables: "
|
|
78
|
+
"An object of values (keys) in the column and the semantic "
|
|
79
|
+
"term (URI and label) they are unambiguously mapped to.",
|
|
80
|
+
alias="Levels",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ContinuousNeurobagel(Neurobagel):
|
|
85
|
+
"""A Neurobagel annotation for a continuous column"""
|
|
86
|
+
|
|
87
|
+
format: Identifier = Field(
|
|
88
|
+
...,
|
|
89
|
+
description="For continuous columns this field is used to describe "
|
|
90
|
+
"the format of the raw numerical values in the column. This information is used to transform "
|
|
91
|
+
"the column values into the desired format of the standardized "
|
|
92
|
+
"data element referenced in the IsAbout attribute.",
|
|
93
|
+
alias="Format",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class IdentifierNeurobagel(Neurobagel):
|
|
98
|
+
"""A Neurobagel annotation for an identifier column"""
|
|
99
|
+
|
|
100
|
+
identifies: str = Field(
|
|
101
|
+
...,
|
|
102
|
+
description="For identifier columns, the type of observation uniquely identified by this column.",
|
|
103
|
+
alias="Identifies",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ToolNeurobagel(Neurobagel):
|
|
108
|
+
"""A Neurobagel annotation for an assessment tool column"""
|
|
109
|
+
|
|
110
|
+
# NOTE: Optional[Identifier] was removed as part of https://github.com/neurobagel/bagel-cli/pull/389
|
|
111
|
+
# because we couldn't tell what the Optional was doing
|
|
112
|
+
isPartOf: Identifier = Field(
|
|
113
|
+
...,
|
|
114
|
+
description="If the column is a subscale or item of an assessment tool "
|
|
115
|
+
"then the assessment tool should be specified here.",
|
|
116
|
+
alias="IsPartOf",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Column(BaseModel):
|
|
121
|
+
"""The base model for a BIDS column description"""
|
|
122
|
+
|
|
123
|
+
description: str = Field(
|
|
124
|
+
...,
|
|
125
|
+
description="Free-form natural language description",
|
|
126
|
+
alias="Description",
|
|
127
|
+
)
|
|
128
|
+
annotations: Union[
|
|
129
|
+
CategoricalNeurobagel,
|
|
130
|
+
ContinuousNeurobagel,
|
|
131
|
+
IdentifierNeurobagel,
|
|
132
|
+
ToolNeurobagel,
|
|
133
|
+
] = Field(None, description="Semantic annotations", alias="Annotations")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class CategoricalColumn(Column):
|
|
137
|
+
"""A BIDS column annotation for a categorical column"""
|
|
138
|
+
|
|
139
|
+
levels: Dict[str, str] = Field(
|
|
140
|
+
...,
|
|
141
|
+
description="For categorical variables: "
|
|
142
|
+
"An object of possible values (keys) "
|
|
143
|
+
"and their descriptions (values). ",
|
|
144
|
+
alias="Levels",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ContinuousColumn(Column):
|
|
149
|
+
"""A BIDS column annotation for a continuous column"""
|
|
150
|
+
|
|
151
|
+
units: str = Field(
|
|
152
|
+
None,
|
|
153
|
+
description="Measurement units for the values in this column. "
|
|
154
|
+
"SI units in CMIXF formatting are RECOMMENDED (see Units)",
|
|
155
|
+
alias="Units",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class DataDictionary(
|
|
160
|
+
RootModel[Dict[str, Union[ContinuousColumn, CategoricalColumn]]]
|
|
161
|
+
):
|
|
162
|
+
"""A data dictionary with human and machine readable information for a tabular data file"""
|
|
163
|
+
|
|
164
|
+
pass
|