Skip to content

Commit 93fb1c8

Browse files
authored
BUG: Fix bug with fwd.info["dev_head_t"] setting (#13619)
1 parent afdae06 commit 93fb1c8

7 files changed

Lines changed: 77 additions & 17 deletions

File tree

doc/changes/dev/13619.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix bug with :func:`mne.make_forward_solution` when no MEG channels are present where ``dev_head_t`` was errantly set, by `Eric Larson`_.

mne/forward/_make_forward.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -499,8 +499,7 @@ def _prepare_for_forward(
499499
kwargs_fwd_info = dict(
500500
chs=info["chs"],
501501
comps=info["comps"],
502-
# The forward-writing code always wants a dev_head_t, so give an identity one
503-
dev_head_t=info["dev_head_t"] or Transform("meg", "head"),
502+
dev_head_t=info["dev_head_t"],
504503
mri_file=info_trans,
505504
mri_id=mri_id,
506505
meas_file=info_extra,

mne/forward/forward.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -441,14 +441,13 @@ def _read_forward_meas_info(tree, fid):
441441
# Get the MEG device <-> head coordinate transformation
442442
tag = find_tag(fid, parent_meg, FIFF.FIFF_COORD_TRANS)
443443
if tag is None:
444-
raise ValueError("MEG/head coordinate transformation not found")
445-
cand = tag.data
446-
if cand["from"] == coord_device and cand["to"] == coord_head:
447-
info["dev_head_t"] = cand
448-
elif cand["from"] == coord_ctf_head and cand["to"] == coord_head:
449-
info["ctf_head_t"] = cand
444+
info["dev_head_t"] = None # EEG-only
450445
else:
451-
raise ValueError("MEG/head coordinate transformation not found")
446+
cand = tag.data
447+
if cand["from"] == coord_device and cand["to"] == coord_head:
448+
info["dev_head_t"] = cand
449+
elif cand["from"] == coord_ctf_head and cand["to"] == coord_head:
450+
info["ctf_head_t"] = cand
452451

453452
bads = _read_bad_channels(fid, parent_meg, ch_names_mapping=ch_names_mapping)
454453
# clean up our bad list, old versions could have non-existent bads
@@ -1107,9 +1106,8 @@ def write_forward_meas_info(fid, info):
11071106
write_id(fid, FIFF.FIFF_PARENT_BLOCK_ID, info["meas_id"])
11081107
# get transformation from CTF and DEVICE to HEAD coordinate frame
11091108
meg_head_t = info.get("dev_head_t", info.get("ctf_head_t"))
1110-
if meg_head_t is None:
1111-
raise ValueError("Head<-->sensor transform not found")
1112-
write_coord_trans(fid, meg_head_t)
1109+
if meg_head_t is not None:
1110+
write_coord_trans(fid, meg_head_t)
11131111

11141112
ch_names_mapping = dict()
11151113
if "chs" in info:

mne/forward/tests/test_make_forward.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,11 +836,14 @@ def test_make_forward_no_meg(tmp_path):
836836
trans = None
837837
montage = make_standard_montage("standard_1020")
838838
info = create_info(["Cz"], 1000.0, "eeg").set_montage(montage)
839+
assert info["dev_head_t"] is None # gh-13604
839840
fwd = make_forward_solution(info, trans, src, bem)
841+
assert fwd["info"]["dev_head_t"] is None
840842
fname = tmp_path / "test-fwd.fif"
841843
write_forward_solution(fname, fwd)
842844
fwd_read = read_forward_solution(fname)
843845
assert_allclose(fwd["sol"]["data"], fwd_read["sol"]["data"])
846+
assert fwd_read["info"]["dev_head_t"] is None
844847

845848

846849
def test_use_coil_def(tmp_path):

mne/minimum_norm/tests/test_inverse.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
compute_rank,
2727
compute_raw_covariance,
2828
convert_forward_solution,
29+
create_info,
2930
make_ad_hoc_cov,
3031
make_forward_solution,
3132
make_sphere_model,
@@ -35,7 +36,9 @@
3536
read_cov,
3637
read_evokeds,
3738
read_forward_solution,
39+
setup_volume_source_space,
3840
)
41+
from mne.channels import make_standard_montage
3942
from mne.datasets import testing
4043
from mne.epochs import Epochs, EpochsArray, make_fixed_length_epochs
4144
from mne.event import read_events
@@ -1726,3 +1729,38 @@ def test_allow_mixed_source_spaces(mixed_fwd_cov_evoked):
17261729
inv_op = make_inverse_operator(evoked.info, fwd, cov, loose=0.0)
17271730
stc = apply_inverse(evoked, inv_op, lambda2=1.0 / 9.0) # normal
17281731
assert (stc.data < 0).any()
1732+
1733+
1734+
@testing.requires_testing_data
1735+
def test_make_inverse_no_meg(tmp_path):
1736+
"""Test that we can make and I/O forward solution with no MEG channels."""
1737+
pos = dict(rr=[[0.05, 0, 0]], nn=[[0, 0, 1.0]])
1738+
src = setup_volume_source_space(pos=pos)
1739+
bem = make_sphere_model()
1740+
trans = None
1741+
montage = make_standard_montage("standard_1020")
1742+
info = create_info(["Cz", "Fz"], 1000.0, "eeg").set_montage(montage)
1743+
data = np.random.default_rng(0).standard_normal((len(info["ch_names"]), 10))
1744+
evoked = EvokedArray(data, info)
1745+
evoked.set_eeg_reference(projection=True)
1746+
del info, data
1747+
fwd = make_forward_solution(evoked.info, trans, src, bem)
1748+
assert fwd["info"]["dev_head_t"] is None
1749+
cov = make_ad_hoc_cov(evoked.info)
1750+
# Ensure we maintain backward-compatible behavior with when we *did* save
1751+
# the forward with an identity dev_head_t
1752+
fname = tmp_path / "test-inv.fif"
1753+
invs = list()
1754+
for dev_head_t in (None, mne.Transform("meg", "head")):
1755+
fwd["info"]["dev_head_t"] = dev_head_t
1756+
inv = make_inverse_operator(evoked.info, fwd, cov)
1757+
assert inv["info"]["dev_head_t"] == dev_head_t
1758+
stc = apply_inverse(evoked, inv, lambda2=1.0 / 9.0)
1759+
assert stc.data.shape == (1, 10)
1760+
write_inverse_operator(fname, inv, overwrite=True)
1761+
inv_read = read_inverse_operator(fname)
1762+
assert inv_read["info"]["dev_head_t"] == dev_head_t
1763+
invs.append(inv)
1764+
assert invs[1]["info"]["dev_head_t"] is not None
1765+
invs[1]["info"]["dev_head_t"] = None # for comparison
1766+
_compare(invs[0], invs[1])

mne/simulation/raw.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
setup_volume_source_space,
4242
)
4343
from ..surface import _CheckInside
44-
from ..transforms import _get_trans, transform_surface_to
44+
from ..transforms import Transform, _get_trans, transform_surface_to
4545
from ..utils import (
4646
_check_preload,
4747
_pl,
@@ -131,15 +131,16 @@ def _check_head_pos(head_pos, info, first_samp, times=None):
131131
f"s), found {bad.sum()}/{len(bad)} bad values (is this a split "
132132
"file?)"
133133
)
134+
dev_head_t = info["dev_head_t"] or Transform("meg", "head") # deal with None
134135
# If it starts close to zero, make it zero (else unique(offset) fails)
135136
if len(ts) > 0 and ts[0] < (0.5 / info["sfreq"]):
136137
ts[0] = 0.0
137138
# If it doesn't start at zero, insert one at t=0
138139
elif len(ts) == 0 or ts[0] > 0:
139140
ts = np.r_[[0.0], ts]
140-
dev_head_ts.insert(0, info["dev_head_t"]["trans"])
141+
dev_head_ts.insert(0, dev_head_t["trans"])
141142
dev_head_ts = [
142-
{"trans": d, "to": info["dev_head_t"]["to"], "from": info["dev_head_t"]["from"]}
143+
{"trans": d, "to": dev_head_t["to"], "from": dev_head_t["from"]}
143144
for d in dev_head_ts
144145
]
145146
offsets = np.round(ts * info["sfreq"]).astype(int)
@@ -290,7 +291,8 @@ def simulate_raw(
290291
"If forward is not None then trans, src, bem, "
291292
"and head_pos must all be None"
292293
)
293-
if not np.allclose(
294+
ch_types = info.get_channel_types(unique=True)
295+
if ("mag" in ch_types or "grad" in ch_types) and not np.allclose(
294296
forward["info"]["dev_head_t"]["trans"],
295297
info["dev_head_t"]["trans"],
296298
atol=1e-6,

mne/simulation/tests/test_raw.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
from mne.source_space._source_space import _compare_source_spaces
5555
from mne.surface import _get_ico_surface
5656
from mne.tests.test_chpi import _assert_quats
57-
from mne.transforms import _affine_to_quat
57+
from mne.transforms import Transform, _affine_to_quat
5858
from mne.utils import catch_logging
5959

6060
raw_fname_short = Path(__file__).parents[2] / "io" / "tests" / "data" / "test_raw.fif"
@@ -501,6 +501,25 @@ def test_simulate_round_trip(raw_data):
501501
simulate_raw(raw.info, stc, None, None, None, forward=fwd)
502502

503503

504+
def test_simulate_eeg_only(raw_data):
505+
"""Test simulate_raw with EEG data only."""
506+
# gh-13604
507+
raw, src, stc, trans, sphere = raw_data
508+
raw = raw.pick("eeg")
509+
assert raw.info["dev_head_t"] is not None
510+
with raw.info._unlock():
511+
raw.info["dev_head_t"] = None
512+
fwd = make_forward_solution(raw.info, trans, src, sphere)
513+
assert fwd["info"]["dev_head_t"] is None
514+
# forward with identity dev_head_t (old style) but EEG-only data
515+
raw_sim = simulate_raw(raw.info, stc, None, None, None, forward=fwd)
516+
fwd["info"]["dev_head_t"] = Transform("meg", "head")
517+
raw_sim_2 = simulate_raw(raw.info, stc, None, None, None, forward=fwd)
518+
assert raw_sim.info["dev_head_t"] is None
519+
assert raw_sim_2.info["dev_head_t"] is None
520+
assert_allclose(raw_sim[:][0], raw_sim_2[:][0])
521+
522+
504523
@pytest.mark.slowtest
505524
@testing.requires_testing_data
506525
def test_simulate_raw_chpi():

0 commit comments

Comments
 (0)