Skip to content

Commit 941c387

Browse files
authored
ENH: Add movement compensation (mne-tools#747)
1 parent ec978f5 commit 941c387

22 files changed

+565
-80
lines changed

.circleci/config.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jobs:
4848
name: Get Python running
4949
command: |
5050
pip install --upgrade --progress-bar off pip setuptools
51-
pip install --upgrade --progress-bar off "autoreject @ https://api.github.com/repos/autoreject/autoreject/zipball/master" "mne[hdf5] @ git+https://github.com/mne-tools/mne-python" "mne-bids[full] @ https://api.github.com/repos/mne-tools/mne-bids/zipball/main"
51+
pip install --upgrade --progress-bar off "autoreject @ https://api.github.com/repos/autoreject/autoreject/zipball/master" "mne[hdf5] @ git+https://github.com/mne-tools/mne-python" "mne-bids[full] @ https://api.github.com/repos/mne-tools/mne-bids/zipball/main" numba
5252
pip install -ve .[tests]
5353
pip install PyQt6
5454
- run:
@@ -402,6 +402,7 @@ jobs:
402402

403403
test_ds004107:
404404
<<: *imageconfig
405+
resource_class: large # memory
405406
steps:
406407
- attach_workspace:
407408
at: ~/
@@ -768,6 +769,7 @@ jobs:
768769

769770
test_ds004229:
770771
<<: *imageconfig
772+
resource_class: large # head position estimation
771773
steps:
772774
- attach_workspace:
773775
at: ~/

docs/source/settings/preprocessing/maxfilter.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@ tags:
1010
members:
1111
- use_maxwell_filter
1212
- mf_st_duration
13+
- mf_st_correlation
1314
- mf_head_origin
15+
- mf_destination
16+
- mf_int_order
1417
- mf_reference_run
1518
- mf_cal_fname
1619
- mf_ctc_fname
20+
- mf_mc
21+
- mf_mc_t_step_min
22+
- mf_mc_t_window
23+
- mf_mc_gof_limit
24+
- mf_mc_dist_limit
25+
- mf_filter_chpi

docs/source/settings/preprocessing/ssp_ica.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ tags:
2121
- eog_proj_from_average
2222
- ssp_reject_eog
2323
- ssp_reject_ecg
24+
- ssp_ecg_channel
2425
- ica_reject
2526
- ica_algorithm
2627
- ica_l_freq

docs/source/v1.4.md.inc

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
## v1.4.0 (unreleased)
22

3-
[//]: # (### :new: New features & enhancements)
3+
### :new: New features & enhancements
44

5-
[//]: # (- Whatever (#000 by @whoever))
5+
- Add movement compensation and cHPI filtering to the Maxwell filtering step, along with additional configuration options (#747 by @larsoner)
6+
- Add option to specify `ssp_ecg_channel` to override the default value (#747 by @larsoner)
67

78
[//]: # (### :warning: Behavior changes)
89

@@ -17,3 +18,4 @@
1718
- Fix bug when `mf_reference_run != runs[0]` (#742 by @larsoner)
1819
- Fix bug with too many JSON files found during empty room matching (#743 by @allermat)
1920
- Fix bug with outdated info on ch_types config option (#745 by @allermat)
21+
- Fix bug where SSP projectors were not added to the report (#747 by @larsoner)

mne_bids_pipeline/_config.py

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,16 @@
576576
```
577577
"""
578578

579+
mf_st_correlation: float = 0.98
580+
"""
581+
The correlation limit for spatio-temporal SSS (tSSS).
582+
583+
???+ example "Example"
584+
```python
585+
st_correlation = 0.98
586+
```
587+
"""
588+
579589
mf_head_origin: Union[Literal["auto"], ArrayLike] = "auto"
580590
"""
581591
`mf_head_origin` : array-like, shape (3,) | 'auto'
@@ -591,17 +601,42 @@
591601
```
592602
"""
593603

594-
mf_reference_run: Optional[str] = None
604+
mf_destination: Union[Literal["reference_run"], ArrayLike] = "reference_run"
595605
"""
596606
Despite all possible care to avoid movements in the MEG, the participant
597607
will likely slowly drift down from the Dewar or slightly shift the head
598608
around in the course of the recording session. Hence, to take this into
599-
account, we are realigning all data to a single position. For this, you need
600-
to define a reference run (typically the one in the middle of
601-
the recording session).
609+
account, we are realigning all data to a single position. For this, you can:
602610
611+
1. Choose a reference run. Often one from the middle of the recording session
612+
is a good choice. Set `mf_destination = "reference_run" and then set
613+
[`config.mf_reference_run`](mne_bids_pipeline._config.mf_reference_run).
614+
This will result in a device-to-head transformation that differs between
615+
subjects.
616+
2. Choose a standard position in the MEG coordinate frame. For this, pass
617+
a 4x4 transformation matrix for the device-to-head
618+
transform. This will result in a device-to-head transformation that is
619+
the same across all subjects.
620+
621+
???+ example "A Standardized Position"
622+
```python
623+
from mne.transforms import translation
624+
mf_destination = translation(z=0.04)
625+
```
626+
"""
627+
628+
mf_int_order: int = 8
629+
"""
630+
Internal order for the Maxwell basis. Can be set to something lower (e.g., 6
631+
or higher for datasets where lower or higher spatial complexity, respectively,
632+
is expected.
633+
"""
634+
635+
mf_reference_run: Optional[str] = None
636+
"""
603637
Which run to take as the reference for adjusting the head position of all
604-
runs. If `None`, pick the first run.
638+
runs when [`mf_destination="reference_run"`](mne_bids_pipeline._config.mf_destination).
639+
If `None`, pick the first run.
605640
606641
???+ example "Example"
607642
```python
@@ -638,6 +673,39 @@
638673
```
639674
""" # noqa : E501
640675

676+
mf_mc: bool = False
677+
"""
678+
If True, perform movement compensation on the data.
679+
"""
680+
681+
mf_mc_t_step_min: float = 0.01
682+
"""
683+
Minimum time step to use during cHPI coil amplitude estimation.
684+
"""
685+
686+
mf_mc_t_window: Union[float, Literal["auto"]] = "auto"
687+
"""
688+
The window to use during cHPI coil amplitude estimation and in cHPI filtering.
689+
Can be "auto" to autodetect a reasonable value or a float (in seconds).
690+
"""
691+
692+
mf_mc_gof_limit: float = 0.98
693+
"""
694+
Minimum goodness of fit to accept for each cHPI coil.
695+
"""
696+
697+
mf_mc_dist_limit: float = 0.005
698+
"""
699+
Minimum distance (m) to accept for cHPI position fitting.
700+
"""
701+
702+
mf_filter_chpi: Optional[bool] = None
703+
"""
704+
Use mne.chpi.filter_chpi after Maxwell filtering. Can be None to use
705+
the same value as [`mf_mc`][mne_bids_pipeline._config.mf_mc].
706+
Only used when [`use_maxwell_filter=True`][mne_bids_pipeline._config.use_maxwell_filter]
707+
""" # noqa: E501
708+
641709
###############################################################################
642710
# STIMULATION ARTIFACT
643711
# --------------------
@@ -1125,6 +1193,12 @@
11251193
```
11261194
"""
11271195

1196+
ssp_ecg_channel: Optional[str] = None
1197+
"""
1198+
Channel to use for ECG SSP. Can be useful when the autodetected ECG channel
1199+
is not reliable.
1200+
"""
1201+
11281202
# Rejection based on ICA
11291203
# ~~~~~~~~~~~~~~~~~~~~~~
11301204
ica_reject: Optional[Dict[str, float]] = None

mne_bids_pipeline/_config_import.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import Optional, List
99

1010
import matplotlib
11+
import numpy as np
1112
import mne
1213
from mne.utils import _check_option, _validate_type
1314

@@ -343,6 +344,25 @@ def _check_config(config: SimpleNamespace) -> None:
343344
("raise", "warn", "ignore"),
344345
)
345346

347+
_validate_type(
348+
config.mf_destination,
349+
(str, list, tuple, np.ndarray),
350+
"config.mf_destination",
351+
)
352+
if isinstance(config.mf_destination, str):
353+
_check_option(
354+
"config.mf_destination",
355+
config.mf_destination,
356+
("reference_run",),
357+
)
358+
else:
359+
destination = np.array(config.mf_destination, float)
360+
if destination.shape != (4, 4):
361+
raise ValueError(
362+
"config.mf_destination, if array-like, must have shape (4, 4) "
363+
f"but got shape {destination.shape}"
364+
)
365+
346366

347367
_REMOVED_NAMES = {
348368
"debug": dict(

mne_bids_pipeline/_config_utils.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,10 @@ def get_intersect_run(config: SimpleNamespace) -> List[str]:
170170

171171

172172
def get_runs(
173-
*, config: SimpleNamespace, subject: str, verbose: bool = False
173+
*,
174+
config: SimpleNamespace,
175+
subject: str,
176+
verbose: bool = False,
174177
) -> Union[List[str], List[None]]:
175178
"""Returns a list of runs in the BIDS input data.
176179
@@ -218,17 +221,43 @@ def get_runs(
218221
logger.info(**gen_log_kwargs(message=msg))
219222

220223
if runs == "all":
221-
runs = valid_runs
224+
runs = list(valid_runs)
222225

223226
if not runs:
224-
return [None]
227+
runs = [None]
225228
else:
226229
inclusion = set(runs).issubset(set(valid_runs))
227230
if not inclusion:
228231
raise ValueError(
229232
f"Invalid run. It can be a subset of {valid_runs} but " f"got {runs}"
230233
)
231-
return runs
234+
return runs
235+
236+
237+
def get_runs_tasks(
238+
*,
239+
config: SimpleNamespace,
240+
subject: str,
241+
session: Optional[str],
242+
) -> List[Tuple[str]]:
243+
"""Get (run, task) tuples for all runs plus (maybe) rest."""
244+
from ._import_data import _get_bids_path_in
245+
246+
runs = get_runs(config=config, subject=subject)
247+
tasks = [config.task] * len(runs)
248+
if config.process_rest and not config.task_is_rest:
249+
run, task = None, "rest"
250+
bids_path_in = _get_bids_path_in(
251+
cfg=config,
252+
subject=subject,
253+
session=session,
254+
run=run,
255+
task=task,
256+
)
257+
if bids_path_in.fpath.exists():
258+
runs.append(None)
259+
tasks.append("rest")
260+
return tuple(zip(runs, tasks))
232261

233262

234263
def get_mf_reference_run(config: SimpleNamespace) -> str:

mne_bids_pipeline/_import_data.py

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ._config_utils import (
1010
get_mf_reference_run,
1111
get_runs,
12+
get_datatype,
1213
_bids_kwargs,
1314
)
1415
from ._io import _read_json, _empty_room_match_path
@@ -508,29 +509,29 @@ def _find_breaks_func(
508509
raw.set_annotations(raw.annotations + break_annots) # add to existing
509510

510511

511-
def _get_raw_paths(
512+
def _get_bids_path_in(
512513
*,
513514
cfg: SimpleNamespace,
514515
subject: str,
515516
session: Optional[str],
516517
run: Optional[str],
517-
kind: Literal["raw", "sss"],
518-
add_bads: bool = True,
519-
include_mf_ref: bool = True,
520-
) -> dict:
521-
# Construct the basenames of the files we wish to load, and of the empty-
522-
# room recording we wish to save.
523-
# The basenames of the empty-room recording output file does not contain
524-
# the "run" entity.
518+
task: Optional[str],
519+
kind: Literal["orig", "sss"] = "orig",
520+
) -> BIDSPath:
521+
# b/c can be used before this is updated
522+
if hasattr(cfg, "datatype"):
523+
datatype = cfg.datatype
524+
else:
525+
datatype = get_datatype(config=cfg)
525526
path_kwargs = dict(
526527
subject=subject,
527528
run=run,
528529
session=session,
529-
task=cfg.task,
530+
task=task or cfg.task,
530531
acquisition=cfg.acq,
531532
recording=cfg.rec,
532533
space=cfg.space,
533-
datatype=cfg.datatype,
534+
datatype=datatype,
534535
check=False,
535536
)
536537
if kind == "sss":
@@ -545,7 +546,31 @@ def _get_raw_paths(
545546
path_kwargs["extension"] = None
546547
path_kwargs["processing"] = cfg.proc
547548
bids_path_in = BIDSPath(**path_kwargs)
549+
return bids_path_in
548550

551+
552+
def _get_raw_paths(
553+
*,
554+
cfg: SimpleNamespace,
555+
subject: str,
556+
session: Optional[str],
557+
run: Optional[str],
558+
kind: Literal["orig", "sss"],
559+
add_bads: bool = True,
560+
include_mf_ref: bool = True,
561+
) -> dict:
562+
# Construct the basenames of the files we wish to load, and of the empty-
563+
# room recording we wish to save.
564+
# The basenames of the empty-room recording output file does not contain
565+
# the "run" entity.
566+
bids_path_in = _get_bids_path_in(
567+
cfg=cfg,
568+
subject=subject,
569+
session=session,
570+
run=run,
571+
task=None,
572+
kind=kind,
573+
)
549574
in_files = dict()
550575
key = f"raw_run-{run}"
551576
in_files[key] = bids_path_in
@@ -556,8 +581,6 @@ def _get_raw_paths(
556581
in_files=in_files,
557582
key=key,
558583
)
559-
orig_key = key
560-
561584
if run == cfg.runs[0]:
562585
do = dict(
563586
rest=cfg.process_rest and not cfg.task_is_rest,
@@ -600,9 +623,7 @@ def _get_raw_paths(
600623
)
601624
if include_mf_ref and task == "noise":
602625
key = "raw_ref_run"
603-
in_files[key] = (
604-
in_files[orig_key].copy().update(run=cfg.mf_reference_run)
605-
)
626+
in_files[key] = bids_path_in.copy().update(run=cfg.mf_reference_run)
606627
_update_for_splits(in_files, key, single=True, allow_missing=True)
607628
if not in_files[key].fpath.exists():
608629
in_files.pop(key)
@@ -612,7 +633,6 @@ def _get_raw_paths(
612633
in_files=in_files,
613634
key=key,
614635
)
615-
616636
return in_files
617637

618638

0 commit comments

Comments
 (0)