Repository: mne-tools/mne-python

Issue:
read_raw_edf fails if excluding all the signal channels
#### Describe the bug
When calling read_raw_edf(path, exclude=exclude, preload=False), if exclude contains all channels but stim ones, 
the call fails 

```
  File "/data/loris-mri/bin/mri/python_virtualenvs/loris-mri-python/lib/python3.7/site-packages/mne/io/edf/edf.py", line 433, in _get_info
    edf_info['record_length'][1] / edf_info['record_length'][0]
  File "/data/loris-mri/bin/mri/python_virtualenvs/loris-mri-python/lib/python3.7/site-packages/numpy/core/_methods.py", line 40, in _amax
    return umr_maximum(a, axis, None, out, keepdims, initial, where)
ValueError: zero-size array to reduction operation maximum which has no identity

```
In mne/io/edf/edf.py::_get_info
```
sfreq = np.take(n_samps, not_stim_ch).max() * \
        edf_info['record_length'][1] / edf_info['record_length'][0]

```
np.take(n_samps, not_stim_ch) returns an empty list and .max() is failing.

#### Steps to reproduce
Calling read_raw_edf(path, exclude=exclude, preload=False), with exclude set to all channels but stim ones 

#### Expected results
Avoid the ValueError and report the absence of data/signal channels to the user

#### Actual results
ValueError: zero-size array to reduction operation maximum which has no identity



Discussion:
Hello! 👋 Thanks for opening your first issue here! ❤️ We will try to get back to you soon. 🚴🏽‍♂️

can you share the file?

>

Sure: 
Click on login here: https://demo.loris.ca
Then you can download a sample using this link: https://demo.loris.ca/mri/jiv/get_file.php?file=bids_imports/Face13_BIDSVersion_1.1.0/sub-OTT170/ses-V1/eeg/sub-OTT170_ses-V1_task-faceO_eeg.edf
"User is not authenticated."

can you share it with me privately?

can you before test with edfbrowser if the file is problematic?

https://www.teuniz.net/edfbrowser/

The file is not problematic. The problem occurs when we try to read a file with all channels ignored but stim ones.
You will get the user is not authenticated error if you don't follow the first step: 
- Click on the login button here: https://demo.loris.ca
Once this is done, you can download the file.

Hope this helps!
It is still the case that if we click on the login button on https://demo.loris.ca and then use the sample data link, the browser just says "user is not authenticated."
If the file itself is not the problem, can you post a code sample replicating the problem with one of our sample datasets?
I managed to get a download start but it's super slow

please share the file and a code script to replicate. I already spent >= 20
mins to try to help you.

your help is much appreciated

>

ok I could replicate with:

```
import mne

path = "/Users/alex/Downloads/sub-OTT170_ses-V1_task-faceO_eeg.edf"
exclude = ()
raw = mne.io.read_raw_edf(path, exclude=exclude, preload=False)
exclude = raw.ch_names[:-1]
raw = mne.io.read_raw_edf(path, exclude=exclude, preload=False)
```
We do not support loading EDF files without any data channels. We've fixed that during loading in #9283, but apparently we haven't considered this use case.

Resolving patch:
diff --git a/mne/datasets/config.py b/mne/datasets/config.py
--- a/mne/datasets/config.py
+++ b/mne/datasets/config.py
@@ -87,7 +87,7 @@
 # respective repos, and make a new release of the dataset on GitHub. Then
 # update the checksum in the MNE_DATASETS dict below, and change version
 # here:                  ↓↓↓↓↓         ↓↓↓
-RELEASES = dict(testing='0.125', misc='0.23')
+RELEASES = dict(testing='0.127', misc='0.23')
 TESTING_VERSIONED = f'mne-testing-data-{RELEASES["testing"]}'
 MISC_VERSIONED = f'mne-misc-data-{RELEASES["misc"]}'
 
@@ -107,6 +107,25 @@
 # OPTIONAL KEYS:
 # - config_key : key to use with `mne.set_config` to store the on-disk location
 #                of the downloaded dataset (ex: "MNE_DATASETS_EEGBCI_PATH").
