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