[7f9fb8]: / mne / chpi.py

Download this file

1609 lines (1429 with data), 55.9 kB

   1
   2
   3
   4
   5
   6
   7
   8
   9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
"""Functions for fitting head positions with (c)HPI coils.
``compute_head_pos`` can be used to:
1. Drop coils whose GOF are below ``gof_limit``. If fewer than 3 coils
remain, abandon fitting for the chunk.
2. Fit dev_head_t quaternion (using ``_fit_chpi_quat_subset``),
iteratively dropping coils (as long as 3 remain) to find the best GOF
(using ``_fit_chpi_quat``).
3. If fewer than 3 coils meet the ``dist_limit`` criteria following
projection of the fitted device coil locations into the head frame,
abandon fitting for the chunk.
The function ``filter_chpi`` uses the same linear model to filter cHPI
and (optionally) line frequencies from the data.
"""
# Authors: The MNE-Python contributors.
# License: BSD-3-Clause
# Copyright the MNE-Python contributors.
import copy
import itertools
from functools import partial
import numpy as np
from scipy.linalg import orth
from scipy.optimize import fmin_cobyla
from scipy.spatial.distance import cdist
from ._fiff.constants import FIFF
from ._fiff.meas_info import Info, _simplify_info
from ._fiff.pick import (
_picks_to_idx,
pick_channels,
pick_channels_regexp,
pick_info,
pick_types,
)
from ._fiff.proj import Projection, setup_proj
from .channels.channels import _get_meg_system
from .cov import compute_whitener, make_ad_hoc_cov
from .dipole import _make_guesses
from .event import find_events
from .fixes import jit
from .forward import _concatenate_coils, _create_meg_coils, _magnetic_dipole_field_vec
from .io import BaseRaw
from .io.ctf.trans import _make_ctf_coord_trans_set
from .io.kit.constants import KIT
from .io.kit.kit import RawKIT as _RawKIT
from .preprocessing.maxwell import (
_get_mf_picks_fix_mags,
_prep_mf_coils,
_regularize_out,
_sss_basis,
)
from .transforms import (
_angle_between_quats,
_fit_matched_points,
_quat_to_affine,
als_ras_trans,
apply_trans,
invert_transform,
quat_to_rot,
rot_to_quat,
)
from .utils import (
ProgressBar,
_check_fname,
_check_option,
_on_missing,
_pl,
_validate_type,
_verbose_safe_false,
logger,
use_log_level,
verbose,
warn,
)
# Eventually we should add:
# hpicons
# high-passing of data during fits
# parsing cHPI coil information from acq pars, then to PSD if necessary
# ############################################################################
# Reading from text or FIF file
def read_head_pos(fname):
"""Read MaxFilter-formatted head position parameters.
Parameters
----------
fname : path-like
The filename to read. This can be produced by e.g.,
``maxfilter -headpos <name>.pos``.
Returns
-------
pos : array, shape (N, 10)
The position and quaternion parameters from cHPI fitting.
See Also
--------
write_head_pos
head_pos_to_trans_rot_t
Notes
-----
.. versionadded:: 0.12
"""
_check_fname(fname, must_exist=True, overwrite="read")
data = np.loadtxt(fname, skiprows=1) # first line is header, skip it
data.shape = (-1, 10) # ensure it's the right size even if empty
if np.isnan(data).any(): # make sure we didn't do something dumb
raise RuntimeError(f"positions could not be read properly from {fname}")
return data
def write_head_pos(fname, pos):
"""Write MaxFilter-formatted head position parameters.
Parameters
----------
fname : path-like
The filename to write.
pos : array, shape (N, 10)
The position and quaternion parameters from cHPI fitting.
See Also
--------
read_head_pos
head_pos_to_trans_rot_t
Notes
-----
.. versionadded:: 0.12
"""
_check_fname(fname, overwrite=True)
pos = np.array(pos, np.float64)
if pos.ndim != 2 or pos.shape[1] != 10:
raise ValueError("pos must be a 2D array of shape (N, 10)")
with open(fname, "wb") as fid:
fid.write(
" Time q1 q2 q3 q4 q5 "
"q6 g-value error velocity\n".encode("ASCII")
)
for p in pos:
fmts = ["% 9.3f"] + ["% 8.5f"] * 9
fid.write(((" " + " ".join(fmts) + "\n") % tuple(p)).encode("ASCII"))
def head_pos_to_trans_rot_t(quats):
"""Convert Maxfilter-formatted head position quaternions.
Parameters
----------
quats : ndarray, shape (N, 10)
MaxFilter-formatted position and quaternion parameters.
Returns
-------
translation : ndarray, shape (N, 3)
Translations at each time point.
rotation : ndarray, shape (N, 3, 3)
Rotations at each time point.
t : ndarray, shape (N,)
The time points.
See Also
--------
read_head_pos
write_head_pos
"""
t = quats[..., 0].copy()
rotation = quat_to_rot(quats[..., 1:4])
translation = quats[..., 4:7].copy()
return translation, rotation, t
@verbose
def extract_chpi_locs_ctf(raw, verbose=None):
r"""Extract cHPI locations from CTF data.
Parameters
----------
raw : instance of Raw
Raw data with CTF cHPI information.
%(verbose)s
Returns
-------
%(chpi_locs)s
Notes
-----
CTF continuous head monitoring stores the x,y,z location (m) of each chpi
coil as separate channels in the dataset:
- ``HLC001[123]\\*`` - nasion
- ``HLC002[123]\\*`` - lpa
- ``HLC003[123]\\*`` - rpa
This extracts these positions for use with
:func:`~mne.chpi.compute_head_pos`.
.. versionadded:: 0.20
"""
# Pick channels corresponding to the cHPI positions
hpi_picks = pick_channels_regexp(raw.info["ch_names"], "HLC00[123][123].*")
# make sure we get 9 channels
if len(hpi_picks) != 9:
raise RuntimeError("Could not find all 9 cHPI channels")
# get indices in alphabetical order
sorted_picks = np.array(sorted(hpi_picks, key=lambda k: raw.info["ch_names"][k]))
# make picks to match order of dig cardinial ident codes.
# LPA (HPIC002[123]-*), NAS(HPIC001[123]-*), RPA(HPIC003[123]-*)
hpi_picks = sorted_picks[[3, 4, 5, 0, 1, 2, 6, 7, 8]]
del sorted_picks
# process the entire run
time_sl = slice(0, len(raw.times))
chpi_data = raw[hpi_picks, time_sl][0]
# transforms
tmp_trans = _make_ctf_coord_trans_set(None, None)
ctf_dev_dev_t = tmp_trans["t_ctf_dev_dev"]
del tmp_trans
# find indices where chpi locations change
indices = [0]
indices.extend(np.where(np.any(np.diff(chpi_data, axis=1), axis=0))[0] + 1)
# data in channels are in ctf device coordinates (cm)
rrs = chpi_data[:, indices].T.reshape(len(indices), 3, 3) # m
# map to mne device coords
rrs = apply_trans(ctf_dev_dev_t, rrs)
gofs = np.ones(rrs.shape[:2]) # not encoded, set all good
moments = np.zeros(rrs.shape) # not encoded, set all zero
times = raw.times[indices] + raw._first_time
return dict(rrs=rrs, gofs=gofs, times=times, moments=moments)
@verbose
def extract_chpi_locs_kit(raw, stim_channel="MISC 064", *, verbose=None):
"""Extract cHPI locations from KIT data.
Parameters
----------
raw : instance of RawKIT
Raw data with KIT cHPI information.
stim_channel : str
The stimulus channel that encodes HPI measurement intervals.
%(verbose)s
Returns
-------
%(chpi_locs)s
Notes
-----
.. versionadded:: 0.23
"""
_validate_type(raw, (_RawKIT,), "raw")
stim_chs = [
raw.info["ch_names"][pick]
for pick in pick_types(raw.info, stim=True, misc=True, ref_meg=False)
]
_validate_type(stim_channel, str, "stim_channel")
_check_option("stim_channel", stim_channel, stim_chs)
idx = raw.ch_names.index(stim_channel)
safe_false = _verbose_safe_false()
events_on = find_events(
raw, stim_channel=raw.ch_names[idx], output="onset", verbose=safe_false
)[:, 0]
events_off = find_events(
raw, stim_channel=raw.ch_names[idx], output="offset", verbose=safe_false
)[:, 0]
bad = False
if len(events_on) == 0 or len(events_off) == 0:
bad = True
else:
if events_on[-1] > events_off[-1]:
events_on = events_on[:-1]
if events_on.size != events_off.size or not (events_on < events_off).all():
bad = True
if bad:
raise RuntimeError(
f"Could not find appropriate cHPI intervals from {stim_channel}"
)
# use the midpoint for times
times = (events_on + events_off) / (2 * raw.info["sfreq"])
del events_on, events_off
# XXX remove first two rows. It is unknown currently if there is a way to
# determine from the con file the number of initial pulses that
# indicate the start of reading. The number is shown by opening the con
# file in MEG160, but I couldn't find the value in the .con file, so it
# may just always be 2...
times = times[2:]
n_coils = 5 # KIT always has 5 (hard-coded in reader)
header = raw._raw_extras[0]["dirs"][KIT.DIR_INDEX_CHPI_DATA]
dtype = np.dtype([("good", "<u4"), ("data", "<f8", (4,))])
assert dtype.itemsize == header["size"], (dtype.itemsize, header["size"])
all_data = list()
for fname in raw.filenames:
with open(fname) as fid:
fid.seek(header["offset"])
all_data.append(
np.fromfile(fid, dtype, count=header["count"]).reshape(-1, n_coils)
)
data = np.concatenate(all_data)
extra = ""
if len(times) < len(data):
extra = f", truncating to {len(times)} based on events"
logger.info(f"Found {len(data)} cHPI measurement{_pl(len(data))}{extra}")
data = data[: len(times)]
# good is not currently used, but keep this in case we want it later
# good = data['good'] == 1
data = data["data"]
rrs, gofs = data[:, :, :3], data[:, :, 3]
rrs = apply_trans(als_ras_trans, rrs)
moments = np.zeros(rrs.shape) # not encoded, set all zero
return dict(rrs=rrs, gofs=gofs, times=times, moments=moments)
# ############################################################################
# Estimate positions from data
@verbose
def get_chpi_info(info, on_missing="raise", verbose=None):
"""Retrieve cHPI information from the data.
Parameters
----------
%(info_not_none)s
%(on_missing_chpi)s
%(verbose)s
Returns
-------
hpi_freqs : array, shape (n_coils,)
The frequency used for each individual cHPI coil.
hpi_pick : int | None
The index of the ``STIM`` channel containing information about when
which cHPI coils were switched on.
hpi_on : array, shape (n_coils,)
The values coding for the "on" state of each individual cHPI coil.
Notes
-----
.. versionadded:: 0.24
"""
_validate_type(item=info, item_name="info", types=Info)
_check_option(
parameter="on_missing",
value=on_missing,
allowed_values=["ignore", "raise", "warn"],
)
if len(info["hpi_meas"]) == 0 or (
"coil_freq" not in info["hpi_meas"][0]["hpi_coils"][0]
):
_on_missing(
on_missing,
msg="No appropriate cHPI information found in "
'info["hpi_meas"] and info["hpi_subsystem"]',
)
return np.empty(0), None, np.empty(0)
hpi_coils = sorted(
info["hpi_meas"][-1]["hpi_coils"], key=lambda x: x["number"]
) # ascending (info) order
# get frequencies
hpi_freqs = np.array([float(x["coil_freq"]) for x in hpi_coils])
logger.info(
f"Using {len(hpi_freqs)} HPI coils: {' '.join(str(int(s)) for s in hpi_freqs)} "
"Hz"
)
# how cHPI active is indicated in the FIF file
hpi_sub = info["hpi_subsystem"]
hpi_pick = None # there is no pick!
if hpi_sub is not None:
if "event_channel" in hpi_sub:
hpi_pick = pick_channels(
info["ch_names"], [hpi_sub["event_channel"]], ordered=False
)
hpi_pick = hpi_pick[0] if len(hpi_pick) > 0 else None
# grab codes indicating a coil is active
hpi_on = [coil["event_bits"][0] for coil in hpi_sub["hpi_coils"]]
# not all HPI coils will actually be used
hpi_on = np.array([hpi_on[hc["number"] - 1] for hc in hpi_coils])
# mask for coils that may be active
hpi_mask = np.array([event_bit != 0 for event_bit in hpi_on])
hpi_on = hpi_on[hpi_mask]
hpi_freqs = hpi_freqs[hpi_mask]
else:
hpi_on = np.zeros(len(hpi_freqs))
return hpi_freqs, hpi_pick, hpi_on
@verbose
def _get_hpi_initial_fit(info, adjust=False, verbose=None):
"""Get HPI fit locations from raw."""
if info["hpi_results"] is None or len(info["hpi_results"]) == 0:
raise RuntimeError("no initial cHPI head localization performed")
hpi_result = info["hpi_results"][-1]
hpi_dig = sorted(
[d for d in info["dig"] if d["kind"] == FIFF.FIFFV_POINT_HPI],
key=lambda x: x["ident"],
) # ascending (dig) order
if len(hpi_dig) == 0: # CTF data, probably
msg = "HPIFIT: No HPI dig points, using hpifit result"
hpi_dig = sorted(hpi_result["dig_points"], key=lambda x: x["ident"])
if all(
d["coord_frame"] in (FIFF.FIFFV_COORD_DEVICE, FIFF.FIFFV_COORD_UNKNOWN)
for d in hpi_dig
):
# Do not modify in place!
hpi_dig = copy.deepcopy(hpi_dig)
msg += " transformed to head coords"
for dig in hpi_dig:
dig.update(
r=apply_trans(info["dev_head_t"], dig["r"]),
coord_frame=FIFF.FIFFV_COORD_HEAD,
)
logger.debug(msg)
# zero-based indexing, dig->info
# CTF does not populate some entries so we use .get here
pos_order = hpi_result.get("order", np.arange(1, len(hpi_dig) + 1)) - 1
used = hpi_result.get("used", np.arange(len(hpi_dig)))
dist_limit = hpi_result.get("dist_limit", 0.005)
good_limit = hpi_result.get("good_limit", 0.98)
goodness = hpi_result.get("goodness", np.ones(len(hpi_dig)))
# this shouldn't happen, eventually we could add the transforms
# necessary to put it in head coords
if not all(d["coord_frame"] == FIFF.FIFFV_COORD_HEAD for d in hpi_dig):
raise RuntimeError("cHPI coordinate frame incorrect")
# Give the user some info
logger.info(
f"HPIFIT: {len(pos_order)} coils digitized in order "
f"{' '.join(str(o + 1) for o in pos_order)}"
)
logger.debug(
f"HPIFIT: {len(used)} coils accepted: {' '.join(str(h) for h in used)}"
)
hpi_rrs = np.array([d["r"] for d in hpi_dig])[pos_order]
assert len(hpi_rrs) >= 3
# Fitting errors
hpi_rrs_fit = sorted(
[d for d in info["hpi_results"][-1]["dig_points"]], key=lambda x: x["ident"]
)
hpi_rrs_fit = np.array([d["r"] for d in hpi_rrs_fit])
# hpi_result['dig_points'] are in FIFFV_COORD_UNKNOWN coords, but this
# is probably a misnomer because it should be FIFFV_COORD_DEVICE for this
# to work
assert hpi_result["coord_trans"]["to"] == FIFF.FIFFV_COORD_HEAD
hpi_rrs_fit = apply_trans(hpi_result["coord_trans"]["trans"], hpi_rrs_fit)
if "moments" in hpi_result:
logger.debug(f"Hpi coil moments {hpi_result['moments'].shape[::-1]}:")
for moment in hpi_result["moments"]:
logger.debug(f"{moment[0]:g} {moment[1]:g} {moment[2]:g}")
errors = np.linalg.norm(hpi_rrs - hpi_rrs_fit, axis=1)
logger.debug(f"HPIFIT errors: {', '.join(f'{1000 * e:0.1f}' for e in errors)} mm.")
if errors.sum() < len(errors) * dist_limit:
logger.info("HPI consistency of isotrak and hpifit is OK.")
elif not adjust and (len(used) == len(hpi_dig)):
warn("HPI consistency of isotrak and hpifit is poor.")
else:
# adjust HPI coil locations using the hpifit transformation
for hi, (err, r_fit) in enumerate(zip(errors, hpi_rrs_fit)):
# transform to head frame
d = 1000 * err
if not adjust:
if err >= dist_limit:
warn(
f"Discrepancy of HPI coil {hi + 1} isotrak and hpifit is "
f"{d:.1f} mm!"
)
elif hi + 1 not in used:
if goodness[hi] >= good_limit:
logger.info(
f"Note: HPI coil {hi + 1} isotrak is adjusted by {d:.1f} mm!"
)
hpi_rrs[hi] = r_fit
else:
warn(
f"Discrepancy of HPI coil {hi + 1} isotrak and hpifit of "
f"{d:.1f} mm was not adjusted!"
)
logger.debug(
f"HP fitting limits: err = {1000 * dist_limit:.1f} mm, gval = {good_limit:.3f}."
)
return hpi_rrs.astype(float)
def _magnetic_dipole_objective(
x, B, B2, coils, whitener, too_close, return_moment=False
):
"""Project data onto right eigenvectors of whitened forward."""
fwd = _magnetic_dipole_field_vec(x[np.newaxis], coils, too_close)
out, u, s, one = _magnetic_dipole_delta(fwd, whitener, B, B2)
if return_moment:
one /= s
Q = np.dot(one, u.T)
out = (out, Q)
return out
@jit()
def _magnetic_dipole_delta(fwd, whitener, B, B2):
# Here we use .T to get whitener to Fortran order, which speeds things up
fwd = np.dot(fwd, whitener.T)
u, s, v = np.linalg.svd(fwd, full_matrices=False)
one = np.dot(v, B)
Bm2 = np.dot(one, one)
return B2 - Bm2, u, s, one
def _magnetic_dipole_delta_multi(whitened_fwd_svd, B, B2):
# Here we use .T to get whitener to Fortran order, which speeds things up
one = np.matmul(whitened_fwd_svd, B)
Bm2 = np.sum(one * one, axis=1)
return B2 - Bm2
def _fit_magnetic_dipole(B_orig, x0, too_close, whitener, coils, guesses):
"""Fit a single bit of data (x0 = pos)."""
B = np.dot(whitener, B_orig)
B2 = np.dot(B, B)
objective = partial(
_magnetic_dipole_objective,
B=B,
B2=B2,
coils=coils,
whitener=whitener,
too_close=too_close,
)
if guesses is not None:
res0 = objective(x0)
res = _magnetic_dipole_delta_multi(guesses["whitened_fwd_svd"], B, B2)
assert res.shape == (guesses["rr"].shape[0],)
idx = np.argmin(res)
if res[idx] < res0:
x0 = guesses["rr"][idx]
x = fmin_cobyla(objective, x0, (), rhobeg=1e-3, rhoend=1e-5, disp=False)
gof, moment = objective(x, return_moment=True)
gof = 1.0 - gof / B2
return x, gof, moment
@jit()
def _chpi_objective(x, coil_dev_rrs, coil_head_rrs):
"""Compute objective function."""
d = np.dot(coil_dev_rrs, quat_to_rot(x[:3]).T)
d += x[3:]
d -= coil_head_rrs
d *= d
return d.sum()
def _fit_chpi_quat(coil_dev_rrs, coil_head_rrs):
"""Fit rotation and translation (quaternion) parameters for cHPI coils."""
denom = np.linalg.norm(coil_head_rrs - np.mean(coil_head_rrs, axis=0))
denom *= denom
# We could try to solve it the analytic way:
# XXX someday we could choose to weight these points by their goodness
# of fit somehow.
quat = _fit_matched_points(coil_dev_rrs, coil_head_rrs)[0]
gof = 1.0 - _chpi_objective(quat, coil_dev_rrs, coil_head_rrs) / denom
return quat, gof
def _fit_coil_order_dev_head_trans(dev_pnts, head_pnts, bias=True):
"""Compute Device to Head transform allowing for permutiatons of points."""
id_quat = np.zeros(6)
best_order = None
best_g = -999
best_quat = id_quat
for this_order in itertools.permutations(np.arange(len(head_pnts))):
head_pnts_tmp = head_pnts[np.array(this_order)]
this_quat, g = _fit_chpi_quat(dev_pnts, head_pnts_tmp)
assert np.linalg.det(quat_to_rot(this_quat[:3])) > 0.9999
if bias:
# For symmetrical arrangements, flips can produce roughly
# equivalent g values. To avoid this, heavily penalize
# large rotations.
rotation = _angle_between_quats(this_quat[:3], np.zeros(3))
check_g = g * max(1.0 - rotation / np.pi, 0) ** 0.25
else:
check_g = g
if check_g > best_g:
out_g = g
best_g = check_g
best_order = np.array(this_order)
best_quat = this_quat
# Convert Quaterion to transform
dev_head_t = _quat_to_affine(best_quat)
return dev_head_t, best_order, out_g
@verbose
def _setup_hpi_amplitude_fitting(
info, t_window, remove_aliased=False, ext_order=1, allow_empty=False, verbose=None
):
"""Generate HPI structure for HPI localization."""
# grab basic info.
on_missing = "raise" if not allow_empty else "ignore"
hpi_freqs, hpi_pick, hpi_ons = get_chpi_info(info, on_missing=on_missing)
# check for maxwell filtering
for ent in info["proc_history"]:
for key in ("sss_info", "max_st"):
if len(ent["max_info"]["sss_info"]) > 0:
warn(
"Fitting cHPI amplitudes after Maxwell filtering may not work, "
"consider fitting on the original data."
)
break
_validate_type(t_window, (str, "numeric"), "t_window")
if info["line_freq"] is not None:
line_freqs = np.arange(
info["line_freq"], info["sfreq"] / 3.0, info["line_freq"]
)
else:
line_freqs = np.zeros([0])
lfs = " ".join(f"{lf}" for lf in line_freqs)
logger.info(f"Line interference frequencies: {lfs} Hz")
# worry about resampled/filtered data.
# What to do e.g. if Raw has been resampled and some of our
# HPI freqs would now be aliased
highest = info.get("lowpass")
highest = info["sfreq"] / 2.0 if highest is None else highest
keepers = hpi_freqs <= highest
if remove_aliased:
hpi_freqs = hpi_freqs[keepers]
hpi_ons = hpi_ons[keepers]
elif not keepers.all():
raise RuntimeError(
f"Found HPI frequencies {hpi_freqs[~keepers].tolist()} above the lowpass ("
f"or Nyquist) frequency {highest:0.1f}"
)
# calculate optimal window length.
if isinstance(t_window, str):
_check_option("t_window", t_window, ("auto",), extra="if a string")
if len(hpi_freqs):
all_freqs = np.concatenate((hpi_freqs, line_freqs))
delta_freqs = np.diff(np.unique(all_freqs))
t_window = max(5.0 / all_freqs.min(), 1.0 / delta_freqs.min())
else:
t_window = 0.2
t_window = float(t_window)
if t_window <= 0:
raise ValueError(f"t_window ({t_window}) must be > 0")
logger.info(f"Using time window: {1000 * t_window:0.1f} ms")
window_nsamp = np.rint(t_window * info["sfreq"]).astype(int)
model = _setup_hpi_glm(hpi_freqs, line_freqs, info["sfreq"], window_nsamp)
inv_model = np.linalg.pinv(model)
inv_model_reord = _reorder_inv_model(inv_model, len(hpi_freqs))
proj, proj_op, meg_picks = _setup_ext_proj(info, ext_order)
# include mag and grad picks separately, for SNR computations
mag_subpicks = _picks_to_idx(info, "mag", allow_empty=True)
mag_subpicks = np.searchsorted(meg_picks, mag_subpicks)
grad_subpicks = _picks_to_idx(info, "grad", allow_empty=True)
grad_subpicks = np.searchsorted(meg_picks, grad_subpicks)
# Set up magnetic dipole fits
hpi = dict(
meg_picks=meg_picks,
mag_subpicks=mag_subpicks,
grad_subpicks=grad_subpicks,
hpi_pick=hpi_pick,
model=model,
inv_model=inv_model,
t_window=t_window,
inv_model_reord=inv_model_reord,
on=hpi_ons,
n_window=window_nsamp,
proj=proj,
proj_op=proj_op,
freqs=hpi_freqs,
line_freqs=line_freqs,
)
return hpi
def _setup_hpi_glm(hpi_freqs, line_freqs, sfreq, window_nsamp):
"""Initialize a general linear model for HPI amplitude estimation."""
slope = np.linspace(-0.5, 0.5, window_nsamp)[:, np.newaxis]
radians_per_sec = 2 * np.pi * np.arange(window_nsamp, dtype=float) / sfreq
f_t = hpi_freqs[np.newaxis, :] * radians_per_sec[:, np.newaxis]
l_t = line_freqs[np.newaxis, :] * radians_per_sec[:, np.newaxis]
model = [
np.sin(f_t),
np.cos(f_t), # hpi freqs
np.sin(l_t),
np.cos(l_t), # line freqs
slope,
np.ones_like(slope),
] # drift, DC
return np.hstack(model)
@jit()
def _reorder_inv_model(inv_model, n_freqs):
# Reorder for faster computation
idx = np.arange(2 * n_freqs).reshape(2, n_freqs).T.ravel()
return inv_model[idx]
def _setup_ext_proj(info, ext_order):
meg_picks = pick_types(info, meg=True, eeg=False, exclude="bads")
info = pick_info(_simplify_info(info), meg_picks) # makes a copy
_, _, _, _, mag_or_fine = _get_mf_picks_fix_mags(
info, int_order=0, ext_order=ext_order, ignore_ref=True, verbose="error"
)
mf_coils = _prep_mf_coils(info, verbose="error")
ext = _sss_basis(
dict(origin=(0.0, 0.0, 0.0), int_order=0, ext_order=ext_order), mf_coils
).T
out_removes = _regularize_out(0, 1, mag_or_fine, [])
ext = ext[~np.isin(np.arange(len(ext)), out_removes)]
ext = orth(ext.T).T
assert ext.shape[1] == len(meg_picks)
proj = Projection(
kind=FIFF.FIFFV_PROJ_ITEM_HOMOG_FIELD,
desc="SSS",
active=False,
data=dict(
data=ext, ncol=info["nchan"], col_names=info["ch_names"], nrow=len(ext)
),
)
with info._unlock():
info["projs"] = [proj]
proj_op, _ = setup_proj(
info, add_eeg_ref=False, activate=False, verbose=_verbose_safe_false()
)
assert proj_op.shape == (len(meg_picks),) * 2
return proj, proj_op, meg_picks
def _time_prefix(fit_time):
"""Format log messages."""
return (f" t={fit_time:0.3f}:").ljust(17)
def _fit_chpi_amplitudes(raw, time_sl, hpi, snr=False):
"""Fit amplitudes for each channel from each of the N cHPI sinusoids.
Returns
-------
sin_fit : ndarray, shape (n_freqs, n_channels)
The sin amplitudes matching each cHPI frequency.
Will be all nan if this time window should be skipped.
snr : ndarray, shape (n_freqs, 2)
Estimated SNR for this window, separately for mag and grad channels.
"""
# No need to detrend the data because our model has a DC term
with use_log_level(False):
# loads good channels
this_data = raw[hpi["meg_picks"], time_sl][0]
# which HPI coils to use
if hpi["hpi_pick"] is not None:
with use_log_level(False):
# loads hpi_stim channel
chpi_data = raw[hpi["hpi_pick"], time_sl][0]
ons = (np.round(chpi_data).astype(np.int64) & hpi["on"][:, np.newaxis]).astype(
bool
)
n_on = ons.all(axis=-1).sum(axis=0)
if not (n_on >= 3).all():
return None
if snr:
return _fast_fit_snr(
this_data,
len(hpi["freqs"]),
hpi["model"],
hpi["inv_model"],
hpi["mag_subpicks"],
hpi["grad_subpicks"],
)
return _fast_fit(
this_data,
hpi["proj_op"],
len(hpi["freqs"]),
hpi["model"],
hpi["inv_model_reord"],
)
@jit()
def _fast_fit(this_data, proj, n_freqs, model, inv_model_reord):
# first or last window
if this_data.shape[1] != model.shape[0]:
model = model[: this_data.shape[1]]
inv_model_reord = _reorder_inv_model(np.linalg.pinv(model), n_freqs)
proj_data = proj @ this_data
X = inv_model_reord @ proj_data.T
sin_fit = np.zeros((n_freqs, X.shape[1]))
for fi in range(n_freqs):
# use SVD across all sensors to estimate the sinusoid phase
u, s, vt = np.linalg.svd(X[2 * fi : 2 * fi + 2], full_matrices=False)
# the first component holds the predominant phase direction
# (so ignore the second, effectively doing s[1] = 0):
sin_fit[fi] = vt[0] * s[0]
return sin_fit
@jit()
def _fast_fit_snr(this_data, n_freqs, model, inv_model, mag_picks, grad_picks):
# first or last window
if this_data.shape[1] != model.shape[0]:
model = model[: this_data.shape[1]]
inv_model = np.linalg.pinv(model)
coefs = np.ascontiguousarray(inv_model) @ np.ascontiguousarray(this_data.T)
# average sin & cos terms (special property of sinusoids: power=A²/2)
hpi_power = (coefs[:n_freqs] ** 2 + coefs[n_freqs : (2 * n_freqs)] ** 2) / 2
resid = this_data - np.ascontiguousarray((model @ coefs).T)
# can't use np.var(..., axis=1) with Numba, so do it manually:
resid_mean = np.atleast_2d(resid.sum(axis=1) / resid.shape[1]).T
squared_devs = np.abs(resid - resid_mean) ** 2
resid_var = squared_devs.sum(axis=1) / squared_devs.shape[1]
# output array will be (n_freqs, 3 * n_ch_types). The 3 columns for each
# channel type are the SNR, the mean cHPI power and the residual variance
# (which gets tiled to shape (n_freqs,) because it's a scalar).
snrs = np.empty((n_freqs, 0))
# average power & compute residual variance separately for each ch type
for _picks in (mag_picks, grad_picks):
if len(_picks):
avg_power = hpi_power[:, _picks].sum(axis=1) / len(_picks)
avg_resid = resid_var[_picks].mean() * np.ones(n_freqs)
snr = 10 * np.log10(avg_power / avg_resid)
snrs = np.hstack((snrs, np.stack((snr, avg_power, avg_resid), 1)))
return snrs
def _check_chpi_param(chpi_, name):
if name == "chpi_locs":
want_ndims = dict(times=1, rrs=3, moments=3, gofs=2)
extra_keys = list()
else:
assert name == "chpi_amplitudes"
want_ndims = dict(times=1, slopes=3)
extra_keys = ["proj"]
_validate_type(chpi_, dict, name)
want_keys = list(want_ndims.keys()) + extra_keys
if set(want_keys).symmetric_difference(chpi_):
raise ValueError(
f"{name} must be a dict with entries {want_keys}, got "
f"{sorted(chpi_.keys())}"
)
n_times = None
for key, want_ndim in want_ndims.items():
key_str = f"{name}[{key}]"
val = chpi_[key]
_validate_type(val, np.ndarray, key_str)
shape = val.shape
if val.ndim != want_ndim:
raise ValueError(f"{key_str} must have ndim={want_ndim}, got {val.ndim}")
if n_times is None and key != "proj":
n_times = shape[0]
if n_times != shape[0] and key != "proj":
raise ValueError(
f"{name} have inconsistent number of time points in {want_keys}"
)
if name == "chpi_locs":
n_coils = chpi_["rrs"].shape[1]
for key in ("gofs", "moments"):
val = chpi_[key]
if val.shape[1] != n_coils:
raise ValueError(
f'chpi_locs["rrs"] had values for {n_coils} coils but '
f'chpi_locs["{key}"] had values for {val.shape[1]} coils'
)
for key in ("rrs", "moments"):
val = chpi_[key]
if val.shape[2] != 3:
raise ValueError(
f'chpi_locs["{key}"].shape[2] must be 3, got shape {shape}'
)
else:
assert name == "chpi_amplitudes"
slopes, proj = chpi_["slopes"], chpi_["proj"]
_validate_type(proj, Projection, 'chpi_amplitudes["proj"]')
n_ch = len(proj["data"]["col_names"])
if slopes.shape[0] != n_times or slopes.shape[2] != n_ch:
raise ValueError(
f"slopes must have shape[0]=={n_times} and shape[2]=={n_ch}, got shape "
f"{slopes.shape}"
)
@verbose
def compute_head_pos(
info, chpi_locs, dist_limit=0.005, gof_limit=0.98, adjust_dig=False, verbose=None
):
"""Compute time-varying head positions.
Parameters
----------
%(info_not_none)s
%(chpi_locs)s
Typically obtained by :func:`~mne.chpi.compute_chpi_locs` or
:func:`~mne.chpi.extract_chpi_locs_ctf`.
dist_limit : float
Minimum distance (m) to accept for coil position fitting.
gof_limit : float
Minimum goodness of fit to accept for each coil.
%(adjust_dig_chpi)s
%(verbose)s
Returns
-------
quats : ndarray, shape (n_pos, 10)
The ``[t, q1, q2, q3, x, y, z, gof, err, v]`` for each fit.
See Also
--------
compute_chpi_locs
extract_chpi_locs_ctf
read_head_pos
write_head_pos
Notes
-----
.. versionadded:: 0.20
"""
_check_chpi_param(chpi_locs, "chpi_locs")
_validate_type(info, Info, "info")
hpi_dig_head_rrs = _get_hpi_initial_fit(info, adjust=adjust_dig, verbose="error")
n_coils = len(hpi_dig_head_rrs)
coil_dev_rrs = apply_trans(invert_transform(info["dev_head_t"]), hpi_dig_head_rrs)
dev_head_t = info["dev_head_t"]["trans"]
pos_0 = dev_head_t[:3, 3]
last = dict(
quat_fit_time=-0.1,
coil_dev_rrs=coil_dev_rrs,
quat=np.concatenate([rot_to_quat(dev_head_t[:3, :3]), dev_head_t[:3, 3]]),
)
del coil_dev_rrs
quats = []
for fit_time, this_coil_dev_rrs, g_coils in zip(
*(chpi_locs[key] for key in ("times", "rrs", "gofs"))
):
use_idx = np.where(g_coils >= gof_limit)[0]
#
# 1. Check number of good ones
#
if len(use_idx) < 3:
gofs = ", ".join(f"{g:0.2f}" for g in g_coils)
warn(
f"{_time_prefix(fit_time)}{len(use_idx)}/{n_coils} "
"good HPI fits, cannot determine the transformation "
f"({gofs} GOF)!"
)
continue
#
# 2. Fit the head translation and rotation params (minimize error
# between coil positions and the head coil digitization
# positions) iteratively using different sets of coils.
#
this_quat, g, use_idx = _fit_chpi_quat_subset(
this_coil_dev_rrs, hpi_dig_head_rrs, use_idx
)
#
# 3. Stop if < 3 good
#
# Convert quaterion to transform
this_dev_head_t = _quat_to_affine(this_quat)
est_coil_head_rrs = apply_trans(this_dev_head_t, this_coil_dev_rrs)
errs = np.linalg.norm(hpi_dig_head_rrs - est_coil_head_rrs, axis=1)
n_good = ((g_coils >= gof_limit) & (errs < dist_limit)).sum()
if n_good < 3:
warn_str = ", ".join(
f"{1000 * e:0.1f}::{g:0.2f}" for e, g in zip(errs, g_coils)
)
warn(
f"{_time_prefix(fit_time)}{n_good}/{n_coils} good HPI fits, cannot "
f"determine the transformation ({warn_str} mm/GOF)!"
)
continue
# velocities, in device coords, of HPI coils
dt = fit_time - last["quat_fit_time"]
vs = tuple(
1000.0
* np.linalg.norm(last["coil_dev_rrs"] - this_coil_dev_rrs, axis=1)
/ dt
)
logger.info(
_time_prefix(fit_time)
+ (
"%s/%s good HPI fits, movements [mm/s] = "
+ " / ".join(["% 8.1f"] * n_coils)
)
% ((n_good, n_coils) + vs)
)
# Log results
# MaxFilter averages over a 200 ms window for display, but we don't
for ii in range(n_coils):
if ii in use_idx:
start, end = " ", "/"
else:
start, end = "(", ")"
log_str = (
" "
+ start
+ "{0:6.1f} {1:6.1f} {2:6.1f} / "
+ "{3:6.1f} {4:6.1f} {5:6.1f} / "
+ "g = {6:0.3f} err = {7:4.1f} "
+ end
)
vals = np.concatenate(
(
1000 * hpi_dig_head_rrs[ii],
1000 * est_coil_head_rrs[ii],
[g_coils[ii], 1000 * errs[ii]],
)
)
if len(use_idx) >= 3:
if ii <= 2:
log_str += "{8:6.3f} {9:6.3f} {10:6.3f}"
vals = np.concatenate((vals, this_dev_head_t[ii, :3]))
elif ii == 3:
log_str += "{8:6.1f} {9:6.1f} {10:6.1f}"
vals = np.concatenate((vals, this_dev_head_t[:3, 3] * 1000.0))
logger.debug(log_str.format(*vals))
# resulting errors in head coil positions
d = np.linalg.norm(last["quat"][3:] - this_quat[3:]) # m
r = _angle_between_quats(last["quat"][:3], this_quat[:3]) / dt
v = d / dt # m/s
d = 100 * np.linalg.norm(this_quat[3:] - pos_0) # dis from 1st
logger.debug(
f" #t = {fit_time:0.3f}, #e = {100 * errs.mean():0.2f} cm, #g = {g:0.3f}"
f", #v = {100 * v:0.2f} cm/s, #r = {r:0.2f} rad/s, #d = {d:0.2f} cm"
)
q_rep = " ".join(f"{qq:8.5f}" for qq in this_quat)
logger.debug(f" #t = {fit_time:0.3f}, #q = {q_rep}")
quats.append(
np.concatenate(([fit_time], this_quat, [g], [errs[use_idx].mean()], [v]))
)
last["quat_fit_time"] = fit_time
last["quat"] = this_quat
last["coil_dev_rrs"] = this_coil_dev_rrs
quats = np.array(quats, np.float64)
quats = np.zeros((0, 10)) if quats.size == 0 else quats
return quats
def _fit_chpi_quat_subset(coil_dev_rrs, coil_head_rrs, use_idx):
quat, g = _fit_chpi_quat(coil_dev_rrs[use_idx], coil_head_rrs[use_idx])
out_idx = use_idx.copy()
if len(use_idx) > 3: # try dropping one (recursively)
for di in range(len(use_idx)):
this_use_idx = list(use_idx[:di]) + list(use_idx[di + 1 :])
this_quat, this_g, this_use_idx = _fit_chpi_quat_subset(
coil_dev_rrs, coil_head_rrs, this_use_idx
)
if this_g > g:
quat, g, out_idx = this_quat, this_g, this_use_idx
return quat, g, np.array(out_idx, int)
@verbose
def compute_chpi_snr(
raw, t_step_min=0.01, t_window="auto", ext_order=1, tmin=0, tmax=None, verbose=None
):
"""Compute time-varying estimates of cHPI SNR.
Parameters
----------
raw : instance of Raw
Raw data with cHPI information.
t_step_min : float
Minimum time step to use.
%(t_window_chpi_t)s
%(ext_order_chpi)s
%(tmin_raw)s
%(tmax_raw)s
%(verbose)s
Returns
-------
chpi_snrs : dict
The time-varying cHPI SNR estimates, with entries "times", "freqs",
"snr_mag", "power_mag", and "resid_mag" (and/or "snr_grad",
"power_grad", and "resid_grad", depending on which channel types are
present in ``raw``).
See Also
--------
mne.chpi.compute_chpi_locs, mne.chpi.compute_chpi_amplitudes
Notes
-----
.. versionadded:: 0.24
"""
return _compute_chpi_amp_or_snr(
raw, t_step_min, t_window, ext_order, tmin, tmax, verbose, snr=True
)
@verbose
def compute_chpi_amplitudes(
raw, t_step_min=0.01, t_window="auto", ext_order=1, tmin=0, tmax=None, verbose=None
):
"""Compute time-varying cHPI amplitudes.
Parameters
----------
raw : instance of Raw
Raw data with cHPI information.
t_step_min : float
Minimum time step to use.
%(t_window_chpi_t)s
%(ext_order_chpi)s
%(tmin_raw)s
%(tmax_raw)s
%(verbose)s
Returns
-------
%(chpi_amplitudes)s
See Also
--------
mne.chpi.compute_chpi_locs, mne.chpi.compute_chpi_snr
Notes
-----
This function will:
1. Get HPI frequencies, HPI status channel, HPI status bits,
and digitization order using ``_setup_hpi_amplitude_fitting``.
2. Window data using ``t_window`` (half before and half after ``t``) and
``t_step_min``.
3. Use a linear model (DC + linear slope + sin + cos terms) to fit
sinusoidal amplitudes to MEG channels.
It uses SVD to determine the phase/amplitude of the sinusoids.
In "auto" mode, ``t_window`` will be set to the longer of:
1. Five cycles of the lowest HPI or line frequency.
Ensures that the frequency estimate is stable.
2. The reciprocal of the smallest difference between HPI and line freqs.
Ensures that neighboring frequencies can be disambiguated.
The output is meant to be used with :func:`~mne.chpi.compute_chpi_locs`.
.. versionadded:: 0.20
"""
return _compute_chpi_amp_or_snr(
raw, t_step_min, t_window, ext_order, tmin, tmax, verbose
)
def _compute_chpi_amp_or_snr(
raw,
t_step_min=0.01,
t_window="auto",
ext_order=1,
tmin=0,
tmax=None,
verbose=None,
snr=False,
):
"""Compute cHPI amplitude or SNR.
See compute_chpi_amplitudes for parameter descriptions. One additional
boolean parameter ``snr`` signals whether to return SNR instead of
amplitude.
"""
hpi = _setup_hpi_amplitude_fitting(raw.info, t_window, ext_order=ext_order)
tmin, tmax = raw._tmin_tmax_to_start_stop(tmin, tmax)
tmin = tmin / raw.info["sfreq"]
tmax = tmax / raw.info["sfreq"]
need_win = hpi["t_window"] / 2.0
fit_idxs = raw.time_as_index(
np.arange(tmin + need_win, tmax, t_step_min), use_rounding=True
)
logger.info(
f"Fitting {len(hpi['freqs'])} HPI coil locations at up to "
f"{len(fit_idxs)} time points ({tmax - tmin:.1f} s duration)"
)
del tmin, tmax
sin_fits = dict()
sin_fits["proj"] = hpi["proj"]
sin_fits["times"] = (
np.round(fit_idxs + raw.first_samp - hpi["n_window"] / 2.0) / raw.info["sfreq"]
)
n_times = len(sin_fits["times"])
n_freqs = len(hpi["freqs"])
n_chans = len(sin_fits["proj"]["data"]["col_names"])
if snr:
del sin_fits["proj"]
sin_fits["freqs"] = hpi["freqs"]
ch_types = raw.get_channel_types()
grad_offset = 3 if "mag" in ch_types else 0
for ch_type in ("mag", "grad"):
if ch_type in ch_types:
for key in ("snr", "power", "resid"):
cols = 1 if key == "resid" else n_freqs
sin_fits[f"{ch_type}_{key}"] = np.empty((n_times, cols))
else:
sin_fits["slopes"] = np.empty((n_times, n_freqs, n_chans))
message = f"cHPI {'SNRs' if snr else 'amplitudes'}"
for mi, midpt in enumerate(ProgressBar(fit_idxs, mesg=message)):
#
# 0. determine samples to fit.
#
time_sl = midpt - hpi["n_window"] // 2
time_sl = slice(max(time_sl, 0), min(time_sl + hpi["n_window"], len(raw.times)))
#
# 1. Fit amplitudes for each channel from each of the N sinusoids
#
amps_or_snrs = _fit_chpi_amplitudes(raw, time_sl, hpi, snr)
if snr:
if amps_or_snrs is None:
amps_or_snrs = np.full((n_freqs, grad_offset + 3), np.nan)
# unpack the SNR estimates. mag & grad are returned in one array
# (because of Numba) so take care with which column is which.
# note that mean residual is a scalar (same for all HPI freqs) but
# is returned as a (tiled) vector (again, because Numba) so that's
# why below we take amps_or_snrs[0, 2] instead of [:, 2]
ch_types = raw.get_channel_types()
if "mag" in ch_types:
sin_fits["mag_snr"][mi] = amps_or_snrs[:, 0] # SNR
sin_fits["mag_power"][mi] = amps_or_snrs[:, 1] # mean power
sin_fits["mag_resid"][mi] = amps_or_snrs[0, 2] # mean resid
if "grad" in ch_types:
sin_fits["grad_snr"][mi] = amps_or_snrs[:, grad_offset]
sin_fits["grad_power"][mi] = amps_or_snrs[:, grad_offset + 1]
sin_fits["grad_resid"][mi] = amps_or_snrs[0, grad_offset + 2]
else:
sin_fits["slopes"][mi] = amps_or_snrs
return sin_fits
@verbose
def compute_chpi_locs(
info,
chpi_amplitudes,
t_step_max=1.0,
too_close="raise",
adjust_dig=False,
verbose=None,
):
"""Compute locations of each cHPI coils over time.
Parameters
----------
%(info_not_none)s
%(chpi_amplitudes)s
Typically obtained by :func:`mne.chpi.compute_chpi_amplitudes`.
t_step_max : float
Maximum time step to use.
too_close : str
How to handle HPI positions too close to the sensors,
can be ``'raise'`` (default), ``'warning'``, or ``'info'``.
%(adjust_dig_chpi)s
%(verbose)s
Returns
-------
%(chpi_locs)s
See Also
--------
compute_chpi_amplitudes
compute_head_pos
read_head_pos
write_head_pos
extract_chpi_locs_ctf
Notes
-----
This function is designed to take the output of
:func:`mne.chpi.compute_chpi_amplitudes` and:
1. Get HPI coil locations (as digitized in ``info['dig']``) in head coords.
2. If the amplitudes are 98%% correlated with last position
(and Δt < t_step_max), skip fitting.
3. Fit magnetic dipoles using the amplitudes for each coil frequency.
The number of fitted points ``n_pos`` will depend on the velocity of head
movements as well as ``t_step_max`` (and ``t_step_min`` from
:func:`mne.chpi.compute_chpi_amplitudes`).
.. versionadded:: 0.20
"""
# Set up magnetic dipole fits
_check_option("too_close", too_close, ["raise", "warning", "info"])
_check_chpi_param(chpi_amplitudes, "chpi_amplitudes")
_validate_type(info, Info, "info")
sin_fits = chpi_amplitudes # use the old name below
del chpi_amplitudes
proj = sin_fits["proj"]
meg_picks = pick_channels(info["ch_names"], proj["data"]["col_names"], ordered=True)
info = pick_info(info, meg_picks) # makes a copy
with info._unlock():
info["projs"] = [proj]
del meg_picks, proj
meg_coils = _concatenate_coils(_create_meg_coils(info["chs"], "accurate"))
# Set up external model for interference suppression
safe_false = _verbose_safe_false()
cov = make_ad_hoc_cov(info, verbose=safe_false)
whitener, _ = compute_whitener(cov, info, verbose=safe_false)
# Make some location guesses (1 cm grid)
R = np.linalg.norm(meg_coils[0], axis=1).min()
guesses = _make_guesses(
dict(R=R, r0=np.zeros(3)), 0.01, 0.0, 0.005, verbose=safe_false
)[0]["rr"]
logger.info(
f"Computing {len(guesses)} HPI location guesses "
f"(1 cm grid in a {R * 100:.1f} cm sphere)"
)
fwd = _magnetic_dipole_field_vec(guesses, meg_coils, too_close)
fwd = np.dot(fwd, whitener.T)
fwd.shape = (guesses.shape[0], 3, -1)
fwd = np.linalg.svd(fwd, full_matrices=False)[2]
guesses = dict(rr=guesses, whitened_fwd_svd=fwd)
del fwd, R
iter_ = list(zip(sin_fits["times"], sin_fits["slopes"]))
chpi_locs = dict(times=[], rrs=[], gofs=[], moments=[])
# setup last iteration structure
hpi_dig_dev_rrs = apply_trans(
invert_transform(info["dev_head_t"])["trans"],
_get_hpi_initial_fit(info, adjust=adjust_dig),
)
last = dict(
sin_fit=None,
coil_fit_time=sin_fits["times"][0] - 1,
coil_dev_rrs=hpi_dig_dev_rrs,
)
n_hpi = len(hpi_dig_dev_rrs)
del hpi_dig_dev_rrs
for fit_time, sin_fit in ProgressBar(iter_, mesg="cHPI locations "):
# skip this window if bad
if not np.isfinite(sin_fit).all():
continue
# check if data has sufficiently changed
if last["sin_fit"] is not None: # first iteration
corrs = np.array(
[np.corrcoef(s, lst)[0, 1] for s, lst in zip(sin_fit, last["sin_fit"])]
)
corrs *= corrs
# check to see if we need to continue
if (
fit_time - last["coil_fit_time"] <= t_step_max - 1e-7
and (corrs > 0.98).sum() >= 3
):
# don't need to refit data
continue
# update 'last' sin_fit *before* inplace sign mult
last["sin_fit"] = sin_fit.copy()
#
# 2. Fit magnetic dipole for each coil to obtain coil positions
# in device coordinates
#
coil_fits = [
_fit_magnetic_dipole(f, x0, too_close, whitener, meg_coils, guesses)
for f, x0 in zip(sin_fit, last["coil_dev_rrs"])
]
rrs, gofs, moments = zip(*coil_fits)
chpi_locs["times"].append(fit_time)
chpi_locs["rrs"].append(rrs)
chpi_locs["gofs"].append(gofs)
chpi_locs["moments"].append(moments)
last["coil_fit_time"] = fit_time
last["coil_dev_rrs"] = rrs
n_times = len(chpi_locs["times"])
shapes = dict(
times=(n_times,),
rrs=(n_times, n_hpi, 3),
gofs=(n_times, n_hpi),
moments=(n_times, n_hpi, 3),
)
for key, val in chpi_locs.items():
chpi_locs[key] = np.array(val, float).reshape(shapes[key])
return chpi_locs
def _chpi_locs_to_times_dig(chpi_locs):
"""Reformat chpi_locs as list of dig (dict)."""
dig = list()
for rrs, gofs in zip(*(chpi_locs[key] for key in ("rrs", "gofs"))):
dig.append(
[
{
"r": rr,
"ident": idx,
"gof": gof,
"kind": FIFF.FIFFV_POINT_HPI,
"coord_frame": FIFF.FIFFV_COORD_DEVICE,
}
for idx, (rr, gof) in enumerate(zip(rrs, gofs), 1)
]
)
return chpi_locs["times"], dig
@verbose
def filter_chpi(
raw,
include_line=True,
t_step=0.01,
t_window="auto",
ext_order=1,
allow_line_only=False,
verbose=None,
):
"""Remove cHPI and line noise from data.
.. note:: This function will only work properly if cHPI was on
during the recording.
Parameters
----------
raw : instance of Raw
Raw data with cHPI information. Must be preloaded. Operates in-place.
include_line : bool
If True, also filter line noise.
t_step : float
Time step to use for estimation, default is 0.01 (10 ms).
%(t_window_chpi_t)s
%(ext_order_chpi)s
allow_line_only : bool
If True, allow filtering line noise only. The default is False,
which only allows the function to run when cHPI information is present.
.. versionadded:: 0.20
%(verbose)s
Returns
-------
raw : instance of Raw
The raw data.
Notes
-----
cHPI signals are in general not stationary, because head movements act
like amplitude modulators on cHPI signals. Thus it is recommended to
use this procedure, which uses an iterative fitting method, to
remove cHPI signals, as opposed to notch filtering.
.. versionadded:: 0.12
"""
_validate_type(raw, BaseRaw, "raw")
if not raw.preload:
raise RuntimeError("raw data must be preloaded")
t_step = float(t_step)
if t_step <= 0:
raise ValueError(f"t_step ({t_step}) must be > 0")
n_step = int(np.ceil(t_step * raw.info["sfreq"]))
if include_line and raw.info["line_freq"] is None:
raise RuntimeError(
'include_line=True but raw.info["line_freq"] is '
"None, consider setting it to the line frequency"
)
hpi = _setup_hpi_amplitude_fitting(
raw.info,
t_window,
remove_aliased=True,
ext_order=ext_order,
allow_empty=allow_line_only,
verbose=_verbose_safe_false(),
)
fit_idxs = np.arange(0, len(raw.times) + hpi["n_window"] // 2, n_step)
n_freqs = len(hpi["freqs"])
n_remove = 2 * n_freqs
meg_picks = pick_types(raw.info, meg=True, exclude=()) # filter all chs
n_times = len(raw.times)
msg = f"Removing {n_freqs} cHPI"
if include_line:
n_remove += 2 * len(hpi["line_freqs"])
msg += f" and {len(hpi['line_freqs'])} line harmonic"
msg += f" frequencies from {len(meg_picks)} MEG channels"
recon = np.dot(hpi["model"][:, :n_remove], hpi["inv_model"][:n_remove]).T
logger.info(msg)
chunks = list() # the chunks to subtract
last_endpt = 0
pb = ProgressBar(fit_idxs, mesg="Filtering")
for ii, midpt in enumerate(pb):
left_edge = midpt - hpi["n_window"] // 2
time_sl = slice(
max(left_edge, 0), min(left_edge + hpi["n_window"], len(raw.times))
)
this_len = time_sl.stop - time_sl.start
if this_len == hpi["n_window"]:
this_recon = recon
else: # first or last window
model = hpi["model"][:this_len]
inv_model = np.linalg.pinv(model)
this_recon = np.dot(model[:, :n_remove], inv_model[:n_remove]).T
this_data = raw._data[meg_picks, time_sl]
subt_pt = min(midpt + n_step, n_times)
if last_endpt != subt_pt:
fit_left_edge = left_edge - time_sl.start + hpi["n_window"] // 2
fit_sl = slice(fit_left_edge, fit_left_edge + (subt_pt - last_endpt))
chunks.append((subt_pt, np.dot(this_data, this_recon[:, fit_sl])))
last_endpt = subt_pt
# Consume (trailing) chunks that are now safe to remove because
# our windows will no longer touch them
if ii < len(fit_idxs) - 1:
next_left_edge = fit_idxs[ii + 1] - hpi["n_window"] // 2
else:
next_left_edge = np.inf
while len(chunks) > 0 and chunks[0][0] <= next_left_edge:
right_edge, chunk = chunks.pop(0)
raw._data[meg_picks, right_edge - chunk.shape[1] : right_edge] -= chunk
return raw
def _compute_good_distances(hpi_coil_dists, new_pos, dist_limit=0.005):
"""Compute good coils based on distances."""
these_dists = cdist(new_pos, new_pos)
these_dists = np.abs(hpi_coil_dists - these_dists)
# there is probably a better algorithm for finding the bad ones...
good = False
use_mask = np.ones(len(hpi_coil_dists), bool)
while not good:
d = these_dists[use_mask][:, use_mask]
d_bad = d > dist_limit
good = not d_bad.any()
if not good:
if use_mask.sum() == 2:
use_mask[:] = False
break # failure
# exclude next worst point
badness = (d * d_bad).sum(axis=0)
exclude_coils = np.where(use_mask)[0][np.argmax(badness)]
use_mask[exclude_coils] = False
return use_mask, these_dists
@verbose
def get_active_chpi(raw, *, on_missing="raise", verbose=None):
"""Determine how many HPI coils were active for a time point.
Parameters
----------
raw : instance of Raw
Raw data with cHPI information.
%(on_missing_chpi)s
%(verbose)s
Returns
-------
n_active : array, shape (n_times)
The number of active cHPIs for every timepoint in raw.
Notes
-----
.. versionadded:: 1.2
"""
# get meg system
system, _ = _get_meg_system(raw.info)
# check whether we have a neuromag system
if system not in ["122m", "306m"]:
raise NotImplementedError(
"Identifying active HPI channels is not implemented for other systems than "
"neuromag."
)
# extract hpi info
chpi_info = get_chpi_info(raw.info, on_missing=on_missing)
if (len(chpi_info[2]) == 0) or (chpi_info[1] is None):
return np.zeros_like(raw.times)
# extract hpi time series and infer which one was on
chpi_ts = raw[chpi_info[1]][0].astype(int)
chpi_active = (chpi_ts & chpi_info[2][:, np.newaxis]).astype(bool)
return chpi_active.sum(axis=0)