+
+# Testing and misc are at the top as they're updated most often
+MNE_DATASETS['testing'] = dict(
+    archive_name=f'{TESTING_VERSIONED}.tar.gz',  # 'mne-testing-data',
+    hash='md5:1e2bb7fcffc7ed26ecbe61f11afbfbc6',
+    url=('https://codeload.github.com/mne-tools/mne-testing-data/'
+         f'tar.gz/{RELEASES["testing"]}'),
+    folder_name='MNE-testing-data',
+    config_key='MNE_DATASETS_TESTING_PATH',
+)
+MNE_DATASETS['misc'] = dict(
+    archive_name=f'{MISC_VERSIONED}.tar.gz',  # 'mne-misc-data',
+    hash='md5:01e409d82ff11ca8b19a27c4f7ee6794',
+    url=('https://codeload.github.com/mne-tools/mne-misc-data/tar.gz/'
+         f'{RELEASES["misc"]}'),
+    folder_name='MNE-misc-data',
+    config_key='MNE_DATASETS_MISC_PATH'
+)
+
 MNE_DATASETS['fnirs_motor'] = dict(
     archive_name='MNE-fNIRS-motor-data.tgz',
     hash='md5:c4935d19ddab35422a69f3326a01fef8',
@@ -304,20 +323,3 @@
     folder_name='foo',
     config_key='MNE_DATASETS_FAKE_PATH'
 )
-
-MNE_DATASETS['testing'] = dict(
-    archive_name=f'{TESTING_VERSIONED}.tar.gz',  # 'mne-testing-data',
-    hash='md5:0871ae325805a9cd2cdfb2fc74327abb',
-    url=('https://codeload.github.com/mne-tools/mne-testing-data/'
-         f'tar.gz/{RELEASES["testing"]}'),
-    folder_name='MNE-testing-data',
-    config_key='MNE_DATASETS_TESTING_PATH',
-)
-MNE_DATASETS['misc'] = dict(
-    archive_name=f'{MISC_VERSIONED}.tar.gz',  # 'mne-misc-data',
-    hash='md5:01e409d82ff11ca8b19a27c4f7ee6794',
-    url=('https://codeload.github.com/mne-tools/mne-misc-data/tar.gz/'
-         f'{RELEASES["misc"]}'),
-    folder_name='MNE-misc-data',
-    config_key='MNE_DATASETS_MISC_PATH'
-)
diff --git a/mne/io/edf/edf.py b/mne/io/edf/edf.py
--- a/mne/io/edf/edf.py
+++ b/mne/io/edf/edf.py
@@ -368,7 +368,10 @@ def _get_info(fname, stim_channel, eog, misc, exclude, preload):
         ch_types = edf_info['ch_types']  # of length len(sel)
     else:
         ch_types = [None] * len(sel)
-    n_samps = edf_info['n_samps'][sel]
+    if len(sel) == 0:  # only want stim channels
+        n_samps = edf_info['n_samps'][[0]]
+    else:
+        n_samps = edf_info['n_samps'][sel]
     nchan = edf_info['nchan']
     physical_ranges = edf_info['physical_max'] - edf_info['physical_min']
     cals = edf_info['digital_max'] - edf_info['digital_min']
@@ -456,7 +459,6 @@ def _get_info(fname, stim_channel, eog, misc, exclude, preload):
         logger.info(msg)
 
     edf_info['stim_channel_idxs'] = stim_channel_idxs
-
     if any(pick_mask):
         picks = [item for item, mask in zip(range(nchan), pick_mask) if mask]
         edf_info['max_samp'] = max_samp = n_samps[picks].max()
@@ -468,8 +470,11 @@ def _get_info(fname, stim_channel, eog, misc, exclude, preload):
 
     not_stim_ch = [x for x in range(n_samps.shape[0])
                    if x not in stim_channel_idxs]
+    if len(not_stim_ch) == 0:  # only loading stim channels
+        not_stim_ch = list(range(len(n_samps)))
     sfreq = np.take(n_samps, not_stim_ch).max() * \
         edf_info['record_length'][1] / edf_info['record_length'][0]
+    del n_samps
     info = _empty_info(sfreq)
     info['meas_date'] = edf_info['meas_date']
     info['chs'] = chs
@@ -634,7 +639,7 @@ def _read_edf_header(fname, exclude):
         record_length = float(_edf_str(fid.read(8)))
         record_length = np.array([record_length, 1.])  # in seconds
         if record_length[0] == 0:
-            record_length = record_length[0] = 1.
+            record_length[0] = 1.
             warn('Header information is incorrect for record length. Default '
                  'record length set to 1.\nIt is possible that this file only'
                  ' contains annotations and no signals. In that case, please '


Test patch:
diff --git a/mne/io/edf/tests/test_edf.py b/mne/io/edf/tests/test_edf.py
--- a/mne/io/edf/tests/test_edf.py
+++ b/mne/io/edf/tests/test_edf.py
@@ -21,6 +21,7 @@
 import pytest
 
 from mne import pick_types, Annotations
+from mne.annotations import events_from_annotations, read_annotations
 from mne.datasets import testing
 from mne.utils import requires_pandas
 from mne.io import read_raw_edf, read_raw_bdf, read_raw_fif, edf, read_raw_gdf
@@ -29,7 +30,7 @@
                             _read_ch, _parse_prefilter_string, _edf_str,
                             _read_edf_header, _read_header)
 from mne.io.pick import channel_indices_by_type, get_channel_type_constants
