Repository: mne-tools/mne-python

Issue:
Bug in IO module for BrainVision: Wrong conversion from Seconds to Hertz
In BrainVision Recorder *.vhdr files, the highpass filter settings are often listed as "low-cutoff in seconds". The relevant mne.io function translates this into Hz as such:

https://github.com/mne-tools/mne-python/blob/239c83dfe0686084d1c96c945579dc8cc10e895c/mne/io/brainvision/brainvision.py#L597-L598

With this method, the standard setting of `low-cutoff=10s` is translated to highpass=0.1Hz. However, the [BrainProducts website](http://www.brainproducts.com/productdetails.php?id=2) specifies 0.016Hz (see under "Lower cutoff frequency (high pass) / time constant").

The conversion from seconds to Hz is probably not implemented correctly in the mne.io function for brainvision. Or am I missing something?

Some references:
- [book excerpt with brief explanation and equation](https://books.google.se/books?id=z-PmHHsi4zYC&pg=PA41&lpg=PA41&dq=cut+off+frequency+conversion+hertz+and+seconds&source=bl&ots=g8Cf68JVOy&sig=CuDjd5p59iyyemfpuHu8AUlpnc0&hl=en&sa=X&ved=0ahUKEwi10t-z2trZAhVSsaQKHWh-DS84ChDoAQgmMAA#v=onepage&q=cut%20off%20frequency%20conversion%20hertz%20and%20seconds&f=false)

- [handy calculator and equations](http://www.sengpielaudio.com/calculator-timeconstant.htm)


Discussion:
The fix seems simple enough:

``` python
if hp_s:
    info['highpass'] = 1. / (2 * np.pi * info['highpass'])
```

... and using this approach also for the remaining lines (lowpass filters, and the case for heterogeneous filters) 
Yes this looks correct to me. I've actually never seen this nomenclature before.
... although "time constant" rings a bell in terms of analog filtering and circuits. I wonder if that's where it comes from.

Resolving patch:
diff --git a/mne/io/brainvision/brainvision.py b/mne/io/brainvision/brainvision.py
--- a/mne/io/brainvision/brainvision.py
+++ b/mne/io/brainvision/brainvision.py
@@ -7,6 +7,7 @@
 #          Jona Sassenhagen <jona.sassenhagen@gmail.com>
 #          Phillip Alday <phillip.alday@mpi.nl>
 #          Okba Bekhelifi <okba.bekhelifi@gmail.com>
+#          Stefan Appelhoff <stefan.appelhoff@mailbox.org>
 #
 # License: BSD (3-clause)
 
@@ -73,13 +74,14 @@ class RawBrainVision(BaseRaw):
     See Also
     --------
     mne.io.Raw : Documentation of attribute and methods.
+
     """
 
     @verbose
     def __init__(self, vhdr_fname, montage=None,
                  eog=('HEOGL', 'HEOGR', 'VEOGb'), misc='auto',
                  scale=1., preload=False, response_trig_shift=0,
-                 event_id=None, verbose=None):  # noqa: D102
+                 event_id=None, verbose=None):  # noqa: D107
         # Channel info and events
         logger.info('Extracting parameters from %s...' % vhdr_fname)
         vhdr_fname = os.path.abspath(vhdr_fname)
@@ -140,6 +142,7 @@ def _get_brainvision_events(self):
         events : array, shape (n_events, 3)
             Events, each row consisting of an (onset, duration, trigger)
             sequence.
+
         """
         return self._events.copy()
 
@@ -151,6 +154,7 @@ def _set_brainvision_events(self, events):
         events : array, shape (n_events, 3)
             Events, each row consisting of an (onset, duration, trigger)
             sequence.
+
         """
         self._create_event_ch(events)
 
@@ -210,6 +214,7 @@ def _read_vmrk_events(fname, event_id=None, response_trig_shift=0):
     events : array, shape (n_events, 3)
         An array containing the whole recording's events, each row representing
         an event as (onset, duration, trigger) sequence.
+
     """
     if event_id is None:
         event_id = dict()
@@ -371,6 +376,7 @@ def _get_vhdr_info(vhdr_fname, eog, misc, scale, montage):
         A dict containing Brain Vision specific parameters.
     events : array, shape (n_events, 3)
         Events from the corresponding vmrk file.
+
     """
     scale = float(scale)
     ext = os.path.splitext(vhdr_fname)[-1]
@@ -566,10 +572,15 @@ def _get_vhdr_info(vhdr_fname, eog, misc, scale, montage):
         else:
             shift = 0
 
-        # extract filter units and convert s to Hz if necessary
+        # Extract filter units and convert from seconds to Hz if necessary.
         # this cannot be done as post-processing as the inverse t-f
         # relationship means that the min/max comparisons don't make sense
-        # unless we know the units
+        # unless we know the units.
+        #
+        # For reasoning about the s to Hz conversion, see this reference:
+        # `Ebersole, J. S., & Pedley, T. A. (Eds.). (2003).
+        # Current practice of clinical electroencephalography.
+        # Lippincott Williams & Wilkins.`, page 40-41
         header = re.split(r'\s\s+', settings[idx])
         hp_s = '[s]' in header[hp_col]
         lp_s = '[s]' in header[lp_col]
@@ -595,7 +606,9 @@ def _get_vhdr_info(vhdr_fname, eog, misc, scale, montage):
             else:
                 info['highpass'] = float(highpass[0])
                 if hp_s:
-                    info['highpass'] = 1. / info['highpass']
+                    # filter time constant t [secs] to Hz conversion: 1/2*pi*t
+                    info['highpass'] = 1. / (2 * np.pi * info['highpass'])
+
         else:
             heterogeneous_hp_filter = True
             if hp_s:
@@ -606,7 +619,8 @@ def _get_vhdr_info(vhdr_fname, eog, misc, scale, montage):
                 info['highpass'] = np.max(np.array(highpass, dtype=np.float))
                 # Coveniently enough 1 / np.Inf = 0.0, so this works for
                 # DC / no highpass filter
-                info['highpass'] = 1. / info['highpass']
+                # filter time constant t [secs] to Hz conversion: 1/2*pi*t
+                info['highpass'] = 1. / (2 * np.pi * info['highpass'])
 
                 # not exactly the cleanest use of FP, but this makes us
                 # more conservative in *not* warning.
@@ -636,7 +650,9 @@ def _get_vhdr_info(vhdr_fname, eog, misc, scale, montage):
             else:
                 info['lowpass'] = float(lowpass[0])
                 if lp_s:
-                    info['lowpass'] = 1. / info['lowpass']
+                    # filter time constant t [secs] to Hz conversion: 1/2*pi*t
+                    info['lowpass'] = 1. / (2 * np.pi * info['lowpass'])
+
         else:
             heterogeneous_lp_filter = True
             if lp_s:
@@ -646,7 +662,9 @@ def _get_vhdr_info(vhdr_fname, eog, misc, scale, montage):
                            else 0.0 for filt in lowpass]
                 info['lowpass'] = np.min(np.array(lowpass, dtype=np.float))
                 try:
-                    info['lowpass'] = 1. / info['lowpass']
+                    # filter time constant t [secs] to Hz conversion: 1/2*pi*t
+                    info['lowpass'] = 1. / (2 * np.pi * info['lowpass'])
+
                 except ZeroDivisionError:
                     if len(set(lowpass)) == 1:
                         # No lowpass actually set for the weakest setting
@@ -778,6 +796,7 @@ def read_raw_brainvision(vhdr_fname, montage=None,
     See Also
     --------
     mne.io.Raw : Documentation of attribute and methods.
+
     """
     return RawBrainVision(vhdr_fname=vhdr_fname, montage=montage, eog=eog,
                           misc=misc, scale=scale, preload=preload,


Test patch:
diff --git a/mne/io/brainvision/tests/test_brainvision.py b/mne/io/brainvision/tests/test_brainvision.py
--- a/mne/io/brainvision/tests/test_brainvision.py
+++ b/mne/io/brainvision/tests/test_brainvision.py
@@ -1,4 +1,4 @@
-"""Data Equivalence Tests"""
+"""Data Equivalence Tests."""
 from __future__ import print_function
 
 # Author: Teon Brooks <teon.brooks@gmail.com>
@@ -137,7 +137,7 @@ def test_brainvision_data_highpass_filters():
             montage=montage, eog=eog)
     assert_true(all('parse triggers that' in str(ww.message) for ww in w))
 
-    assert_equal(raw.info['highpass'], 0.1)
+    assert_equal(raw.info['highpass'], 1. / (2 * np.pi * 10))
     assert_equal(raw.info['lowpass'], 250.)
 
     # Heterogeneous highpass in seconds (default measurement unit)
@@ -155,7 +155,7 @@ def test_brainvision_data_highpass_filters():
 
     assert_true(all(any([lp, hp]) for lp, hp in expected_warnings))
 
-    assert_equal(raw.info['highpass'], 0.1)
+    assert_equal(raw.info['highpass'], 1. / (2 * np.pi * 10))
     assert_equal(raw.info['lowpass'], 250.)
 
     # Homogeneous highpass in Hertz
@@ -190,13 +190,12 @@ def test_brainvision_data_highpass_filters():
 
 def test_brainvision_data_lowpass_filters():
     """Test files with amplifier LP filter settings."""
-
     # Homogeneous lowpass in Hertz (default measurement unit)
     raw = _test_raw_reader(
         read_raw_brainvision, vhdr_fname=vhdr_lowpass_path,
         montage=montage, eog=eog, event_id=event_id)
 
-    assert_equal(raw.info['highpass'], 0.1)
+    assert_equal(raw.info['highpass'], 1. / (2 * np.pi * 10))
     assert_equal(raw.info['lowpass'], 250.)
 
     # Heterogeneous lowpass in Hertz (default measurement unit)
@@ -214,7 +213,7 @@ def test_brainvision_data_lowpass_filters():
 
     assert_true(all(any([lp, hp]) for lp, hp in expected_warnings))
 
-    assert_equal(raw.info['highpass'], 0.1)
+    assert_equal(raw.info['highpass'], 1. / (2 * np.pi * 10))
     assert_equal(raw.info['lowpass'], 250.)
 
     # Homogeneous lowpass in seconds
@@ -222,8 +221,8 @@ def test_brainvision_data_lowpass_filters():
         read_raw_brainvision, vhdr_fname=vhdr_lowpass_s_path,
         montage=montage, eog=eog, event_id=event_id)
 
-    assert_equal(raw.info['highpass'], 0.1)
-    assert_equal(raw.info['lowpass'], 250.)
+    assert_equal(raw.info['highpass'], 1. / (2 * np.pi * 10))
+    assert_equal(raw.info['lowpass'], 1. / (2 * np.pi * 0.004))
 
     # Heterogeneous lowpass in seconds
     with warnings.catch_warnings(record=True) as w:  # filter settings
@@ -240,8 +239,8 @@ def test_brainvision_data_lowpass_filters():
 
     assert_true(all(any([lp, hp]) for lp, hp in expected_warnings))
 
-    assert_equal(raw.info['highpass'], 0.1)
-    assert_equal(raw.info['lowpass'], 250.)
+    assert_equal(raw.info['highpass'], 1. / (2 * np.pi * 10))
+    assert_equal(raw.info['lowpass'], 1. / (2 * np.pi * 0.004))
 
 
 def test_brainvision_data_partially_disabled_hw_filters():
@@ -275,7 +274,7 @@ def test_brainvision_data_software_filters_latin1_global_units():
             eog=("VEOGo", "VEOGu", "HEOGli", "HEOGre"), misc=("A2",))
     assert_true(all('software filter detected' in str(ww.message) for ww in w))
 
-    assert_equal(raw.info['highpass'], 1. / 0.9)
+    assert_equal(raw.info['highpass'], 1. / (2 * np.pi * 0.9))
     assert_equal(raw.info['lowpass'], 50.)
 
 
@@ -330,7 +329,6 @@ def test_brainvision_data():
 
 def test_brainvision_vectorized_data():
     """Test reading BrainVision data files with vectorized data."""
-
     with warnings.catch_warnings(record=True):  # software filter settings
         raw = read_raw_brainvision(vhdr_old_path, preload=True)
 
@@ -485,7 +483,7 @@ def test_events():
 
 
 def test_brainvision_with_montage():
-    """Test reading embedded montage information"""
+    """Test reading embedded montage information."""
     raw = read_raw_brainvision(vhdr_v2_path, eog=eog, misc=['ReRef'])
     for i, d in enumerate(raw.info['dig'], 1):
         assert_equal(d['coord_frame'], FIFF.FIFFV_COORD_HEAD)