4040 pick_types ,
4141)
4242from ._fiff .proj import Projection , setup_proj
43+ from ._ola import _Interp2
4344from .bem import ConductorModel
4445from .channels .channels import _get_meg_system
4546from .cov import compute_whitener , make_ad_hoc_cov
@@ -1548,6 +1549,8 @@ def filter_chpi(
15481549 t_window = "auto" ,
15491550 ext_order = 1 ,
15501551 allow_line_only = False ,
1552+ * ,
1553+ interp = "hann" ,
15511554 verbose = None ,
15521555):
15531556 """Remove cHPI and line noise from data.
@@ -1570,6 +1573,14 @@ def filter_chpi(
15701573 which only allows the function to run when cHPI information is present.
15711574
15721575 .. versionadded:: 0.20
1576+ interp : str | None
1577+ How the fitted cHPI/line amplitude envelope is interpolated between the
1578+ fitting windows before subtraction. ``"hann"`` (default) smoothly interpolates,
1579+ avoiding small step discontinuities (and associated broadband spectral
1580+ leakage) produced by ``None``, which holds each window's amplitudes constant
1581+ over its central ``t_step``. ``"linear"`` interpolation is also supported.
1582+
1583+ .. versionadded:: 1.13
15731584 %(verbose)s
15741585
15751586 Returns
@@ -1607,55 +1618,99 @@ def filter_chpi(
16071618 verbose = _verbose_safe_false (),
16081619 )
16091620
1610- fit_idxs = np . arange ( 0 , len ( raw . times ) + hpi [ "n_window" ] // 2 , n_step )
1621+ _check_option ( "interp" , interp , ( None , "hann" , "cos2" , "linear" ) )
16111622 n_freqs = len (hpi ["freqs" ])
16121623 n_remove = 2 * n_freqs
16131624 meg_picks = pick_types (raw .info , meg = True , exclude = ()) # filter all chs
1614- n_times = len (raw .times )
16151625
16161626 msg = f"Removing { n_freqs } cHPI"
16171627 if include_line :
16181628 n_remove += 2 * len (hpi ["line_freqs" ])
16191629 msg += f" and { len (hpi ['line_freqs' ])} line harmonic"
16201630 msg += f" frequencies from { len (meg_picks )} MEG channels"
1621-
1622- recon = (hpi ["model" ][:, :n_remove ] @ hpi ["inv_model" ][:n_remove ]).T
16231631 logger .info (msg )
1624- chunks = list () # the chunks to subtract
1625- last_endpt = 0
1626- pb = ProgressBar (fit_idxs , mesg = "Filtering" )
1627- for ii , midpt in enumerate (pb ):
1628- left_edge = midpt - hpi ["n_window" ] // 2
1629- time_sl = slice (
1630- max (left_edge , 0 ), min (left_edge + hpi ["n_window" ], len (raw .times ))
1631- )
1632- this_len = time_sl .stop - time_sl .start
1633- if this_len == hpi ["n_window" ]:
1634- this_recon = recon
1635- else : # first or last window
1636- model = hpi ["model" ][:this_len ]
1637- inv_model = np .linalg .pinv (model )
1638- this_recon = (model [:, :n_remove ] @ inv_model [:n_remove ]).T
1639- this_data = raw ._data [meg_picks , time_sl ]
1640- subt_pt = min (midpt + n_step , n_times )
1641- if last_endpt != subt_pt :
1642- fit_left_edge = left_edge - time_sl .start + hpi ["n_window" ] // 2
1643- fit_sl = slice (fit_left_edge , fit_left_edge + (subt_pt - last_endpt ))
1644- chunks .append ((subt_pt , this_data @ this_recon [:, fit_sl ]))
1645- last_endpt = subt_pt
1646-
1647- # Consume (trailing) chunks that are now safe to remove because
1648- # our windows will no longer touch them
1649- if ii < len (fit_idxs ) - 1 :
1650- next_left_edge = fit_idxs [ii + 1 ] - hpi ["n_window" ] // 2
1651- else :
1652- next_left_edge = np .inf
1653- while len (chunks ) > 0 and chunks [0 ][0 ] <= next_left_edge :
1654- right_edge , chunk = chunks .pop (0 )
1655- raw ._data [meg_picks , right_edge - chunk .shape [1 ] : right_edge ] -= chunk
1632+
1633+ # interp=None reproduces the pre-1.13 zero-order-hold subtraction
1634+ _subtract_chpi (
1635+ raw ,
1636+ hpi ,
1637+ meg_picks ,
1638+ n_step ,
1639+ n_remove ,
1640+ include_line ,
1641+ "zero" if interp is None else interp ,
1642+ )
16561643 return raw
16571644
16581645
1646+ def _chpi_fit_coeffs (
1647+ midpt , * , raw , meg_picks , hpi , freqs , n_freqs , n_line , n_remove , inv_cache
1648+ ):
1649+ """Fit absolute-phase sin/cos amplitudes for one control point."""
1650+ n_window = hpi ["n_window" ]
1651+ n_times = len (raw .times )
1652+ sfreq = raw .info ["sfreq" ]
1653+ # window centered on the control point, clamped (shorter) at the edges, to
1654+ # match the legacy per-window fit so that interp="zero" reproduces it
1655+ left_edge = midpt - n_window // 2
1656+ start = max (left_edge , 0 )
1657+ stop = min (left_edge + n_window , n_times )
1658+ this_len = stop - start
1659+ if this_len not in inv_cache :
1660+ inv_cache [this_len ] = np .linalg .pinv (hpi ["model" ][:this_len ])[:n_remove ]
1661+ coef = inv_cache [this_len ] @ raw ._data [meg_picks , start :stop ].T # (n_remove, n_ch)
1662+ # model columns of the removable subspace: [sin_hpi, cos_hpi, sin_line, cos_line]
1663+ sin_sl = slice (0 , n_freqs )
1664+ sin_sl_line = slice (2 * n_freqs , 2 * n_freqs + n_line )
1665+ cos_sl = slice (n_freqs , 2 * n_freqs )
1666+ cos_sl_line = slice (2 * n_freqs + n_line , 2 * n_freqs + 2 * n_line )
1667+ sin_c = np .concatenate ([coef [sin_sl ], coef [sin_sl_line ]])
1668+ cos_c = np .concatenate ([coef [cos_sl ], coef [cos_sl_line ]])
1669+ # rotate window-relative phase (0 at window start) to absolute time
1670+ theta0 = (2 * np .pi * freqs * start / sfreq )[:, np .newaxis ]
1671+ # one-element list (for _Interp2) with array of shape ``(2, n_freq, n_ch)``
1672+ s_t_0 = np .sin (theta0 )
1673+ c_t_0 = np .cos (theta0 )
1674+ return [np .stack ([sin_c * c_t_0 + cos_c * s_t_0 , cos_c * c_t_0 - sin_c * s_t_0 ])]
1675+
1676+
1677+ def _subtract_chpi (raw , hpi , meg_picks , n_step , n_remove , include_line , interp ):
1678+ """Subtract cHPI/line noise from the data."""
1679+ # Fits amplitudes on then uses _Interp2 to interpolate the amplitude envelope
1680+ # before reconstructing and subtracting.
1681+ sfreq = raw .info ["sfreq" ]
1682+ n_times = len (raw .times )
1683+ n_freqs = len (hpi ["freqs" ])
1684+ n_line = len (hpi ["line_freqs" ]) if include_line else 0
1685+ freqs = np .concatenate ([hpi ["freqs" ], hpi ["line_freqs" ][:n_line ]])
1686+ fit = partial (
1687+ _chpi_fit_coeffs ,
1688+ raw = raw ,
1689+ meg_picks = meg_picks ,
1690+ hpi = hpi ,
1691+ freqs = freqs ,
1692+ n_freqs = n_freqs ,
1693+ n_line = n_line ,
1694+ n_remove = n_remove ,
1695+ inv_cache = {},
1696+ )
1697+ interp2 = _Interp2 (np .arange (0 , n_times , n_step ), fit , interp = interp )
1698+ chunk = max (2 * hpi ["n_window" ], 1000 )
1699+ prev = None # (start, stop, recon) subtracted one chunk late (see docstring)
1700+ for start in ProgressBar (range (0 , n_times , chunk ), mesg = "Filtering" ):
1701+ stop = min (start + chunk , n_times )
1702+ vals = interp2 .feed (stop - start )[0 ] # (2, n_freq, n_ch, n_pts)
1703+ phase = 2 * np .pi * np .outer (freqs , np .arange (start , stop )) / sfreq
1704+ recon = np .einsum ("fct,ft->ct" , vals [0 ], np .sin (phase )) + np .einsum (
1705+ "fct,ft->ct" , vals [1 ], np .cos (phase )
1706+ )
1707+ if prev is not None :
1708+ raw ._data [meg_picks , prev [0 ] : prev [1 ]] -= prev [2 ]
1709+ prev = (start , stop , recon )
1710+ if prev is not None :
1711+ raw ._data [meg_picks , prev [0 ] : prev [1 ]] -= prev [2 ]
1712+
1713+
16591714def _compute_good_distances (hpi_coil_dists , new_pos , dist_limit = 0.005 ):
16601715 """Compute good coils based on distances."""
16611716 these_dists = cdist (new_pos , new_pos )
0 commit comments