Repository: mne-tools/mne-python

Issue:
When loading EDF+ with different sampling frequencies, don't resample blockwise
#### Describe the new feature or enhancement

It is possible to load EDF+ files with different sampling frequencies. To my understanding, internally, the channels with differing sampling frequencies are resampled. I understand that the underlying data format `BaseRAW` assumes that all channels have the same sampling frequency, so resampling the signals to a common denominator is strictly necessary.

#### Describe your proposed implementation

However, this resampling is happening per block, i.e. every 10 MB of data, and therefore introduces edge artefacts (as also noted in the comments). These are mostly minor, however, in some of my calculations they do indeed change the results of our analysis.

https://github.com/mne-tools/mne-python/blob/549de120926465a514ac5d7b0b8950f49ec2ade5/mne/io/edf/edf.py#L330=

#### Describe possible alternatives

I think it would be good to load all channels into different data buffers, and only resample them after loading the entire file. However, maybe there was a reasoning why the resampling takes place on a per-block basis (e.g. efficiency?), so please let me know if it would be worth implementing such a change, or if I could give it a try to refactor the function to resample the data after loading everything.



Discussion:
oh waouh.... to me resampling by buffer is really a bad idea. I have not
realized it was done like that.

I don't we can address this issue very rapidly. Can you go around the pb
with your analysis eg by exclude
the channels with a higher sampling rate?

> Message ID: ***@***.***>
>

We should definitely resample the whole signal to avoid edge artifacts. Maybe the blockwise implementation is used to support on-demand reading of specific data segments?
> oh waouh.... to me resampling by buffer is really a bad idea. I have not realized it was done like that. I don't we can address this issue very rapidly. Can you go around the pb with your analysis eg by exclude the channels with a higher sampling rate?

Unfortunately this is not possible in my case, as by accident `Pz` and `M1/M2` were recorded with different sampling frequencies. However, I can resort to loading everything with `pyedflib` and putting it manually into a `RawArray`


btw: what do you abbreviate with pb? never seen it before and google doesnt help me
Workaround:

