From e2766219e84536a9000e4e5fc56ef74009ef1cca Mon Sep 17 00:00:00 2001 From: Kevin Sitek Date: Wed, 23 Sep 2026 22:26:50 -0500 Subject: [PATCH 01/10] Add opt-in --filter-method iir and --reject-mode abs preprocessing Some published FFR pipelines band-pass with zero-phase first-order Butterworth filters (butter + filtfilt) and reject trials on absolute amplitude (max|x| >= threshold) rather than peak-to-peak. Both are now opt-in (defaults unchanged), plumbed through the CLI, nipype workflow nodes and sidecar (RejectionMode). Tests compare the IIR output with scipy butter+sosfiltfilt and check abs vs ptp on constructed epochs. Co-Authored-By: Claude Sonnet 5 --- CHANGES.rst | 10 ++ docs/source/pipeline_details.rst | 7 +- ffrprep/ffrprep_cli.py | 32 ++++++- ffrprep/preproc.py | 97 +++++++++++++++++-- ffrprep/tests/test_cli.py | 53 +++++++++++ ffrprep/tests/test_preproc.py | 158 +++++++++++++++++++++++++++++++ 6 files changed, 346 insertions(+), 11 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2b715b0..d668269 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -19,6 +19,16 @@ CLI - New flag ``--difference-pairs A:B [C:D …]`` to compute difference evokeds across explicit pairs. For 2-type datasets the difference is auto-emitted; for 3+ types this flag is required to opt in. +- New flag ``--filter-method {fir,iir}`` (default ``fir``): ``iir`` applies + zero-phase (forward-backward) first-order Butterworth high-pass and + low-pass filters (12 dB/octave overall), matching the ``butter`` / + ``filtfilt`` band-pass used in some published FFR pipelines. +- New flag ``--reject-mode {ptp,abs}`` (default ``ptp``): ``abs`` drops + epochs whose absolute amplitude reaches ``--reject-eeg`` at any sample + (``max|x| >= threshold``, drop reason ``ABS_AMP``) instead of MNE's + peak-to-peak criterion. The preprocessing sidecar records + ``RejectionMode`` (``peak-to-peak`` / ``absolute-amplitude``) next to + ``RejectionThresholds``. - New flag ``--no-report``: skip HTML report generation (preprocessing and analysis) while still writing all derivatives. Intended for bulk runs over many subjects, where report figures dominate runtime. diff --git a/docs/source/pipeline_details.rst b/docs/source/pipeline_details.rst index faea4e0..8d69e01 100644 --- a/docs/source/pipeline_details.rst +++ b/docs/source/pipeline_details.rst @@ -132,7 +132,9 @@ Preprocessing Workflow Nodes *Default Parameters:* - **High-pass:** 1.0 Hz (removes slow drifts, preserves FFR frequencies) - **Low-pass:** 40.0 Hz (removes EMG and high-frequency noise) - - **Filter design:** Zero-phase FIR with automatic transition bandwidth + - **Filter design:** Zero-phase FIR with automatic transition bandwidth. + ``--filter-method iir`` switches to zero-phase first-order + Butterworth high-pass + low-pass passes (12 dB/octave overall). *Outputs:* Filtered EEG data @@ -158,7 +160,8 @@ amplitude-based rejection. around stimulus onset - **Baseline:** ``--baseline -0.2 0`` seconds (pre-stimulus) - **Rejection:** ``--reject-eeg 75e-6`` (75 µV peak-to-peak); pass - ``--no-auto-reject`` to disable + ``--no-auto-reject`` to disable, or ``--reject-mode abs`` to drop + epochs whose absolute amplitude reaches the threshold at any sample *Outputs:* Epoched EEG data and the post-rejection drop log. diff --git a/ffrprep/ffrprep_cli.py b/ffrprep/ffrprep_cli.py index 9bf318c..5861d30 100644 --- a/ffrprep/ffrprep_cli.py +++ b/ffrprep/ffrprep_cli.py @@ -221,6 +221,8 @@ def _make_preproc_payload(args_snap, deriv_snap, subject, task_label, run_label, "ref_channels": ref_channels, "high_pass": effective_l, "low_pass": effective_h, + "filter_method": args_snap.get("filter_method", "fir"), + "reject_mode": args_snap.get("reject_mode", "ptp"), "baseline": baseline, "tmin": args_snap.get("tmin"), "tmax": args_snap.get("tmax"), @@ -308,6 +310,8 @@ def _make_concat_payload(args_snap, deriv_snap, subject, task_label, runs, "ref_channels": ref_channels, "high_pass": effective_l, "low_pass": effective_h, + "filter_method": args_snap.get("filter_method", "fir"), + "reject_mode": args_snap.get("reject_mode", "ptp"), "baseline": baseline, "tmin": args_snap.get("tmin"), "tmax": args_snap.get("tmax"), @@ -524,6 +528,8 @@ def _build_preproc_workflow(payload): target.ref_channels = payload["ref_channels"] target.high_pass = payload["high_pass"] target.low_pass = payload["low_pass"] + target.filter_method = payload.get("filter_method", "fir") + target.reject_mode = payload.get("reject_mode", "ptp") target.baseline = payload["baseline"] target.tmin = payload["tmin"] target.tmax = payload["tmax"] @@ -1663,11 +1669,35 @@ def get_parser(): "--reject-eeg", type=float, help=( - "Peak-to-peak rejection threshold for EEG channels in Volts. " + "Rejection threshold for EEG channels in Volts (peak-to-peak " + "by default; see --reject-mode). " "Set to 0 to disable automatic rejection." ), default=75e-6, ) + preproc_group.add_argument( + "--reject-mode", + choices=["ptp", "abs"], + default="ptp", + help=( + "How --reject-eeg is applied: 'ptp' = MNE peak-to-peak " + "criterion (default); 'abs' = drop epochs whose absolute " + "amplitude reaches the threshold at any sample (max|x| >= " + "threshold), e.g. --reject-mode abs --reject-eeg 35e-6." + ), + ) + preproc_group.add_argument( + "--filter-method", + choices=["fir", "iir"], + default="fir", + help=( + "Band-pass filter design: 'fir' = MNE zero-phase FIR with " + "automatic transition bandwidth (default); 'iir' = zero-phase " + "first-order Butterworth high-pass then low-pass (12 dB/octave " + "overall, forward-backward), as used in some published FFR " + "pipelines." + ), + ) preproc_group.add_argument( "--no-auto-reject", action="store_true", diff --git a/ffrprep/preproc.py b/ffrprep/preproc.py index 5382145..8c18f3a 100644 --- a/ffrprep/preproc.py +++ b/ffrprep/preproc.py @@ -441,7 +441,8 @@ def reference_raw_file(input_raw_path, ref_channels, output_dir, subject=None, t def filter_raw_file( - input_raw_path, high_pass, low_pass, output_dir, subject=None, task=None, session=None, run=None + input_raw_path, high_pass, low_pass, output_dir, subject=None, task=None, session=None, run=None, + filter_method="fir", ): """ Load a raw FIF file, apply filtering, save filtered FIF. Returns path. @@ -455,7 +456,9 @@ def filter_raw_file( from ffrprep.preproc import filter_data as _filter_data from ffrprep.preproc import save_raw_helper as _save_raw_helper - filtered = _filter_data(raw, high_pass=high_pass, low_pass=low_pass) + filtered = _filter_data( + raw, high_pass=high_pass, low_pass=low_pass, filter_method=filter_method, + ) # Derive identifiers from filename as in reference_raw_file import re @@ -625,7 +628,7 @@ def reference_data(eeg_data=None, ref_channels=None): return eeg_data -def filter_data(eeg_data=None, high_pass=None, low_pass=None): +def filter_data(eeg_data=None, high_pass=None, low_pass=None, filter_method="fir"): """ Apply frequency filters to the provided EEG data object. @@ -639,6 +642,13 @@ def filter_data(eeg_data=None, high_pass=None, low_pass=None): low_pass : float Upper passband edge (in Hertz). Default = None. + filter_method : {"fir", "iir"} + ``"fir"`` (default) uses MNE's zero-phase FIR design with automatic + transition bandwidths. ``"iir"`` applies zero-phase (forward- + backward) first-order Butterworth filters, a high-pass then a + low-pass, so the band-pass rolls off at 12 dB/octave overall. + This mirrors the ``butter``/``filtfilt`` pipeline used in some + published FFR analyses. Returns ------- @@ -664,7 +674,25 @@ def filter_data(eeg_data=None, high_pass=None, low_pass=None): # Apply filtering using MNE's filter method # l_freq is low-frequency cutoff (high-pass), h_freq is high-frequency cutoff (low-pass) # noqa: E501 - filtered_eeg.filter(l_freq=high_pass, h_freq=low_pass) + if filter_method == "fir": + filtered_eeg.filter(l_freq=high_pass, h_freq=low_pass) + elif filter_method == "iir": + # Fresh iir_params per call: MNE writes the designed filter back + # into the dict it is given. + if high_pass is not None: + filtered_eeg.filter( + l_freq=high_pass, h_freq=None, method="iir", + iir_params=dict(order=1, ftype="butter"), phase="zero", + ) + if low_pass is not None: + filtered_eeg.filter( + l_freq=None, h_freq=low_pass, method="iir", + iir_params=dict(order=1, ftype="butter"), phase="zero", + ) + else: + raise ValueError( + f"filter_method must be 'fir' or 'iir', got {filter_method!r}" + ) # Return the filtered data return filtered_eeg @@ -686,6 +714,7 @@ def epoch_data( run=None, trial_types=None, verbose=True, + reject_mode="ptp", ): """ Epoch the provided EEG data object based on events. @@ -706,6 +735,13 @@ def epoch_data( Start time before event (in seconds). tmax : float, optional End time after event (in seconds). + reject : dict, optional + Rejection thresholds in Volts, e.g. ``{"eeg": 75e-6}``. + reject_mode : {"ptp", "abs"} + How ``reject["eeg"]`` is interpreted. ``"ptp"`` (default) is MNE's + peak-to-peak criterion. ``"abs"`` drops any epoch whose absolute + amplitude reaches the threshold at any EEG sample (``max|x| >= + threshold``); dropped epochs are logged with reason ``ABS_AMP``. verbose : bool Whether to print verbose output. @@ -917,6 +953,16 @@ def _coerce_int(val): ) chosen_event_id = {k: chosen_event_id[k] for k in requested} + if reject_mode not in ("ptp", "abs"): + raise ValueError( + f"reject_mode must be 'ptp' or 'abs', got {reject_mode!r}" + ) + abs_threshold = None + epochs_reject = reject + if reject_mode == "abs" and reject: + abs_threshold = float(reject["eeg"]) + epochs_reject = None + epoched_data = Epochs( eeg_data, events=events, @@ -927,13 +973,27 @@ def _coerce_int(val): tmax=tmax, baseline=baseline_window, verbose=verbose, - reject=reject, + reject=epochs_reject, ) # drop_bad() will apply rejection heuristics and return the filtered # Epochs object; keep this separate for clarity and easier debugging. epoched_data = epoched_data.drop_bad() + if abs_threshold is not None: + import numpy as np + from mne import pick_types + + eeg_picks = pick_types(epoched_data.info, eeg=True) + if len(eeg_picks) and len(epoched_data): + amplitude = np.abs(epoched_data.get_data(picks=eeg_picks)) + too_large = np.flatnonzero(amplitude.max(axis=(1, 2)) >= abs_threshold) + if too_large.size: + epoched_data.drop(too_large, reason="ABS_AMP", verbose=False) + # Read back by _save_one_preproc_epochs for the JSON sidecar + # (MNE's own ``reject`` attribute is None in this mode). + epoched_data.ffrprep_reject_abs = abs_threshold + return epoched_data, (tmin, tmax) @@ -1126,6 +1186,8 @@ def create_preprocessing_workflow(name="ffrprep_preproc", disk_backed=False): "ref_channels", "high_pass", "low_pass", + "filter_method", + "reject_mode", "baseline", "tmin", "tmax", @@ -1199,6 +1261,7 @@ def create_preprocessing_workflow(name="ffrprep_preproc", disk_backed=False): "task", "session", "run", + "filter_method", ], output_names=["filtered_raw_path"], function=filter_raw_file, @@ -1208,7 +1271,7 @@ def create_preprocessing_workflow(name="ffrprep_preproc", disk_backed=False): else: filter_node = Node( Function( - input_names=["eeg_data", "high_pass", "low_pass"], + input_names=["eeg_data", "high_pass", "low_pass", "filter_method"], output_names=["filtered_data"], function=filter_data, ), @@ -1244,6 +1307,7 @@ def create_preprocessing_workflow(name="ffrprep_preproc", disk_backed=False): "task", "run", "trial_types", + "reject_mode", ], output_names=["epochs", "time_window"], function=epoch_data, @@ -1268,6 +1332,7 @@ def create_preprocessing_workflow(name="ffrprep_preproc", disk_backed=False): "task", "run", "trial_types", + "reject_mode", ], output_names=["epochs", "time_window"], function=epoch_data, @@ -1339,6 +1404,7 @@ def create_preprocessing_workflow(name="ffrprep_preproc", disk_backed=False): [ ("high_pass", "high_pass"), ("low_pass", "low_pass"), + ("filter_method", "filter_method"), ("output_dir", "output_dir"), ("sub_label", "subject"), ("task_label", "task"), @@ -1363,6 +1429,7 @@ def create_preprocessing_workflow(name="ffrprep_preproc", disk_backed=False): ("tmin", "tmin"), ("tmax", "tmax"), ("reject", "reject"), + ("reject_mode", "reject_mode"), ("picks", "picks"), ("on_missing", "on_missing"), ("event_id", "event_id"), @@ -1423,7 +1490,15 @@ def create_preprocessing_workflow(name="ffrprep_preproc", disk_backed=False): (inputnode, reference_node, [("ref_channels", "ref_channels")]), # Connect reference to filter (reference_node, filter_node, [("referenced_data", "eeg_data")]), - (inputnode, filter_node, [("high_pass", "high_pass"), ("low_pass", "low_pass")]), + ( + inputnode, + filter_node, + [ + ("high_pass", "high_pass"), + ("low_pass", "low_pass"), + ("filter_method", "filter_method"), + ], + ), # Connect filter to epoch (filter_node, epoch_node, [("filtered_data", "eeg_data")]), ( @@ -1434,6 +1509,7 @@ def create_preprocessing_workflow(name="ffrprep_preproc", disk_backed=False): ("tmin", "tmin"), ("tmax", "tmax"), ("reject", "reject"), + ("reject_mode", "reject_mode"), ("picks", "picks"), ("on_missing", "on_missing"), ("event_id", "event_id"), @@ -1822,7 +1898,12 @@ def _save_one_preproc_epochs( ), }, } - if reject_thresholds: + abs_reject = getattr(epochs, "ffrprep_reject_abs", None) + if abs_reject is not None: + sidecar["RejectionMode"] = "absolute-amplitude" + sidecar["RejectionThresholds"] = {"eeg": float(abs_reject)} + elif reject_thresholds: + sidecar["RejectionMode"] = "peak-to-peak" sidecar["RejectionThresholds"] = { k: float(v) for k, v in reject_thresholds.items() } diff --git a/ffrprep/tests/test_cli.py b/ffrprep/tests/test_cli.py index a9a360a..815a013 100644 --- a/ffrprep/tests/test_cli.py +++ b/ffrprep/tests/test_cli.py @@ -372,6 +372,59 @@ def test_run_ffrprep_both_stages( mock_analysis_workflow.run.assert_called_once() +def test_paper_exact_preprocessing_flags_default_to_mne_behaviour(): + parser = get_parser() + base = ["/path/to/bids", "/path/to/output", "participant"] + args = parser.parse_args(base) + assert args.filter_method == "fir" + assert args.reject_mode == "ptp" + + args = parser.parse_args( + base + ["--filter-method", "iir", "--reject-mode", "abs", "--reject-eeg", "35e-6"] + ) + assert args.filter_method == "iir" + assert args.reject_mode == "abs" + assert args.reject_eeg == pytest.approx(35e-6) + + +@pytest.mark.parametrize("flag, bad", [("--filter-method", "butter"), ("--reject-mode", "mean")]) +def test_paper_exact_preprocessing_flags_reject_unknown_choices(flag, bad): + parser = get_parser() + with pytest.raises(SystemExit): + parser.parse_args(["/b", "/o", "participant", flag, bad]) + + +def test_workflow_inputs_carry_filter_method_and_reject_mode(tmp_path): + """The CLI payload reaches the nipype inputnode (both node layouts).""" + from ffrprep.ffrprep_cli import _build_preproc_workflow, _make_preproc_payload + + args_snap = { + "bids_dir": str(tmp_path / "bids"), "tmin": -0.04, "tmax": 0.213, + "picks": None, "on_missing": "warn", "event_id": None, + "events_file": None, "save_each_node": False, "work_dir": None, + "filter_method": "iir", "reject_mode": "abs", + } + deriv_snap = { + "derivatives_root": str(tmp_path / "deriv"), + "preprocessing_dir": str(tmp_path / "deriv" / "ffrprep-preprocessing"), + "preprocessing_subject_dir": str(tmp_path / "deriv" / "p" / "sub-01" / "eeg"), + "analysis_dir": str(tmp_path / "deriv" / "ffrprep-analysis"), + "analysis_subject_dir": str(tmp_path / "deriv" / "a" / "sub-01"), + } + for save_each_node in (False, True): + args_snap["save_each_node"] = save_each_node + payload = _make_preproc_payload( + args_snap, deriv_snap, "01", "da", "01", [], 70.0, 2000.0, + (-0.04, 0.0), {"eeg": 35e-6}, + ) + assert payload["filter_method"] == "iir" + assert payload["reject_mode"] == "abs" + wf = _build_preproc_workflow(payload) + inputs = wf.get_node("inputnode").inputs + assert inputs.filter_method == "iir" + assert inputs.reject_mode == "abs" + + def test_no_report_flag_defaults_off_and_can_be_enabled(): parser = get_parser() base = ["/path/to/bids", "/path/to/output", "participant"] diff --git a/ffrprep/tests/test_preproc.py b/ffrprep/tests/test_preproc.py index 635cfac..55ba571 100644 --- a/ffrprep/tests/test_preproc.py +++ b/ffrprep/tests/test_preproc.py @@ -590,6 +590,164 @@ def test_epoch_data_trial_types_unknown_raises(tmp_path): ) +def _single_channel_raw(sfreq=16384.0, seconds=8.0, seed=0): + import numpy as np + from mne import create_info + from mne.io import RawArray + + rng = np.random.default_rng(seed) + n = int(sfreq * seconds) + t = np.arange(n) / sfreq + data = ( + rng.normal(0, 1e-6, n) + + 5e-6 * np.sin(2 * np.pi * 100.0 * t) + + 3e-6 * np.sin(2 * np.pi * 20.0 * t) + )[np.newaxis, :] + return RawArray(data, create_info(["Cz"], sfreq, ch_types=["eeg"]), verbose=False) + + +def test_filter_data_default_is_fir_and_matches_explicit_fir(): + import numpy as np + + raw = _single_channel_raw(sfreq=2000.0, seconds=4.0) + default = filter_data(raw, high_pass=70.0, low_pass=500.0) + explicit = filter_data(raw, high_pass=70.0, low_pass=500.0, filter_method="fir") + np.testing.assert_array_equal(default.get_data(), explicit.get_data()) + + +def test_filter_data_iir_matches_scipy_butter_filtfilt(): + """iir = order-1 Butterworth HP then LP, each zero-phase (filtfilt).""" + import numpy as np + from scipy import signal + + sfreq = 16384.0 + raw = _single_channel_raw(sfreq=sfreq, seconds=6.0) + filtered = filter_data(raw, high_pass=70.0, low_pass=2000.0, filter_method="iir") + + x = raw.get_data()[0] + hp = signal.butter(1, 70.0 / (sfreq / 2), "high", output="sos") + lp = signal.butter(1, 2000.0 / (sfreq / 2), "low", output="sos") + expected = signal.sosfiltfilt(lp, signal.sosfiltfilt(hp, x)) + + interior = slice(int(sfreq), -int(sfreq)) # skip edge-padding differences + np.testing.assert_allclose( + filtered.get_data()[0][interior], expected[interior], rtol=0, atol=1e-12, + ) + # And it actually filtered: the 20 Hz component is strongly attenuated. + assert np.std(filtered.get_data()[0][interior]) < np.std(x[interior]) + + +def test_filter_data_iir_supports_single_sided_and_leaves_input_untouched(): + import numpy as np + + raw = _single_channel_raw(sfreq=2000.0, seconds=4.0) + before = raw.get_data().copy() + high_only = filter_data(raw, high_pass=70.0, low_pass=None, filter_method="iir") + low_only = filter_data(raw, high_pass=None, low_pass=200.0, filter_method="iir") + np.testing.assert_array_equal(raw.get_data(), before) + assert not np.allclose(high_only.get_data(), before) + assert not np.allclose(low_only.get_data(), before) + + +def test_filter_data_unknown_method_raises(): + raw = _single_channel_raw(sfreq=1000.0, seconds=2.0) + with pytest.raises(ValueError, match="filter_method"): + filter_data(raw, high_pass=1.0, low_pass=40.0, filter_method="butter") + + +def _raw_and_events_for_rejection(tmp_path): + """Three-epoch recording: clean, spike (+45 uV), +/-20 uV square wave.""" + import numpy as np + import pandas as pd + from mne import create_info + from mne.io import RawArray + + sfreq = 1000.0 + n = int(sfreq * 6) + rng = np.random.default_rng(1) + data = rng.normal(0, 0.5e-6, n) + # epoch 2 (onset 2.0 s): single +45 uV sample after stimulus onset + data[int(2.0 * sfreq) + 50] += 45e-6 + # epoch 3 (onset 3.0 s): +/-20 uV square wave -> ptp = 40 uV, max|x| = 20 uV + seg = slice(int(3.0 * sfreq) + 10, int(3.0 * sfreq) + 110) + data[seg] += 20e-6 * np.sign(np.sin(np.linspace(0, 8 * np.pi, 100))) + raw = RawArray(data[np.newaxis, :], create_info(["Cz"], sfreq, ["eeg"]), verbose=False) + events_path = tmp_path / "events.tsv" + pd.DataFrame({ + "onset": [1.0, 2.0, 3.0], + "duration": [0.0] * 3, + "trial_type": ["a", "a", "a"], + }).to_csv(events_path, sep="\t", index=False) + return raw, events_path + + +def test_epoch_data_ptp_rejection_default_rejects_square_wave(tmp_path): + raw, events_path = _raw_and_events_for_rejection(tmp_path) + epochs, _ = epoch_data( + raw, baseline=[-0.05, 0.0], events_file=str(events_path), + tmin=-0.05, tmax=0.2, reject={"eeg": 35e-6}, verbose=False, + ) + assert len(epochs) == 1 # spike (ptp 45) and square wave (ptp 40) both dropped + + +def test_epoch_data_abs_rejection_keeps_epoch_whose_ptp_only_exceeds(tmp_path): + raw, events_path = _raw_and_events_for_rejection(tmp_path) + epochs, _ = epoch_data( + raw, baseline=[-0.05, 0.0], events_file=str(events_path), + tmin=-0.05, tmax=0.2, reject={"eeg": 35e-6}, reject_mode="abs", + verbose=False, + ) + # Only the +45 uV spike reaches |x| >= 35 uV; the +/-20 uV wave stays. + assert len(epochs) == 2 + reasons = [r for r in epochs.drop_log if r] + assert reasons == [("ABS_AMP",)] + assert epochs.ffrprep_reject_abs == pytest.approx(35e-6) + + +def test_epoch_data_abs_mode_without_reject_is_a_noop(tmp_path): + raw, events_path = _raw_and_events_for_rejection(tmp_path) + epochs, _ = epoch_data( + raw, baseline=[-0.05, 0.0], events_file=str(events_path), + tmin=-0.05, tmax=0.2, reject=None, reject_mode="abs", verbose=False, + ) + assert len(epochs) == 3 + + +def test_epoch_data_unknown_reject_mode_raises(tmp_path): + raw, events_path = _raw_and_events_for_rejection(tmp_path) + with pytest.raises(ValueError, match="reject_mode"): + epoch_data( + raw, baseline=[-0.05, 0.0], events_file=str(events_path), + tmin=-0.05, tmax=0.2, reject={"eeg": 35e-6}, reject_mode="mean", + verbose=False, + ) + + +@pytest.mark.parametrize( + "reject_mode, reject, expected_mode", + [ + ("abs", {"eeg": 35e-6}, "absolute-amplitude"), + ("ptp", {"eeg": 75e-6}, "peak-to-peak"), + ], +) +def test_sidecar_records_rejection_mode(tmp_path, reject_mode, reject, expected_mode): + raw, events_path = _raw_and_events_for_rejection(tmp_path) + epochs, _ = epoch_data( + raw, baseline=[-0.05, 0.0], events_file=str(events_path), + tmin=-0.05, tmax=0.2, reject=reject, reject_mode=reject_mode, verbose=False, + ) + bids_root = tmp_path / "bids" + bids_root.mkdir() + out = save_preprocessing_outputs( + epochs, bids_root, subject="01", task="da", run="01", + ) + out = out if isinstance(out, list) else [out] + with open(out[0].with_suffix(".json")) as f: + sidecar = json.load(f) + assert sidecar["RejectionMode"] == expected_mode + assert sidecar["RejectionThresholds"] == {"eeg": pytest.approx(reject["eeg"])} + + def test_create_preprocessing_workflow(tmp_path): """Test the create_preprocessing_workflow function.""" # Test that the workflow can be created without errors From 92af1e10a36c8372ea354575c913b3fe50f331b6 Mon Sep 17 00:00:00 2001 From: Kevin Sitek Date: Wed, 23 Sep 2026 22:29:14 -0500 Subject: [PATCH 02/10] Add generic FFR harmonic-amplitude and stimulus-to-response metrics harmonic_amplitudes computes zero-padded, band-averaged FFT amplitudes at the harmonics of f0 (fundamental and summed upper harmonics, in uV) and stim_to_resp_xcorr the max cross-correlation in xcorr 'coeff' form with an optional lag window and Fisher z. load_wav_mono / resample_signal bring a stimulus to the EEG rate. Defaults follow Whiteford et al. (2025). Co-Authored-By: Claude Sonnet 5 --- CHANGES.rst | 11 ++ docs/source/api_ref.rst | 4 + ffrprep/analysis.py | 169 +++++++++++++++++++++++ ffrprep/tests/test_analysis_numerical.py | 164 ++++++++++++++++++++++ 4 files changed, 348 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index d668269..4cdb084 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -143,6 +143,17 @@ Analysis Python loop of ``scipy.stats.pearsonr`` calls (same values and pair ordering; ~0.3 s vs ~9 min for 3000 epochs x 4147 samples). - ``compute_fft``: amplitude spectrum helper. + - ``harmonic_amplitudes``: FFT amplitude (zero-padded, single-sided, + microvolts) of a response window, averaged in a ``bin_hz``-wide + band around each harmonic of ``f0``; returns the per-harmonic values, + the fundamental, and the summed upper harmonics. Defaults follow the + /da/ measure of Whiteford et al. (2025) (60-180 ms, 100 Hz, 10 + harmonics, 60 Hz bins). + - ``stim_to_resp_xcorr``: maximum stimulus-to-response correlation in + MATLAB ``xcorr(..., 'coeff')`` form (no mean removal) with an + optional lag window, returning ``(r, Fisher z, lag_ms)``. + - ``load_wav_mono`` / ``resample_signal``: WAV reader and polyphase + resampler used to bring a stimulus to the EEG sampling rate. - Analysis worker granularity changed from per-file to per-(task, run) group. ``_collect_analysis_groups`` stitches diff --git a/docs/source/api_ref.rst b/docs/source/api_ref.rst index 8364c19..bdfa560 100644 --- a/docs/source/api_ref.rst +++ b/docs/source/api_ref.rst @@ -62,6 +62,10 @@ Reference API corr_resp_to_resp response_consistency compute_fft + harmonic_amplitudes + stim_to_resp_xcorr + load_wav_mono + resample_signal .. _ref_reports: diff --git a/ffrprep/analysis.py b/ffrprep/analysis.py index c7a3b88..71d2d56 100644 --- a/ffrprep/analysis.py +++ b/ffrprep/analysis.py @@ -1060,3 +1060,172 @@ def compute_fft(signal_data, sfreq, fmin=None, fmax=None): amplitude = amplitude[mask] return freqs, amplitude + + +def harmonic_amplitudes( + evoked, + f0=100.0, + n_harmonics=10, + bin_hz=60.0, + tmin=0.060, + tmax=0.180, + pad_pow=2, + ch_name=None, + unit_scale=1e6, +): + """ + Spectral amplitude of the fundamental and its harmonics in an FFR. + + The steady-state portion of the response (``tmin``-``tmax``) is + Fourier transformed with zero padding to + ``2 ** (ceil(log2(L)) + pad_pow)`` points (``L`` = window length in + samples), converted to single-sided amplitude ``2 * |FFT| / L`` and, + for harmonic ``k`` (``k = 1 .. n_harmonics``, harmonic 1 = ``f0``), + averaged over all FFT points within ``k * f0 +/- bin_hz / 2``. The + defaults reproduce the /da/ (100 Hz F0) F0 / upper-harmonic measure of + Whiteford et al. (2025), which is computed on the *sum* of the two + polarity averages. + + Parameters + ---------- + evoked : mne.Evoked + Response to analyze (in Volts), e.g. the sum of the per-polarity + averages. + f0 : float, default=100.0 + Fundamental frequency of the stimulus in Hz. + n_harmonics : int, default=10 + Number of harmonics (including the fundamental) to evaluate. + bin_hz : float, default=60.0 + Full width of the averaging band centred on each harmonic, in Hz. + tmin, tmax : float + Analysis window in seconds relative to the Evoked time axis + (inclusive at both ends). + pad_pow : int, default=2 + Extra powers of two of zero padding beyond the next power of two. + ch_name : str, optional + Channel to analyze. Defaults to the first channel. + unit_scale : float, default=1e6 + Multiplier applied to the data before the FFT (1e6 -> microvolts). + + Returns + ------- + result : dict + ``"harmonics"`` : ndarray of shape (n_harmonics,) with the band- + averaged amplitude of each harmonic; ``"f0"`` : amplitude of the + first harmonic; ``"upper_harmonics"`` : sum of harmonics 2 to + ``n_harmonics`` (0.0 if ``n_harmonics`` is 1). + """ + sfreq = float(evoked.info["sfreq"]) + if (n_harmonics * f0 + bin_hz / 2.0) >= sfreq / 2.0: + raise ValueError( + f"Harmonic band up to {n_harmonics * f0 + bin_hz / 2.0:g} Hz " + f"reaches the Nyquist frequency ({sfreq / 2.0:g} Hz)." + ) + + picks = 0 if ch_name is None else evoked.ch_names.index(ch_name) + segment = evoked.copy().crop(tmin=tmin, tmax=tmax).data[picks] + n_samples = segment.size + if n_samples < 2: + raise ValueError("Analysis window contains fewer than 2 samples.") + + n_fft = 2 ** (int(np.ceil(np.log2(n_samples))) + int(pad_pow)) + amplitude = 2.0 * np.abs(np.fft.rfft(segment * unit_scale, n_fft)) / n_samples + freqs = np.fft.rfftfreq(n_fft, d=1.0 / sfreq) + + half_bin = bin_hz / 2.0 + 1e-9 + harmonics = np.array([ + amplitude[np.abs(freqs - k * f0) <= half_bin].mean() + for k in range(1, n_harmonics + 1) + ]) + return { + "harmonics": harmonics, + "f0": float(harmonics[0]), + "upper_harmonics": float(harmonics[1:].sum()), + } + + +def stim_to_resp_xcorr(stim, resp, sfreq, lag_range_ms=None): + """ + Maximum stimulus-to-response cross-correlation (coefficient form). + + Both inputs must share the sampling rate ``sfreq`` and be already + cropped to the analysis windows (e.g. stimulus vowel 50-170 ms vs. + response 60-180 ms). The cross-correlation is normalized like MATLAB's + ``xcorr(..., 'coeff')``: divided by ``sqrt(sum(stim**2) * + sum(resp**2))`` with **no** mean removal. The reported value is the + maximum over lags (optionally restricted to ``lag_range_ms``). + + Parameters + ---------- + stim, resp : array-like + 1-D stimulus and response waveforms sampled at ``sfreq``. + sfreq : float + Sampling frequency in Hz. + lag_range_ms : tuple of (float, float), optional + Inclusive lag window in ms in which to search for the maximum. + Positive lag means the response is delayed relative to the + stimulus. ``None`` searches all lags. + + Returns + ------- + r : float + Maximum correlation coefficient within the searched lags. + z : float + Fisher r-to-z transform of ``r`` (``arctanh``). + lag_ms : float + Lag in ms at which the maximum occurs. + """ + stim = np.asarray(stim, dtype=float).ravel() + resp = np.asarray(resp, dtype=float).ravel() + if stim.size == 0 or resp.size == 0: + raise ValueError("stim and resp must be non-empty.") + + denom = np.sqrt(np.sum(stim ** 2) * np.sum(resp ** 2)) + if denom == 0: + return float("nan"), float("nan"), float("nan") + + corr = signal.correlate(resp, stim, mode="full", method="fft") / denom + lags_ms = signal.correlation_lags(resp.size, stim.size, mode="full") / sfreq * 1000.0 + + mask = np.ones(corr.shape, dtype=bool) + if lag_range_ms is not None: + lo, hi = lag_range_ms + mask = (lags_ms >= lo - 1e-9) & (lags_ms <= hi + 1e-9) + if not mask.any(): + return float("nan"), float("nan"), float("nan") + + masked_idx = np.flatnonzero(mask) + best = masked_idx[np.argmax(corr[masked_idx])] + r = float(corr[best]) + with np.errstate(divide="ignore"): + z = float(np.arctanh(r)) + return r, z, float(lags_ms[best]) + + +def load_wav_mono(path): + """Read a WAV file as a float, mono (channel-averaged) waveform. + + Returns + ------- + data : ndarray + 1-D float waveform (arbitrary amplitude scale). + sfreq : float + Sampling rate of the file in Hz. + """ + from scipy.io import wavfile + + sfreq, data = wavfile.read(str(path)) + data = np.asarray(data, dtype=float) + if data.ndim > 1: + data = data.mean(axis=1) + return data, float(sfreq) + + +def resample_signal(data, sfreq_in, sfreq_out): + """Polyphase-resample a 1-D signal (no-op if the rates match).""" + from fractions import Fraction + + if int(round(sfreq_in)) == int(round(sfreq_out)): + return np.asarray(data, dtype=float) + ratio = Fraction(sfreq_out / sfreq_in).limit_denominator(100000) + return signal.resample_poly(np.asarray(data, dtype=float), ratio.numerator, ratio.denominator) diff --git a/ffrprep/tests/test_analysis_numerical.py b/ffrprep/tests/test_analysis_numerical.py index 99f46f3..6c6ff47 100644 --- a/ffrprep/tests/test_analysis_numerical.py +++ b/ffrprep/tests/test_analysis_numerical.py @@ -214,3 +214,167 @@ def test_response_consistency_single_epoch_returns_nan_and_empty(): mean_r, r_vals = response_consistency(epochs) assert r_vals.size == 0 assert np.isnan(mean_r) + + +# --------------------------------------------------------------------------- +# harmonic_amplitudes / stim_to_resp_xcorr / wav + resampling helpers +# --------------------------------------------------------------------------- + +def _harmonic_evoked(amps_uv, f0=100.0, sfreq=16384.0, tmin=-0.04, tmax=0.213, phase=0.3): + """Evoked (Volts) made of sines at k*f0 with given amplitudes in microvolts.""" + n = int(round((tmax - tmin) * sfreq)) + 1 + times = tmin + np.arange(n) / sfreq + data = np.zeros(n) + for k, amp in enumerate(amps_uv, start=1): + data += amp * 1e-6 * np.sin(2 * np.pi * k * f0 * times + phase * k) + info = mne.create_info(["Cz"], sfreq, "eeg") + return mne.EvokedArray(data[np.newaxis, :], info, tmin=tmin, nave=1, verbose="ERROR") + + +def _reference_harmonic_amplitudes(evoked, f0, n_harmonics, bin_hz, tmin, tmax, pad_pow): + """Straight-line restatement of the published MATLAB recipe (dataFFT.m).""" + sfreq = evoked.info["sfreq"] + # Nearest-sample, inclusive window (MATLAB-style index lookup). + first = int(round((tmin - evoked.tmin) * sfreq)) + last = int(round((tmax - evoked.tmin) * sfreq)) + x = evoked.data[0][first:last + 1] * 1e6 + length = x.size + nfft = 2 ** (int(np.ceil(np.log2(length))) + pad_pow) + amp = 2 * np.abs(np.fft.fft(x, nfft) / length) + freqs = np.arange(nfft) * sfreq / nfft + out = [] + for k in range(1, n_harmonics + 1): + band = (freqs >= k * f0 - bin_hz / 2) & (freqs <= k * f0 + bin_hz / 2) + out.append(amp[band].mean()) + return np.array(out) + + +def test_harmonic_amplitudes_matches_reference_recipe(): + from ffrprep.analysis import harmonic_amplitudes + + evoked = _harmonic_evoked([1.0, 0.5, 0.25, 0.1, 0.05]) + result = harmonic_amplitudes(evoked) + expected = _reference_harmonic_amplitudes( + evoked, f0=100.0, n_harmonics=10, bin_hz=60.0, tmin=0.06, tmax=0.18, pad_pow=2, + ) + np.testing.assert_allclose(result["harmonics"], expected, rtol=1e-12) + assert result["f0"] == pytest.approx(expected[0]) + assert result["upper_harmonics"] == pytest.approx(expected[1:].sum()) + + +def test_harmonic_amplitudes_scales_linearly_and_reports_microvolts(): + from ffrprep.analysis import harmonic_amplitudes + + base = harmonic_amplitudes(_harmonic_evoked([1.0, 0.5])) + doubled = harmonic_amplitudes(_harmonic_evoked([2.0, 1.0])) + assert doubled["f0"] == pytest.approx(2 * base["f0"]) + assert doubled["upper_harmonics"] == pytest.approx(2 * base["upper_harmonics"]) + # A 1 uV tone yields a microvolt-scale (not volt-scale) amplitude. + assert 1e-3 < base["f0"] < 1.0 + + single_uv = harmonic_amplitudes(_harmonic_evoked([1.0])) + single_volts = harmonic_amplitudes(_harmonic_evoked([1.0]), unit_scale=1.0) + assert single_volts["f0"] == pytest.approx(single_uv["f0"] * 1e-6) + + +def test_harmonic_amplitudes_separates_fundamental_from_upper_harmonics(): + from ffrprep.analysis import harmonic_amplitudes + + fundamental_only = harmonic_amplitudes(_harmonic_evoked([1.0])) + harmonics_only = harmonic_amplitudes(_harmonic_evoked([0.0, 1.0, 1.0, 1.0])) + assert fundamental_only["f0"] > fundamental_only["upper_harmonics"] * 5 + assert harmonics_only["upper_harmonics"] > harmonics_only["f0"] * 5 + + +def test_harmonic_amplitudes_validates_inputs(): + from ffrprep.analysis import harmonic_amplitudes + + low_rate = _harmonic_evoked([1.0], sfreq=1000.0) + with pytest.raises(ValueError, match="Nyquist"): + harmonic_amplitudes(low_rate) + with pytest.raises(ValueError, match="fewer than 2 samples"): + harmonic_amplitudes(_harmonic_evoked([1.0]), tmin=0.1, tmax=0.1) + + +def test_stim_to_resp_xcorr_recovers_delay_and_matches_numpy_reference(): + from ffrprep.analysis import stim_to_resp_xcorr + + rng = np.random.default_rng(4) + sfreq = 16384.0 + stim = rng.normal(size=1967) + delay = 164 # samples (~10 ms) + resp = np.concatenate([np.zeros(delay), stim])[: stim.size] + 0.05 * rng.normal(size=stim.size) + + r, z, lag_ms = stim_to_resp_xcorr(stim, resp, sfreq) + + full = np.correlate(resp, stim, mode="full") / np.sqrt(np.sum(stim**2) * np.sum(resp**2)) + assert r == pytest.approx(full.max()) + assert z == pytest.approx(np.arctanh(r)) + assert lag_ms == pytest.approx(delay / sfreq * 1000.0) + + +def test_stim_to_resp_xcorr_does_not_remove_the_mean(): + """'coeff' normalization (MATLAB xcorr) keeps DC: not a Pearson r.""" + from ffrprep.analysis import stim_to_resp_xcorr + + stim = np.ones(200) + resp = np.ones(200) + r, _, lag_ms = stim_to_resp_xcorr(stim, resp, 1000.0) + assert r == pytest.approx(1.0) + assert lag_ms == pytest.approx(0.0) + + +def test_stim_to_resp_xcorr_lag_range_restricts_search(): + from ffrprep.analysis import stim_to_resp_xcorr + + rng = np.random.default_rng(6) + sfreq = 16384.0 + stim = rng.normal(size=1500) + resp = np.concatenate([np.zeros(164), stim])[:1500] # true lag ~ 10 ms + r_all, _, lag_all = stim_to_resp_xcorr(stim, resp, sfreq) + r_win, _, lag_win = stim_to_resp_xcorr(stim, resp, sfreq, lag_range_ms=(0.0, 5.0)) + assert lag_all == pytest.approx(164 / sfreq * 1000) + assert 0.0 <= lag_win <= 5.0 + 1e-6 + assert r_win < r_all + # Window containing the true lag finds it again. + r_in, _, lag_in = stim_to_resp_xcorr(stim, resp, sfreq, lag_range_ms=(6.9, 10.9)) + assert lag_in == pytest.approx(lag_all) + assert r_in == pytest.approx(r_all) + + +def test_stim_to_resp_xcorr_degenerate_inputs(): + from ffrprep.analysis import stim_to_resp_xcorr + + r, z, lag = stim_to_resp_xcorr(np.zeros(50), np.ones(50), 1000.0) + assert np.isnan(r) and np.isnan(z) and np.isnan(lag) + with pytest.raises(ValueError): + stim_to_resp_xcorr([], [1.0], 1000.0) + r, _, _ = stim_to_resp_xcorr(np.ones(10), np.ones(10), 1000.0, lag_range_ms=(500.0, 600.0)) + assert np.isnan(r) + + +def test_load_wav_mono_and_resample_signal(tmp_path): + from scipy.io import wavfile + + from ffrprep.analysis import load_wav_mono, resample_signal + + sfreq_in = 24414 + t = np.arange(4151) / sfreq_in + left = (np.sin(2 * np.pi * 100 * t) * 20000).astype(np.int16) + right = (np.sin(2 * np.pi * 100 * t) * 10000).astype(np.int16) + path = tmp_path / "stim.wav" + wavfile.write(path, sfreq_in, np.column_stack([left, right])) + + data, sfreq = load_wav_mono(path) + assert sfreq == sfreq_in + assert data.ndim == 1 + np.testing.assert_allclose(data, (left.astype(float) + right.astype(float)) / 2) + + out = resample_signal(data, sfreq_in, 16384.0) + assert out.size == pytest.approx(data.size * 16384 / sfreq_in, abs=2) + same = resample_signal(data, 16384.0, 16384.0) + np.testing.assert_array_equal(same, np.asarray(data, dtype=float)) + # A 100 Hz tone stays a 100 Hz tone after resampling. + spectrum = np.abs(np.fft.rfft(out)) + peak_hz = np.fft.rfftfreq(out.size, 1 / 16384.0)[np.argmax(spectrum)] + assert peak_hz == pytest.approx(100.0, abs=3.0) From deba371b45418185be51113ae7f833d64afd0952 Mon Sep 17 00:00:00 2001 From: Kevin Sitek Date: Wed, 23 Sep 2026 22:34:36 -0500 Subject: [PATCH 03/10] Add opt-in FFR metrics and covariates to the group metrics table With --f0 the group table gains rms_snr_polarity_sum, f0_uv and upper_harmonics_uv computed on the sum of the two per-trial-type averages; --stimulus adds stimulus-to-response r/z/lag (and a lag-limited variant); --n-trials-presented adds usable_pct. participants.tsv and --covariates are joined onto the saved TSV. discover_group_inputs returns per-type evokeds, and the report section shows histograms instead of an unreadably tall table for large cohorts. Defaults leave existing group outputs unchanged. Co-Authored-By: Claude Sonnet 5 --- CHANGES.rst | 12 ++ docs/source/api_ref.rst | 1 + docs/source/usage.rst | 18 +++ ffrprep/ffrprep_cli.py | 119 ++++++++++++++++ ffrprep/group.py | 266 ++++++++++++++++++++++++++++++++++-- ffrprep/reports.py | 85 ++++++++---- ffrprep/tests/test_cli.py | 35 +++++ ffrprep/tests/test_group.py | 253 ++++++++++++++++++++++++++++++++++ 8 files changed, 746 insertions(+), 43 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4cdb084..3413ef5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -63,6 +63,18 @@ Group-level analysis - Deliberately aggregation-only, not inferential: no group-level statistics are computed, matching the scope other BIDS Apps (e.g. MRIQC) use for their own "group" level. +- Group-level FFR metrics (opt-in): ``--f0`` adds ``rms_snr_polarity_sum``, + ``f0_uv`` and ``upper_harmonics_uv`` (band-averaged FFT amplitude at the + harmonics of F0, computed on the sum of the two per-trial-type + averages; see ``--n-harmonics``, ``--harmonic-bin-hz``, + ``--harmonic-window``); ``--stimulus`` adds ``stim2resp_r/z/lag_ms`` + (and ``stim2resp_lim_*`` with ``--xcorr-lag-range``); ``--n-trials-presented`` + adds ``usable_pct``. ``discover_group_inputs`` now also returns the + per-trial-type evoked files (``"by_type"``). +- The group metrics TSV joins subject-level covariates from + ``/participants.tsv`` and ``--covariates`` (``merge_covariates``); + the report section switches to per-metric histograms for cohorts above + 40 subjects (the per-subject values stay in the TSV). - ``ffrprep.reports`` gains ``build_group_report`` and ``build_metrics_table_section``; ``build_subject_report`` / ``build_analysis_report`` pass new ``entity_label`` / ``meta_label`` diff --git a/docs/source/api_ref.rst b/docs/source/api_ref.rst index bdfa560..2817923 100644 --- a/docs/source/api_ref.rst +++ b/docs/source/api_ref.rst @@ -112,6 +112,7 @@ Reference API discover_group_inputs compute_grand_average compute_subject_metrics + merge_covariates save_group_outputs save_group_metrics run_group_level diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 0866486..64e433a 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -265,6 +265,24 @@ recomputed from each subject's saved evoked/epochs), and a single ``--task`` restrict which subjects/tasks are aggregated, same as at the participant level. +Optional metrics for FFR studies are switched on with flags. With +``--f0`` the metrics table gains ``f0_uv`` and ``upper_harmonics_uv`` (FFT +amplitude, in microvolts, averaged in a 60 Hz band around each harmonic of +the stimulus F0 over 60-180 ms) and ``rms_snr_polarity_sum``, all computed +on the **sum** of the two per-trial-type averages. With ``--stimulus`` (a +WAV file) it also gains ``stim2resp_r`` / ``stim2resp_z`` / +``stim2resp_lag_ms``, the maximum stimulus-to-response cross-correlation +(``xcorr`` "coeff" normalization, Fisher z). ``participants.tsv`` from the +BIDS directory (plus any ``--covariates`` TSVs) is joined onto the saved +table so downstream statistics have group, age, etc. alongside the metrics: + +.. code-block:: bash + + ffrprep /data /data/derivatives group \ + --f0 100 --n-harmonics 10 --harmonic-window 0.06 0.18 \ + --stimulus /path/to/da.wav --xcorr-stim-window 0.05 0.17 \ + --xcorr-resp-window 0.06 0.18 --n-trials-presented 6000 + This step only aggregates outputs participant-level ffrprep has already computed — it does not perform any group-level statistics (no hypothesis tests, no GLM). Downstream statistical analysis is diff --git a/ffrprep/ffrprep_cli.py b/ffrprep/ffrprep_cli.py index 5861d30..4fda5e2 100644 --- a/ffrprep/ffrprep_cli.py +++ b/ffrprep/ffrprep_cli.py @@ -1807,6 +1807,125 @@ def get_parser(): default=[0.100, 0.200], ) + # Group-level options + group_group = parser.add_argument_group( + "Group-level options", + "Used with the 'group' analysis level. They add optional columns to " + "the group metrics table; nothing here performs statistical inference.", + ) + group_group.add_argument( + "--f0", + type=float, + default=None, + help=( + "Stimulus fundamental frequency in Hz. When set, the group metrics " + "table gains rms_snr_polarity_sum, f0_uv and upper_harmonics_uv " + "(band-averaged FFT amplitude at each harmonic, in microvolts) " + "computed on the SUM of the two per-trial-type averages." + ), + ) + group_group.add_argument( + "--n-harmonics", + dest="n_harmonics", + type=int, + default=10, + help="Number of harmonics (including the fundamental) for --f0.", + ) + group_group.add_argument( + "--harmonic-bin-hz", + dest="harmonic_bin_hz", + type=float, + default=60.0, + help="Full width in Hz of the band averaged around each harmonic.", + ) + group_group.add_argument( + "--harmonic-window", + dest="harmonic_window", + nargs=2, + type=float, + metavar=("START", "END"), + default=[0.060, 0.180], + help="Response window in seconds for the harmonic FFT.", + ) + group_group.add_argument( + "--stimulus", + type=Path, + default=None, + help=( + "WAV file of the stimulus (t=0 at stimulus onset). When set, the " + "group metrics table gains stim2resp_r / stim2resp_z / " + "stim2resp_lag_ms: the maximum stimulus-to-response " + "cross-correlation ('coeff' normalization, Fisher z) on the " + "polarity-summed response. The stimulus is resampled to the EEG " + "sampling rate." + ), + ) + group_group.add_argument( + "--xcorr-stim-window", + dest="xcorr_stim_window", + nargs=2, + type=float, + metavar=("START", "END"), + default=[0.050, 0.170], + help="Stimulus segment in seconds for --stimulus.", + ) + group_group.add_argument( + "--xcorr-resp-window", + dest="xcorr_resp_window", + nargs=2, + type=float, + metavar=("START", "END"), + default=[0.060, 0.180], + help="Response segment in seconds for --stimulus.", + ) + group_group.add_argument( + "--xcorr-lag-range", + dest="xcorr_lag_range", + nargs=2, + type=float, + metavar=("MIN_MS", "MAX_MS"), + default=None, + help=( + "Additionally report the maximum correlation restricted to this " + "lag window in ms (columns stim2resp_lim_*). Positive lag = " + "response delayed relative to the stimulus, with both segments " + "aligned at their start." + ), + ) + group_group.add_argument( + "--xcorr-lag-resp-window", + dest="xcorr_lag_resp_window", + nargs=2, + type=float, + metavar=("START", "END"), + default=None, + help=( + "Response segment in seconds for the lag-restricted correlation " + "(defaults to --xcorr-resp-window)." + ), + ) + group_group.add_argument( + "--n-trials-presented", + dest="n_trials_presented", + type=int, + default=None, + help=( + "Trials presented per recording; adds usable_pct (kept trials / " + "presented) to the group metrics table." + ), + ) + group_group.add_argument( + "--covariates", + nargs="+", + type=Path, + default=None, + help=( + "Extra subject-level TSV files (with a participant_id column) to " + "join onto the group metrics table, in addition to " + "/participants.tsv." + ), + ) + # General options parser.add_argument( "--skip_bids_validation", diff --git a/ffrprep/group.py b/ffrprep/group.py index 2ceaede..c8a7007 100644 --- a/ffrprep/group.py +++ b/ffrprep/group.py @@ -20,7 +20,15 @@ import pandas as pd import ffrprep.reports as reports -from ffrprep.analysis import compute_power, response_consistency, rms_snr +from ffrprep.analysis import ( + compute_power, + harmonic_amplitudes, + load_wav_mono, + resample_signal, + response_consistency, + rms_snr, + stim_to_resp_xcorr, +) # Matches combined/diff evoked filenames written by # preproc.save_analysis_outputs(), e.g. @@ -34,6 +42,7 @@ r"_desc-(?P[A-Za-z0-9]+)\.fif$" ) _DIFF_DESC_RE = re.compile(r"^evokedDiff([A-Za-z0-9]+)Vs([A-Za-z0-9]+)$") +_PER_TYPE_DESC_RE = re.compile(r"^evoked(?P