Repository: nipreps/nibabies

Issue:
Issue finding age in months from participants.tsv for v24.0.0
### What happened?

When submitting recent processing jobs with v24.0.0 I have been running into an error when trying to collect the age in months information from the participants.tsv that I've been using. Specifying the age in months with the `--age-months` flag and not using a participants.tsv can get around in the error in the meantime. 

### What command did you use?

```shell
singularity run --cleanenv \
 -B ${data_dir}/bids_dir/sub-${subj_id}_ses-${ses_id}:/bids_dir \
 -B ${data_dir}/processed/nibabies/sub-${subj_id}_ses-${ses_id}:/output_dir \
 -B ${data_dir}/work_dir/nibabies/sub-${subj_id}_ses-${ses_id}:/wd \
 -B ${data_dir}/processed/bibsnet/sub-${subj_id}_ses-${ses_id}:/derivatives \
 -B ${run_dir}/license.txt:/license.txt \
 /home/faird/shared/code/external/pipelines/nibabies/nibabies_24.0.0.sif \
 /bids_dir /output_dir participant \
--output-spaces MNI152NLin6Asym:res-2 \
--fs-license-file /license.txt \
--omp-nthreads 8 \
--nprocs 8 \
--cifti-output 91k \
-vv \
--project-goodvoxels \
--fd-radius 45 \
--derivatives /derivatives \
--surface-recon-method ${recon} \
-w /wd


and here is the first 5 lines of the participants.tsv I was using:
participant_id	session	age
sub-100619	ses-17mo	17
sub-100619	ses-23mo	23
sub-102606	ses-10mo	10
sub-102606	ses-19mo	19
```


### What version of NiBabies are you using?

v24.0.0

### Relevant log output

```shell
Traceback (most recent call last):
  File "/opt/conda/envs/nibabies/bin/nibabies", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "/opt/conda/envs/nibabies/lib/python3.11/site-packages/nibabies/cli/run.py", line 62, in main
    retval = build_workflow(config_file)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/conda/envs/nibabies/lib/python3.11/site-packages/nibabies/cli/workflow.py", line 76, in build_workflow
    retval['workflow'] = init_nibabies_wf(config.execution.unique_labels)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/conda/envs/nibabies/lib/python3.11/site-packages/nibabies/workflows/base.py", line 124, in init_nibabies_wf
    age = parse_bids_for_age_months(config.execution.bids_dir, subject_id, session_id)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/conda/envs/nibabies/lib/python3.11/site-packages/nibabies/utils/bids.py", line 261, in parse_bids_for_age_months
    age = _get_age_from_tsv(participants_tsv, index_column='participant_id', index_val=subject)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/conda/envs/nibabies/lib/python3.11/site-packages/nibabies/utils/bids.py", line 284, in _get_age_from_tsv
    idx = df.index[df[index_column] == index_val].item()
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/conda/envs/nibabies/lib/python3.11/site-packages/pandas/core/base.py", line 418, in item
    raise ValueError("can only convert an array of size 1 to a Python scalar")
ValueError: can only convert an array of size 1 to a Python scalar
```


### Add any additional information or context about the problem here.

If any more information is needed please let me know. Happy to help with testing/troubleshooting as well, thanks!

Discussion:
mgxd: @erikglee @scott-huberty @lundq163

Please take a look and/or give this branch a try

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 816907f..32088ec 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -12,7 +12,8 @@ repos:
       - id: check-toml
       - id: check-added-large-files
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.2.0
+    rev: v0.6.5
     hooks:
       - id: ruff
+        args: [ --fix ]
       - id: ruff-format