-from mne.annotations import events_from_annotations, read_annotations
+from mne.tests.test_annotations import _assert_annotations_equal
 
 td_mark = testing._pytest_mark()
 
@@ -52,6 +53,7 @@
 edf_overlap_annot_path = op.join(data_path, 'EDF',
                                  'test_edf_overlapping_annotations.edf')
 edf_reduced = op.join(data_path, 'EDF', 'test_reduced.edf')
+edf_annot_only = op.join(data_path, 'EDF', 'SC4001EC-Hypnogram.edf')
 bdf_stim_channel_path = op.join(data_path, 'BDF', 'test_bdf_stim_channel.bdf')
 bdf_multiple_annotations_path = op.join(data_path, 'BDF',
                                         'multiple_annotation_chans.bdf')
@@ -236,6 +238,31 @@ def test_find_events_backward_compatibility():
     assert_array_equal(events_from_EFA, EXPECTED_EVENTS)
 
 
+@testing.requires_testing_data
+def test_no_data_channels():
+    """Test that we can load with no data channels."""
+    # analog
+    raw = read_raw_edf(edf_path, preload=True)
+    picks = pick_types(raw.info, stim=True)
+    assert list(picks) == [len(raw.ch_names) - 1]
+    stim_data = raw[picks][0]
+    raw = read_raw_edf(edf_path, exclude=raw.ch_names[:-1])
+    stim_data_2 = raw[0][0]
+    assert_array_equal(stim_data, stim_data_2)
+    raw.plot()  # smoke test
+    # annotations
+    raw = read_raw_edf(edf_overlap_annot_path)
+    picks = pick_types(raw.info, stim=True)
+    assert picks.size == 0
+    annot = raw.annotations
+    raw = read_raw_edf(edf_overlap_annot_path, exclude=raw.ch_names)
+    annot_2 = raw.annotations
+    _assert_annotations_equal(annot, annot_2)
+    # only annotations (should warn)
+    with pytest.warns(RuntimeWarning, match='read_annotations'):
+        read_raw_edf(edf_annot_only)
+
+
 @requires_pandas
 @pytest.mark.parametrize('fname', [edf_path, bdf_path])
 def test_to_data_frame(fname):
@@ -267,13 +294,6 @@ def test_read_raw_edf_stim_channel_input_parameters():
             read_raw_edf(edf_path, stim_channel=invalid_stim_parameter)
 
 
-def _assert_annotations_equal(a, b):
-    assert_array_equal(a.onset, b.onset)
-    assert_array_equal(a.duration, b.duration)
-    assert_array_equal(a.description, b.description)
-    assert a.orig_time == b.orig_time
-
-
 def test_read_annot(tmpdir):
     """Test parsing the tal channel."""
     EXPECTED_ANNOTATIONS = [[180.0, 0, 'Lights off'], [180.0, 0, 'Close door'],
diff --git a/mne/tests/test_annotations.py b/mne/tests/test_annotations.py
--- a/mne/tests/test_annotations.py
+++ b/mne/tests/test_annotations.py
@@ -36,6 +36,10 @@
 
 first_samps = pytest.mark.parametrize('first_samp', (0, 10000))
 
+data_path = testing.data_path(download=False)
+edf_reduced = op.join(data_path, 'EDF', 'test_reduced.edf')
+edf_annot_only = op.join(data_path, 'EDF', 'SC4001EC-Hypnogram.edf')
+
 
 needs_pandas = pytest.mark.skipif(
     not check_version('pandas'), reason='Needs pandas')
@@ -349,6 +353,17 @@ def test_read_brainstorm_annotations():
     assert np.unique(annot.description).size == 5
 
 
+@testing.requires_testing_data
+@pytest.mark.parametrize('fname, n_annot', [
+    (edf_annot_only, 154),
+    (edf_reduced, 5),
+])
+def test_read_edf_annotations(fname, n_annot):
+    """Test reading EDF annotations."""
+    annot = read_annotations(fname)
+    assert len(annot) == n_annot
+
+
 @first_samps
 def test_raw_reject(first_samp):
     """Test raw data getter with annotation reject."""