Skip to content

Commit 2e1e2de

Browse files
authored
Merge pull request #481 from nipreps/enh/multiverse-mode
enh: add multiverse option to output layout
2 parents d6af62d + e18406b commit 2e1e2de

File tree

10 files changed

+192
-35
lines changed

10 files changed

+192
-35
lines changed

nibabies/cli/parser.py

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -619,11 +619,12 @@ def _str_none(val):
619619
'--output-layout',
620620
action='store',
621621
default='bids',
622-
choices=('bids', 'legacy'),
622+
choices=('bids', 'legacy', 'multiverse'),
623623
help='Organization of outputs. bids (default) places NiBabies derivatives '
624624
'directly in the output directory, and defaults to placing FreeSurfer '
625625
'derivatives in <output-dir>/sourcedata/freesurfer. legacy creates derivative '
626-
'datasets as subdirectories of outputs.',
626+
'datasets as subdirectories of outputs. multiverse appends the version and a hash '
627+
'of parameters used to the output folder - the hash is also applied to the output files.',
627628
)
628629
g_other.add_argument(
629630
'-w',
@@ -834,27 +835,62 @@ def parse_args(args=None, namespace=None):
834835
applied."""
835836
)
836837

838+
config.workflow.skull_strip_template = config.workflow.skull_strip_template[0]
839+
837840
bids_dir = config.execution.bids_dir
838841
output_dir = config.execution.output_dir
839842
work_dir = config.execution.work_dir
840843
version = config.environment.version
841844
output_layout = config.execution.output_layout
845+
config.execution.parameters_hash = config.hash_config(config.get())
842846

843-
if config.execution.fs_subjects_dir is None:
844-
if output_layout == 'bids':
845-
config.execution.fs_subjects_dir = output_dir / 'sourcedata' / 'freesurfer'
846-
elif output_layout == 'legacy':
847-
config.execution.fs_subjects_dir = output_dir / 'freesurfer'
847+
# Multiverse behaves as a cross between bids and legacy
848848
if config.execution.nibabies_dir is None:
849-
if output_layout == 'bids':
850-
config.execution.nibabies_dir = output_dir
851-
elif output_layout == 'legacy':
852-
config.execution.nibabies_dir = output_dir / 'nibabies'
849+
match output_layout:
850+
case 'bids':
851+
config.execution.nibabies_dir = output_dir
852+
case 'legacy':
853+
config.execution.nibabies_dir = output_dir / 'nibabies'
854+
case 'multiverse':
855+
config.loggers.cli.warning(
856+
'Multiverse output selected - assigning output directory based on version'
857+
' and configuration hash.'
858+
)
859+
config.execution.nibabies_dir = (
860+
output_dir
861+
/ f'nibabies-{version.split("+", 1)[0]}-{config.execution.parameters_hash}'
862+
)
863+
case _:
864+
config.loggers.cli.warning('Unknown output layout %s', output_layout)
865+
pass
866+
867+
nibabies_dir = config.execution.nibabies_dir
868+
869+
if config.execution.fs_subjects_dir is None:
870+
match output_layout:
871+
case 'bids':
872+
config.execution.fs_subjects_dir = output_dir / 'sourcedata' / 'freesurfer'
873+
case 'legacy':
874+
config.execution.fs_subjects_dir = output_dir / 'freesurfer'
875+
case 'multiverse':
876+
config.execution.fs_subjects_dir = (
877+
nibabies_dir / 'sourcedata' / f'freesurfer-{config.execution.parameters_hash}'
878+
)
879+
case _:
880+
pass
881+
853882
if config.workflow.surface_recon_method == 'mcribs':
854-
if output_layout == 'bids':
855-
config.execution.mcribs_dir = output_dir / 'sourcedata' / 'mcribs'
856-
elif output_layout == 'legacy':
857-
config.execution.mcribs_dir = output_dir / 'mcribs'
883+
match output_layout:
884+
case 'bids':
885+
config.execution.mcribs_dir = output_dir / 'sourcedata' / 'mcribs'
886+
case 'legacy':
887+
config.execution.mcribs_dir = output_dir / 'mcribs'
888+
case 'multiverse':
889+
config.execution.mcribs_dir = (
890+
nibabies_dir / 'sourcedata' / f'mcribs-{config.execution.parameters_hash}'
891+
)
892+
case _:
893+
pass
858894
# Ensure the directory is created
859895
config.execution.mcribs_dir.mkdir(exist_ok=True, parents=True)
860896

@@ -909,7 +945,6 @@ def parse_args(args=None, namespace=None):
909945
participant_ids=config.execution.participant_label,
910946
session_ids=config.execution.session_id,
911947
)
912-
config.workflow.skull_strip_template = config.workflow.skull_strip_template[0]
913948

914949
# finally, write config to file
915950
config_file = config.execution.work_dir / config.execution.run_uuid / 'config.toml'

nibabies/cli/run.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,16 +144,20 @@ def main():
144144
finally:
145145
from ..reports.core import generate_reports
146146

147+
add_hash = config.execution.output_layout == 'multiverse'
148+
147149
# Generate reports phase
148150
generate_reports(
149151
config.execution.unique_labels,
150152
config.execution.nibabies_dir,
151153
config.execution.run_uuid,
154+
config_hash=config.execution.parameters_hash if add_hash else None,
152155
)
153156
write_derivative_description(
154157
config.execution.bids_dir,
155158
config.execution.nibabies_dir,
156159
config.execution.dataset_links,
160+
config.execution.parameters_hash,
157161
)
158162
write_bidsignore(config.execution.nibabies_dir)
159163

nibabies/config.py

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"""
9090

9191
import os
92+
import typing as ty
9293
from multiprocessing import set_start_method
9394

9495
from templateflow.conf import TF_LAYOUT
@@ -409,6 +410,8 @@ class execution(_Config):
409410
output_spaces = None
410411
"""List of (non)standard spaces designated (with the ``--output-spaces`` flag of
411412
the command line) as spatial references for outputs."""
413+
parameters_hash = None
414+
"""Unique hash of the current configuration parameters."""
412415
reference_anat = None
413416
"""Force usage of this anatomical scan as the structural reference."""
414417
reports_only = False
@@ -792,15 +795,79 @@ def _process_initializer(cwd, omp_nthreads):
792795
os.environ['OMP_NUM_THREADS'] = f'{omp_nthreads}'
793796

794797

795-
def dismiss_echo(entities: list | None = None):
798+
def dismiss_entities(entities: list | None = None) -> list:
796799
"""Set entities to dismiss in a DerivativesDataSink."""
797800
from niworkflows.utils.connections import listify
798801

799-
entities = entities or []
802+
entities = set(entities or [])
800803
echo_idx = execution.echo_idx
801804
if echo_idx is None or len(listify(echo_idx)) > 2:
802-
entities.append('echo')
803-
return entities
805+
entities.add('echo')
806+
output_layout = execution.output_layout
807+
if output_layout != 'multiverse':
808+
entities.add('hash')
809+
return list(entities)
810+
811+
812+
DEFAULT_DISMISS_ENTITIES = dismiss_entities()
813+
814+
DEFAULT_CONFIG_HASH_FIELDS = {
815+
'execution': [
816+
'sloppy',
817+
'echo_idx',
818+
'reference_anat',
819+
],
820+
'workflow': [
821+
'surface_recon_method',
822+
'bold2anat_dof',
823+
'bold2anat_init',
824+
'dummy_scans',
825+
'fd_radius',
826+
'fmap_bspline',
827+
'fmap_demean',
828+
'force_syn',
829+
'hmc_bold_frame',
830+
'longitudinal',
831+
'medial_surface_nan',
832+
'multi_step_reg',
833+
'norm_csf',
834+
'project_goodvoxels',
835+
'regressors_dvars_th',
836+
'regressors_fd_th',
837+
'skull_strip_fixed_seed',
838+
'skull_strip_template',
839+
'skull_strip_anat',
840+
'slice_time_ref',
841+
'surface_recon_method',
842+
'use_bbr',
843+
'use_syn_sdc',
844+
'me_t2s_fit_method',
845+
],
846+
}
847+
848+
849+
def hash_config(
850+
conf: dict[str, ty.Any],
851+
*,
852+
fields_required: dict[str, list[str]] = DEFAULT_CONFIG_HASH_FIELDS,
853+
version: str = None,
854+
digest_size: int = 4,
855+
) -> str:
856+
"""
857+
Generate a unique BLAKE2b hash of configuration attributes.
858+
859+
By default, uses a preselected list of workflow-altering parameters.
860+
"""
861+
import json
862+
from hashlib import blake2b
863+
864+
if version is None:
865+
from nibabies import __version__ as version
804866

867+
data = {}
868+
for level, fields in fields_required.items():
869+
for f in fields:
870+
data[f] = conf[level].get(f, None)
805871

806-
DEFAULT_DISMISS_ENTITIES = dismiss_echo()
872+
datab = json.dumps(data, sort_keys=True).encode()
873+
return blake2b(datab, digest_size=digest_size).hexdigest()

nibabies/reports/core.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ def run_reports(
1010
subject,
1111
run_uuid,
1212
session=None,
13-
out_filename=None,
13+
bootstrap_file=None,
14+
out_filename='report.html',
1415
reportlets_dir=None,
15-
packagename=None,
1616
):
1717
"""
1818
Run the reports.
@@ -22,17 +22,20 @@ def run_reports(
2222
run_uuid,
2323
subject=subject,
2424
session=session,
25-
bootstrap_file=load_data.readable('reports-spec.yml'),
25+
bootstrap_file=load_data('reports-spec.yml'),
2626
reportlets_dir=reportlets_dir,
27+
out_filename=out_filename,
2728
).generate_report()
2829

2930

3031
def generate_reports(
3132
sub_ses_list,
3233
output_dir,
3334
run_uuid,
35+
*,
3436
work_dir=None,
35-
packagename=None,
37+
bootstrap_file=None,
38+
config_hash=None,
3639
):
3740
"""Execute run_reports on a list of subjects."""
3841
reportlets_dir = None
@@ -41,14 +44,23 @@ def generate_reports(
4144

4245
report_errors = []
4346
for subject, session in sub_ses_list:
47+
# Determine the output filename
48+
html_report = f'sub-{subject}'
49+
if session is not None:
50+
html_report += f'_ses-{session}'
51+
if config_hash is not None:
52+
html_report += f'_{config_hash}'
53+
html_report += '.html'
54+
4455
report_errors.append(
4556
run_reports(
4657
output_dir,
4758
subject,
4859
run_uuid,
49-
session=session,
50-
packagename=packagename,
60+
bootstrap_file=bootstrap_file,
5161
reportlets_dir=reportlets_dir,
62+
out_filename=html_report,
63+
session=session,
5264
)
5365
)
5466

nibabies/tests/test_config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,19 @@ def _load_spaces(age):
146146
# Conditional based on workflow necessities
147147
spaces = init_workflow_spaces(init_execution_spaces(), age)
148148
return spaces
149+
150+
151+
def test_hash_config():
152+
# This may change with changes to config defaults / new attributes!
153+
expected = 'cfee5aaf'
154+
assert config.hash_config(config.get()) == expected
155+
_reset_config()
156+
157+
config.execution.log_level = 5 # non-vital attributes do not matter
158+
assert config.hash_config(config.get()) == expected
159+
_reset_config()
160+
161+
# but altering a vital attribute will create a new hash
162+
config.workflow.surface_recon_method = 'mcribs'
163+
assert config.hash_config(config.get()) != expected
164+
_reset_config()

nibabies/utils/bids.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def write_bidsignore(deriv_dir):
4040
ignore_file.write_text('\n'.join(bids_ignore) + '\n')
4141

4242

43-
def write_derivative_description(bids_dir, deriv_dir, dataset_links=None):
43+
def write_derivative_description(bids_dir, deriv_dir, dataset_links=None, config_hash=None):
4444
from nibabies import __version__
4545

4646
DOWNLOAD_URL = f'https://github.com/nipreps/nibabies/archive/{__version__}.tar.gz'
@@ -56,6 +56,7 @@ def write_derivative_description(bids_dir, deriv_dir, dataset_links=None):
5656
'Name': 'NiBabies',
5757
'Version': __version__,
5858
'CodeURL': DOWNLOAD_URL,
59+
'ConfigurationHash': config_hash,
5960
}
6061
],
6162
'HowToAcknowledge': 'TODO',

nibabies/utils/derivatives.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ def copy_derivatives(
136136
modality: str,
137137
subject_id: str,
138138
session_id: str | None = None,
139+
config_hash: str | None = None,
139140
) -> None:
140141
"""
141142
Creates a copy of any found derivatives into output directory.
@@ -154,8 +155,19 @@ def copy_derivatives(
154155
if not isinstance(deriv, str):
155156
continue
156157
deriv = Path(deriv)
157-
158-
shutil.copy2(deriv, outpath / deriv.name)
159-
json = deriv.parent / (deriv.name.split('.')[0] + '.json')
158+
outname = deriv.name
159+
160+
if config_hash:
161+
ents = outname.split('_')
162+
if any(ent.startswith('hash-') for ent in ents):
163+
# Avoid adding another hash
164+
pass
165+
else:
166+
idx = 2 if ents[1].startswith('ses-') else 1
167+
ents.insert(idx, f'hash-{config_hash}')
168+
outname = '_'.join(ents)
169+
170+
shutil.copy2(deriv, outpath / outname)
171+
json = deriv.parent / (outname.split('.')[0] + '.json')
160172
if json.exists():
161173
shutil.copy2(json, outpath / json.name)

nibabies/workflows/base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,9 @@ def init_single_subject_wf(
330330
modality='anat',
331331
subject_id=f'sub-{subject_id}',
332332
session_id=f'ses-{session_id}' if session_id else None,
333+
config_hash=config.execution.parameters_hash
334+
if config.execution.output_layout == 'multiverse'
335+
else None,
333336
)
334337

335338
# Determine some session level options here, as we should have
@@ -368,7 +371,6 @@ def init_single_subject_wf(
368371
)
369372

370373
anat = reference_anat.lower() # To be used for workflow connections
371-
372374
LOGGER.info(
373375
'Collected the following data for %s:\nRaw:\n%s\n\nDerivatives:\n\n%s\n',
374376
f'sub-{subject_id}' if not session_id else f'sub-{subject_id}_ses-{session_id}',
@@ -739,6 +741,9 @@ def init_single_subject_wf(
739741
modality='func',
740742
subject_id=f'sub-{subject_id}',
741743
session_id=f'ses-{session_id}' if session_id else None,
744+
config_hash=config.execution.parameters_hash
745+
if config.execution.output_layout == 'multiverse'
746+
else None,
742747
)
743748

744749
bold_wf = init_bold_wf(
@@ -837,6 +842,10 @@ def clean_datasinks(workflow: pe.Workflow) -> pe.Workflow:
837842
for node in workflow.list_node_names():
838843
if node.split('.')[-1].startswith('ds_'):
839844
workflow.get_node(node).interface.out_path_base = ''
845+
workflow.get_node(node).interface.inputs.base_directory = config.execution.nibabies_dir
846+
847+
if config.execution.output_layout == 'multiverse':
848+
workflow.get_node(node).interface.inputs.hash = config.execution.parameters_hash
840849
return workflow
841850

842851

0 commit comments

Comments
 (0)