diff --git a/nibabies/_warnings.py b/nibabies/_warnings.py
deleted file mode 100644
index ef4677f..0000000
--- a/nibabies/_warnings.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Manipulate Python warnings."""
-
-import logging
-import warnings
-
-_wlog = logging.getLogger('py.warnings')
-_wlog.addHandler(logging.NullHandler())
-
-
-def _warn(message, category=None, stacklevel=1, source=None):
-    """Redefine the warning function."""
-    if category is not None:
-        category = type(category).__name__
-        category = category.replace('type', 'WARNING')
-
-    logging.getLogger('py.warnings').warning(f"{category or 'WARNING'}: {message}")
-
-
-def _showwarning(message, category, filename, lineno, file=None, line=None):
-    _warn(message, category=category)
-
-
-warnings.warn = _warn
-warnings.showwarning = _showwarning
diff --git a/nibabies/utils/bids.py b/nibabies/utils/bids.py
index a8cd127..f8076c3 100644
--- a/nibabies/utils/bids.py
+++ b/nibabies/utils/bids.py
@@ -8,8 +8,11 @@ import json
 import os
 import sys
 import typing as ty
+import warnings
 from pathlib import Path
 
+import pandas as pd
+
 SUPPORTED_AGE_UNITS = (
     'weeks',
     'months',
@@ -244,7 +247,11 @@ def parse_bids_for_age_months(
 
     scans_tsv = session_level / f'{prefix}_scans.tsv'
     if scans_tsv.exists():
-        age = _get_age_from_tsv(scans_tsv)
+        age = _get_age_from_tsv(
+            scans_tsv,
+            index_column='filename',
+            index_val=r'^anat.*',
+        )
 
     if age is not None:
         return age
@@ -258,16 +265,18 @@ def parse_bids_for_age_months(
 
     participants_tsv = Path(bids_root) / 'participants.tsv'
     if participants_tsv.exists() and age is None:
-        age = _get_age_from_tsv(participants_tsv, index_column='participant_id', index_val=subject)
+        age = _get_age_from_tsv(
+            participants_tsv, index_column='participant_id', index_value=subject
+        )
 
     return age
 
 
 def _get_age_from_tsv(
-    bids_tsv: Path, index_column: str | None = None, index_val: str | None = None
-) -> int | None:
-    import pandas as pd
-
+    bids_tsv: Path,
+    index_column: str | None = None,
+    index_value: str | None = None,
+) -> float | None:
     df = pd.read_csv(str(bids_tsv), sep='\t')
     age_col = None
 
@@ -278,14 +287,18 @@ def _get_age_from_tsv(
     if age_col is None:
         return
 
-    if not index_column or not index_val:  # Just grab first value
-        idx = df.index[0]
-    else:
-        idx = df.index[df[index_column] == index_val].item()
+    df = df[df[index_column].str.fullmatch(index_value)]
+
+    # Multiple indices may be present after matching
+    if len(df) > 1:
+        warnings.warn(
+            f'Multiple matches for {index_column}:{index_value} found in {bids_tsv.name}.',
+            stacklevel=1,
+        )
 
     try:
         # extract age value from row
-        age = int(df.loc[idx, age_col].item())
+        age = float(df.loc[df.index[0], age_col].item())
     except Exception:  # noqa: BLE001
         return
 
@@ -294,7 +307,10 @@ def _get_age_from_tsv(
         bids_json = bids_tsv.with_suffix('.json')
         age_units = _get_age_units(bids_json)
         if age_units is False:
-            return None
+            raise FileNotFoundError(
+                f'Could not verify age unit for {bids_tsv.name} - ensure a sidecar JSON '
+                'describing column `age` units is available.'
+            )
     else:
         age_units = age_col.split('_')[-1]
 
@@ -318,12 +334,14 @@ def _get_age_units(bids_json: Path) -> ty.Literal['weeks', 'months', 'years', Fa
     return False
 
 
-def age_to_months(age: int, units: ty.Literal['weeks', 'months', 'years']) -> int:
+def age_to_months(age: int | float, units: ty.Literal['weeks', 'months', 'years']) -> int:
     """
     Convert a given age, in either "weeks", "months", or "years", into months.
 
     >>> age_to_months(1, 'years')
     12
+    >>> age_to_months(0.5, 'years')
+    6
     >>> age_to_months(2, 'weeks')
     0
     >>> age_to_months(3, 'weeks')
@@ -335,7 +353,7 @@ def age_to_months(age: int, units: ty.Literal['weeks', 'months', 'years']) -> in
     YEARS_TO_MONTH = 12
 
     if units == 'weeks':