```Python
from pyedflib import highlevel
import numpy as np
import mne


def resample(data, o_sfreq, t_sfreq):
    """
    resample a signal using MNE resample functions
    This automatically is optimized for EEG applying filters etc
    
    :param raw:     a 1D data array
    :param o_sfreq: the original sampling frequency
    :param t_sfreq: the target sampling frequency
    :returns: the resampled signal
    """
    if o_sfreq==t_sfreq: return data
    raw = np.atleast_2d(data)
    ch_names=['ch{}'.format(i) for i in range(len(raw))]
    info = mne.create_info(ch_names=ch_names, sfreq=o_sfreq, ch_types=['eeg'])
    raw_mne = mne.io.RawArray(raw, info, verbose='WARNING')
    resampled = raw_mne.resample(t_sfreq, n_jobs=-1, verbose='WARNING')
    new_raw = resampled.get_data().squeeze()
    return new_raw.astype(raw.dtype, copy=False)

def load_edf(edf_file, channels):
    """
    Parameters
    ----------
    edf_file : str
        edf file name.
    channels : list of str
        list of channel names to load.

    Returns
    -------
    raw : TYPE
        DESCRIPTION.
    convenience function to load an EDF+ without MNE.
    Workaround for https://github.com/mne-tools/mne-python/issues/10635"""
    
    if not isinstance(channels, list):
        channels = [channels]
        
    sigs, sigheads, header = highlevel.read_edf(edf_file, 
                                               ch_names=channels)
   
    sfreqs = [s['sample_rate'] for s in sigheads]
    labels = [s['label'] for s in sigheads]
    
    if len(set(sfreqs))>1:
        sigs = [resample(x, sfreqs[i], min(sfreqs)) for i, x in enumerate(sigs)]

    info = mne.create_info(labels, min(sfreqs), ch_types='eeg')

    raw = mne.io.RawArray(sigs, info)
    
    return raw
```
@skjerns I think it would be great if you added this code snippet to our docs. Maybe a short note about the problem and your proposed workaround would fit [here](https://mne.tools/stable/auto_tutorials/io/20_reading_eeg_data.html#european-data-format-edf)?
Oh, and pb = problem.
I can do that. 
@skjerns would you still be up to implement a proper solution, i.e. resampling of the entire signal? The current block-wise resampling is highly problematic to the point where I'd rather not do it at all (and let users only load channel groups with the same sampling frequency to avoid block-wise resampling altogether).
I can probably have a look at it after some deadlines have past (~end the end of february) 
@cbrnr I'm struggeling a bit to implement it in a proper way.

Currently I have a working implementation that loads the data and upsamples to the highest sampling frequency in the file of all requested channels by loading all data into lists and upsampling once in the end. `_mult_cal_one` is called all in the end after all data has been upsampled. However, this process also resamples when accessing only a single channel when not preloading e.g.

```Python
raw = mne.io.read_raw_edf('diff-sfreq.edf') # file containing 10, 20 and 30 Hz, no preloading
data = raw.get_data(f'10hz}') # result will be upsampled to 30 Hz

raw = mne.io.read_raw_edf('diff-sfreq.edf', exclude=['30hz']) # file containing 10, 20 and 30 Hz, no preloading
data = raw.get_data(f'10hz}') # result will be upsampled to 20 Hz
```

The problem is that `data` is given to the `_read_segment_file` as a view and is already expecting 30 Hz, there is not much I can do about that without changing `BaseRaw`, as far as I can see?

How would you say to proceed?

Solutions:
- only allow working with preloaded data.
- simply accept that lower sampling frequencies are always upsampled unless channels are added to `excluded` or `included`?  

Besides that it seems to work, just some tests throw errors for small deviations in values when loaded on the fly or the entire signal at once when compared to loading the data from smaller segments. I need to look into how to fix that (i.e. `test_edf_others`). It might be that the "new" values are correct, and the old ones are wrong due to edge artifacts. Any idea how to address this if it happens, without tempering with other files in the repository too much (the test is defined in `base/tests`)?
>
> @cbrnr <https://github.com/cbrnr> I'm struggeling a bit to implement it
> in a proper way.
>
> Currently I have a working implementation that loads the data and
> upsamples to the highest sampling frequency in the file. However, this also
> resamples when accessing only a single channel e.g.
>
> raw = mne.io.read_raw_edf('diff-sfreq.edf') # file containing 10, 20 and 30 Hzdata = raw.get_data(f'10hz}') # result will be upsampled to 30 Hz
>
> yes it's the expected behavior.

you avoid this you need to pick the channels you want in the init. Once you
created the raw instance you have defined the info["sfreq"] which is not
channel specific.

> Message ID: ***@***.***>
>

> @cbrnr <https://github.com/cbrnr> I'm struggeling a bit to implement it in a proper way. Currently I have a working implementation that loads the data and upsamples to the highest sampling frequency in the file. However, this also resamples when accessing only a single channel e.g. raw = mne.io.read_raw_edf('diff-sfreq.edf') # file containing 10, 20 and 30 Hzdata = raw.get_data(f'10hz}') # result will be upsampled to 30 Hz yes it's the expected behavior.
> you avoid this you need to pick the channels you want in the init. Once you created the raw instance you have defined the info["sfreq"] which is not channel specific.

ok, thanks!

Now the only problem remaining is one tests fails when `preload=False` and specific timeframes are accessed. I presume this is due to edge artifacts, as it only happens for channels which were resampled:

this test:
https://github.com/mne-tools/mne-python/blob/bf2502166eb15626c1205accc2d2d467535b8d93/mne/io/edf/tests/test_edf.py#L177-L182

which calls this test in `io/tests/test_raw.py`
https://github.com/mne-tools/mne-python/blob/bf2502166eb15626c1205accc2d2d467535b8d93/mne/io/tests/test_raw.py#L120-L125

Plotting differences between the loaded signals reveals A9 having clear edge artefacts, as it is indeed the only resampled channel
![test_reduced edf-1](https://user-images.githubusercontent.com/14980558/224567932-d2469bc8-dea5-41bb-b2ba-40c7956c7283.png)

However, some other time indices look weird and not like clear edge artifacts:

![test_reduced edf-5](https://user-images.githubusercontent.com/14980558/224567998-21c04bf7-3f0c-4085-b737-c6bb422e404e.png)
![test_reduced edf-7](https://user-images.githubusercontent.com/14980558/224567999-34fd0c30-b461-428e-bf8a-dca2769c15e9.png)


Any idea what to do with this? Simply remove the test? 


@skjerns where can I see your code? Did I miss the pull request?

Resolving patch:
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
@@ -96,6 +96,8 @@ class RawEDF(BaseRaw):
 
     Notes
     -----
+    %(edf_resamp_note)s
+
     Biosemi devices trigger codes are encoded in 16-bit format, whereas system
     codes (CMS in/out-of range, battery low, etc.) are coded in bits 16-23 of
     the status channel (see http://www.biosemi.com/faq/trigger_signals.htm).
@@ -382,6 +384,15 @@ def _read_segment_file(data, idx, fi, start, stop, raw_extras, filenames, cals,
     with open(filenames, "rb", buffering=0) as fid:
         # Extract data
         start_offset = data_offset + block_start_idx * ch_offsets[-1] * dtype_byte
+
+        # first read everything into the `ones` array. For channels with
+        # lower sampling frequency, there will be zeros left at the end of the
+        # row. Ignore TAL/annotations channel and only store `orig_sel`
+        ones = np.zeros((len(orig_sel), data.shape[-1]), dtype=data.dtype)
+        # save how many samples have already been read per channel
+        n_smp_read = [0 for _ in range(len(orig_sel))]
+
+        # read data in chunks
         for ai in range(0, len(r_lims), n_per):
             block_offset = ai * ch_offsets[-1] * dtype_byte
             n_read = min(len(r_lims) - ai, n_per)
@@ -392,13 +403,13 @@ def _read_segment_file(data, idx, fi, start, stop, raw_extras, filenames, cals,
             ).reshape(n_read, -1)
             r_sidx = r_lims[ai][0]
             r_eidx = buf_len * (n_read - 1) + r_lims[ai + n_read - 1][1]
-            d_sidx = d_lims[ai][0]
-            d_eidx = d_lims[ai + n_read - 1][1]
-            one = np.zeros((len(orig_sel), d_eidx - d_sidx), dtype=data.dtype)
+
+            # loop over selected channels, ci=channel selection
             for ii, ci in enumerate(read_sel):
                 # This now has size (n_chunks_read, n_samp[ci])
                 ch_data = many_chunk[:, ch_offsets[ci] : ch_offsets[ci + 1]].copy()
 
+                # annotation channel has to be treated separately
                 if ci in tal_idx:
                     tal_data.append(ch_data)
                     continue
@@ -417,22 +428,50 @@ def _read_segment_file(data, idx, fi, start, stop, raw_extras, filenames, cals,
                         new = np.linspace(0, 1, buf_len, False)
                         ch_data = np.append(ch_data, np.zeros((len(ch_data), 1)), -1)
                         ch_data = interp1d(old, ch_data, kind="zero", axis=-1)(new)
-                    else:
-                        # XXX resampling each chunk isn't great,
-                        # it forces edge artifacts to appear at
-                        # each buffer boundary :(
-                        # it can also be very slow...
-                        ch_data = resample(
-                            ch_data.astype(np.float64),
-                            buf_len,
-                            n_samps[ci],
-                            npad=0,
-                            axis=-1,
-                        )
                 elif orig_idx in stim_channel_idxs:
                     ch_data = np.bitwise_and(ch_data.astype(int), 2**17 - 1)
-                one[orig_idx] = ch_data.ravel()[r_sidx:r_eidx]
-            _mult_cal_one(data[:, d_sidx:d_eidx], one, idx, cals, mult)
+
+                one_i = ch_data.ravel()[r_sidx:r_eidx]
+
+                # note how many samples have been read
+                smp_read = n_smp_read[orig_idx]
+                ones[orig_idx, smp_read : smp_read + len(one_i)] = one_i
+                n_smp_read[orig_idx] += len(one_i)
+
+        # skip if no data was requested, ie. only annotations were read
+        if sum(n_smp_read) > 0:
+            # expected number of samples, equals maximum sfreq
+            smp_exp = data.shape[-1]
+            assert max(n_smp_read) == smp_exp
+
+            # resample data after loading all chunks to prevent edge artifacts
+            resampled = False
+            for i, smp_read in enumerate(n_smp_read):
+                # nothing read, nothing to resample
+                if smp_read == 0:
+                    continue
+                # upsample if n_samples is lower than from highest sfreq
+                if smp_read != smp_exp:
+                    assert (ones[i, smp_read:] == 0).all()  # sanity check
+                    ones[i, :] = resample(
+                        ones[i, :smp_read].astype(np.float64),
+                        smp_exp,
+                        smp_read,
+                        npad=0,
+                        axis=-1,
+                    )
+                    resampled = True
+
+            # give warning if we resampled a subselection
+            if resampled and raw_extras["nsamples"] != (stop - start):
+                warn(
+                    "Loading an EDF with mixed sampling frequencies and \
+                     preload=False will result in edge artifacts. \
+                     It is recommended to use preload=True preload=Buffer. \
+                     See also https://github.com/mne-tools/mne-python/issues/10635"
+                )
+
+            _mult_cal_one(data[:, :], ones, idx, cals, mult)
 
     if len(tal_data) > 1:
         tal_data = np.concatenate([tal.ravel() for tal in tal_data])
@@ -1541,6 +1580,8 @@ def read_raw_edf(
 
     Notes
     -----
+    %(edf_resamp_note)s
+
     It is worth noting that in some special cases, it may be necessary to shift
     event values in order to retrieve correct event triggers. This depends on
     the triggering device used to perform the synchronization. For instance, in
@@ -1672,6 +1713,12 @@ def read_raw_bdf(
 
     Notes
     -----
+    :class:`mne.io.Raw` only stores signals with matching sampling frequencies.
+    Therefore, if mixed sampling frequency signals are requested, all signals
+    are upsampled to the highest loaded sampling frequency. In this case, using
+    preload=True is recommended, as otherwise, edge artifacts appear when
+    slices of the signal are requested.
+
     Biosemi devices trigger codes are encoded in 16-bit format, whereas system
     codes (CMS in/out-of range, battery low, etc.) are coded in bits 16-23 of
     the status channel (see http://www.biosemi.com/faq/trigger_signals.htm).
diff --git a/mne/io/utils.py b/mne/io/utils.py
--- a/mne/io/utils.py
+++ b/mne/io/utils.py
@@ -84,7 +84,7 @@ def _mult_cal_one(data_view, one, idx, cals, mult):
         one.shape[1],
     )  # noqa: E501
     if mult is not None:
-        mult.ndim == one.ndim == 2
+        assert mult.ndim == one.ndim == 2
         data_view[:] = mult @ one[idx]
     else:
         assert cals is not None
diff --git a/mne/utils/docs.py b/mne/utils/docs.py
--- a/mne/utils/docs.py
+++ b/mne/utils/docs.py
@@ -1104,6 +1104,16 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
     If True (default), show ECoG sensors.
 """
 
+docdict[
+    "edf_resamp_note"
+] = """
+:class:`mne.io.Raw` only stores signals with matching sampling frequencies.
+Therefore, if mixed sampling frequency signals are requested, all signals
+are upsampled to the highest loaded sampling frequency. In this case, using
+preload=True is recommended, as otherwise, edge artifacts appear when
+slices of the signal are requested.
+"""
+
 docdict[
     "eeg"
 ] = """


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
@@ -192,8 +192,53 @@ def test_bdf_crop_save_stim_channel(tmp_path):
 def test_edf_others(fname, stim_channel):
     """Test EDF with various sampling rates and overlapping annotations."""
     _test_raw_reader(
-        read_raw_edf, input_fname=fname, stim_channel=stim_channel, verbose="error"
+        read_raw_edf,
+        input_fname=fname,
+        stim_channel=stim_channel,
+        verbose="error",
+        test_preloading=False,
+        preload=True,  # no preload=False for mixed sfreqs
+    )
+
+
+@testing.requires_testing_data
+@pytest.mark.parametrize("stim_channel", (None, False, "auto"))
+def test_edf_different_sfreqs(stim_channel):
+    """Test EDF with various sampling rates."""
+    rng = np.random.RandomState(0)
+    # load with and without preloading, should produce the same results
+    raw1 = read_raw_edf(
+        input_fname=edf_reduced,
+        stim_channel=stim_channel,
+        verbose="error",
+        preload=False,
     )
+    raw2 = read_raw_edf(
+        input_fname=edf_reduced,
+        stim_channel=stim_channel,
+        verbose="error",
+        preload=True,
+    )
+
+    picks = rng.permutation(np.arange(len(raw1.ch_names) - 1))[:10]
+    data1, times1 = raw1[picks, :]
+    data2, times2 = raw2[picks, :]
+    assert_allclose(data1, data2, err_msg="Data mismatch with preload")
+    assert_allclose(times1, times2)
+
+    # loading slices should throw a warning as they have different
+    # edge artifacts than when loading the entire file at once
+    with pytest.warns(RuntimeWarning, match="mixed sampling frequencies"):
+        data1, times1 = raw1[picks, :512]
+    data2, times2 = raw2[picks, :512]
+
+    # should NOT throw a warning when loading channels that have all the same
+    # sampling frequency - here, no edge artifacts can appear
+    picks = np.arange(15, 20)  # these channels all have 512 Hz
+    data1, times1 = raw1[picks, :512]
+    data2, times2 = raw2[picks, :512]
+    assert_allclose(data1, data2, err_msg="Data mismatch with preload")
+    assert_allclose(times1, times2)
 
 
 def test_edf_data_broken(tmp_path):