Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
2.15.2 (October 2, 2026)
========================
Patch release in the 2.15.x series.

This includes two fixes for fieldmap-less estimation:

- In multi-session datasets, a subject-level anatomical reference (i.e., without
a ``session`` entity) was not found for session-specific EPI data.
A session-matched anatomical image is still preferred when available.
- ``bids_filters`` intended for fieldmaps (e.g., ``{"datatype": "fmap"}``) were also
applied when searching for the anatomical reference and EPI targets, silently
preventing fieldmap-less estimation (nipreps/fmriprep#3632).

It also fixes the PEPOLAR heuristics ignoring ``acquisition`` filters, and
finding no estimators when filtering on a single ``ceagent``.

* FIX: Do not apply fieldmap filters to fieldmap-less queries (#563)
* FIX: Allow sessionless anatomical references for fieldmap-less estimation (#562)


2.15.1 (September 28, 2026)
===========================
Patch release in the 2.15.x series.
Expand Down
63 changes: 62 additions & 1 deletion sdcflows/utils/tests/test_wrangler.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pytest
from niworkflows.utils.testing import generate_bids_skeleton

from sdcflows.fieldmaps import clear_registry, get_identifier
from sdcflows.fieldmaps import EstimatorType, clear_registry, get_identifier
from sdcflows.utils.wrangler import find_estimators


Expand Down Expand Up @@ -661,3 +661,64 @@ def test_fieldmapless(tmp_path):
)
clear_registry()
rmtree(bids_dir)


ANAT = (EstimatorType.ANAT, None)
PEPOLAR_A = (EstimatorType.PEPOLAR, 'A')
PEPOLAR_B = (EstimatorType.PEPOLAR, 'B')


@pytest.mark.parametrize(
('bids_filters', 'expected'),
[
(None, {ANAT, PEPOLAR_A, PEPOLAR_B}),
({'datatype': 'fmap'}, {ANAT, PEPOLAR_A, PEPOLAR_B}),
({'datatype': 'fmap', 'run': '9999'}, {ANAT}),
({'acquisition': 'A'}, {ANAT, PEPOLAR_A}),
({'ceagent': 'gad'}, {ANAT, PEPOLAR_A, PEPOLAR_B}),
],
ids=['none', 'datatype', 'run', 'acquisition', 'ceagent'],
)
def test_filters(tmp_path, bids_filters, expected):
"""Filters restrict fieldmaps, but not fieldmap-less anatomical references or targets."""
epi = {'suffix': 'epi', 'ce': 'gad', 'metadata': {'TotalReadoutTime': 0.05}}

spec = {
'01': {
'anat': [{'suffix': 'T1w'}],
'fmap': [
{
**epi,
'acq': acq,
'dir': pedir,
'metadata': {**epi['metadata'], 'PhaseEncodingDirection': pe},
}
for acq in ('A', 'B')
for pedir, pe in (('AP', 'j'), ('PA', 'j-'))
],
'func': [
{
'task': 'rest',
'suffix': 'bold',
'metadata': {
'RepetitionTime': 0.8,
'TotalReadoutTime': 0.5,
'PhaseEncodingDirection': 'j',
},
}
],
},
}
bids_dir = tmp_path / 'bids'
generate_bids_skeleton(bids_dir, spec)
est = find_estimators(
layout=gen_layout(bids_dir),
subject='01',
fmapless=True,
force_fmapless=True,
bids_filters=bids_filters,
)
assert len(est) == len(expected)
# The first source is the T1w for ANAT, and an EPI fieldmap for PEPOLAR
assert {(e.method, e.sources[0].entities.get('acquisition')) for e in est} == expected
clear_registry()
29 changes: 19 additions & 10 deletions sdcflows/utils/wrangler.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ def find_estimators(
The logger used to relay messages. If not provided, one will be created.
bids_filters
Optional dictionary of key/values to filter the entities on.
This allows lower level file inclusion/exclusion.
This allows lower level file inclusion/exclusion of fieldmap files.
Except for ``session``, filters are not applied when querying the
anatomical reference and targets of fieldmap-less estimation.
anat_suffix : :obj:`str` or :obj:`list`
String or list of strings to filter anatomical images for fieldmap-less
approaches. If not provided, ``T1w`` is used.
Expand Down Expand Up @@ -329,6 +331,7 @@ def find_estimators(
'part': ['mag', None],
'scope': 'raw', # Ensure derivatives are not captured
}
no_filter_entities = base_entities.copy()

if bids_filters:
filters = bids_filters.copy() # copy to avoid altering in place
Expand Down Expand Up @@ -411,12 +414,16 @@ def find_estimators(
estimators.append(e)

# A bunch of heuristics to select EPI fieldmaps
acqs = base_entities.get('acquisitions') or layout.get_acquisitions(
subject=subject, suffix='epi'
) + [None]
contrasts = base_entities.get('ceagent') or layout.get_ceagents(
subject=subject, suffix='epi'
) + [None]
acqs = (
listify(base_entities['acquisition'])
if 'acquisition' in base_entities
else layout.get_acquisitions(subject=subject, suffix='epi') + [None]
)
contrasts = (
listify(base_entities['ceagent'])
if 'ceagent' in base_entities
else layout.get_ceagents(subject=subject, suffix='epi') + [None]
)
for ses, acq, ce in product(sessions, acqs, contrasts):
entities = base_entities.copy()
entities.update({'suffix': 'epi', 'session': ses, 'acquisition': acq, 'ceagent': ce})
Expand Down Expand Up @@ -513,12 +520,14 @@ def find_estimators(
fmapless = False

# Find fieldmap-less schemes
anat_file = layout.get(**{**base_entities, **{'suffix': anat_suffix, 'session': sessions}})
anat_file = layout.get(
**{**no_filter_entities, **{'suffix': anat_suffix, 'session': sessions}}
)

# Fall back to a subject-level/sessionless anatomical reference.
if not anat_file:
anat_file = layout.get(
**{**base_entities, **{'suffix': anat_suffix, 'session': Query.NONE}}
**{**no_filter_entities, **{'suffix': anat_suffix, 'session': Query.NONE}}
)

if not fmapless or not anat_file:
Expand All @@ -531,7 +540,7 @@ def find_estimators(
layout=layout,
subject=subject,
sessions=sessions,
base_entities=base_entities,
base_entities=no_filter_entities,
suffixes=fmapless,
)
for spec in estimator_specs:
Expand Down
Loading