-        age = round(age * WEEKS_TO_MONTH)
+        age *= WEEKS_TO_MONTH
     elif units == 'years':
         age *= YEARS_TO_MONTH
-    return age
+    return int(round(age))


Test patch:
diff --git a/nibabies/utils/tests/test_bids.py b/nibabies/utils/tests/test_bids.py
index 160421b..a4e8625 100644
--- a/nibabies/utils/tests/test_bids.py
+++ b/nibabies/utils/tests/test_bids.py
@@ -24,30 +24,59 @@ age_weeks = {'age_weeks': [4, 8, 12]}
 age_months = {'age_months': [3, 6, 9]}
 age_years = {'age_years': [1, 1, 2]}
 
+participants = {'participant_id': ['sub-1', 'sub-2', 'sub-11']}
+sessions = {'session_id': ['ses-1', 'ses-2', 'ses-3']}
+scans = {
+    'filename': [
+        'dwi/sub-01_dwi.nii.gz',
+        'anat/sub-01_T1w.nii.gz',
+        'func/sub-01_task-rest_bold.nii.gz',
+    ]
+}
+
 
 @pytest.mark.parametrize(
-    ('idx_col', 'idx_val', 'data', 'sidecar', 'expected'),
+    ('idx_col', 'idx_val', 'data', 'units', 'expected'),
     [
-        ('session_id', 'x1', age, False, None),
-        ('session_id', 'x1', age, 'months', 4),
-        ('session_id', 'x1', age, 'weeks', 1),  # Convert from 4 weeks -> 1 month
-        ('session_id', 'x1', age, ['months', 'weeks'], None),
-        ('session_id', 'x2', age_weeks, False, 2),
-        ('participant_id', 'x1', age_months, False, 3),
-        ('participant_id', 'x3', age_years, False, 24),
-        ('session_id', 'x3', {**age_months, **age}, False, 9),
-        (None, None, age_months, False, 3),
+        ('session_id', 'ses-1', age, 'months', 4),
+        ('session_id', 'ses-1', age, 'weeks', 1),  # Convert from 4 weeks -> 1 month
+        ('session_id', 'ses-2', age_weeks, False, 2),
+        ('participant_id', 'sub-1', age_months, False, 3),
+        ('participant_id', 'sub-11', age_years, False, 24),
+        ('session_id', 'ses-3', {**age_months, **age}, False, 9),
+        ('filename', r'^anat.*', age_months, False, 6),
     ],
 )
-def test_get_age_from_tsv(tmp_path, idx_col, idx_val, data, sidecar, expected):
+def test_get_age_from_tsv(tmp_path, idx_col, idx_val, data, units, expected):
     tsv_file = tmp_path / 'test-age-parsing.tsv'
-    base = {}
-    if idx_col is not None:
-        base[idx_col] = ['x1', 'x2', 'x3']
-    create_tsv({**base, **data}, tsv_file)
 
-    if sidecar:
-        create_sidecar(tsv_file, sidecar)
+    if idx_col == 'participant_id':
+        base = participants
+    elif idx_col == 'session_id':
+        base = sessions
+    elif idx_col == 'filename':
+        base = scans
+
+    create_tsv({**base, **data}, tsv_file)
+    if units:
+        create_sidecar(tsv_file, units)
 
     res = _get_age_from_tsv(tsv_file, idx_col, idx_val)
     assert res == expected
+
+
+def test_get_age_from_tsv_error(tmp_path):
+    tsv_file = tmp_path / 'participants.tsv'
+
+    create_tsv({**participants, **age}, tsv_file)
+    with pytest.raises(FileNotFoundError):
+        _get_age_from_tsv(tsv_file, 'participant_id', 'sub-1')
+
+
+def test_get_age_from_tsv_warning(tmp_path):
+    tsv_file = tmp_path / 'participants.tsv'
+    dual_participants = {'participant_id': ['sub-1', 'sub-2', 'sub-2']}
+    create_tsv({**dual_participants, **age_months}, tsv_file)
+
+    with pytest.warns(UserWarning):
+        _get_age_from_tsv(tsv_file, 'participant_id', 'sub-2')