Skip to content

Instantly share code, notes, and snippets.

@skjerns
Last active April 28, 2023 02:17
  • Star 19 You must be signed in to star a gist
  • Fork 10 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save skjerns/bc660ef59dca0dbd53f00ed38c42f6be to your computer and use it in GitHub Desktop.
Save a mne.io.Raw object to EDF/EDF+/BDF/BDF+
# -*- coding: utf-8 -*-
"""
Created on Wed Dec 5 12:56:31 2018
@author: skjerns
Gist to save a mne.io.Raw object to an EDF file using pyEDFlib
(https://github.com/holgern/pyedflib)
Disclaimer:
- Saving your data this way will result in slight
loss of precision (magnitude +-1e-09).
- It is assumed that the data is presented in Volt (V),
it will be internally converted to microvolt
- BDF or EDF+ is selected based on the filename extension
- Annotations preserved
"""
import pyedflib # pip install pyedflib
from pyedflib import highlevel # new high-level interface
from pyedflib import FILETYPE_BDF, FILETYPE_BDFPLUS, FILETYPE_EDF, FILETYPE_EDFPLUS
from datetime import datetime, timezone, timedelta
import mne
import os
def _stamp_to_dt(utc_stamp):
"""Convert timestamp to datetime object in Windows-friendly way."""
if 'datetime' in str(type(utc_stamp)): return utc_stamp
# The min on windows is 86400
stamp = [int(s) for s in utc_stamp]
if len(stamp) == 1: # In case there is no microseconds information
stamp.append(0)
return (datetime.fromtimestamp(0, tz=timezone.utc) +
timedelta(0, stamp[0], stamp[1])) # day, sec, μs
def write_mne_edf(mne_raw, fname, picks=None, tmin=0, tmax=None,
overwrite=False):
"""
Saves the raw content of an MNE.io.Raw and its subclasses to
a file using the EDF+/BDF filetype
pyEDFlib is used to save the raw contents of the RawArray to disk
Parameters
----------
mne_raw : mne.io.Raw
An object with super class mne.io.Raw that contains the data
to save
fname : string
File name of the new dataset. This has to be a new filename
unless data have been preloaded. Filenames should end with .edf
picks : array-like of int | None
Indices of channels to include. If None all channels are kept.
tmin : float | None
Time in seconds of first sample to save. If None first sample
is used.
tmax : float | None
Time in seconds of last sample to save. If None last sample
is used.
overwrite : bool
If True, the destination file (if it exists) will be overwritten.
If False (default), an error will be raised if the file exists.
"""
if not issubclass(type(mne_raw), mne.io.BaseRaw):
raise TypeError('Must be mne.io.Raw type')
if not overwrite and os.path.exists(fname):
raise OSError('File already exists. No overwrite.')
# static settings
has_annotations = True if len(mne_raw.annotations)>0 else False
if os.path.splitext(fname)[-1] == '.edf':
file_type = FILETYPE_EDFPLUS if has_annotations else FILETYPE_EDF
dmin, dmax = -32768, 32767
else:
file_type = FILETYPE_BDFPLUS if has_annotations else FILETYPE_BDF
dmin, dmax = -8388608, 8388607
print('saving to {}, filetype {}'.format(fname, file_type))
sfreq = mne_raw.info['sfreq']
date = _stamp_to_dt(mne_raw.info['meas_date'])
if tmin:
date += timedelta(seconds=tmin)
# no conversion necessary, as pyedflib can handle datetime.
#date = date.strftime('%d %b %Y %H:%M:%S')
first_sample = int(sfreq*tmin)
last_sample = int(sfreq*tmax) if tmax is not None else None
# convert data
channels = mne_raw.get_data(picks,
start = first_sample,
stop = last_sample)
# convert to microvolts to scale up precision
channels *= 1e6
# set conversion parameters
n_channels = len(channels)
# create channel from this
try:
f = pyedflib.EdfWriter(fname,
n_channels=n_channels,
file_type=file_type)
channel_info = []
ch_idx = range(n_channels) if picks is None else picks
keys = list(mne_raw._orig_units.keys())
for i in ch_idx:
try:
ch_dict = {'label': mne_raw.ch_names[i],
'dimension': mne_raw._orig_units[keys[i]],
'sample_rate': mne_raw._raw_extras[0]['n_samps'][i],
'physical_min': mne_raw._raw_extras[0]['physical_min'][i],
'physical_max': mne_raw._raw_extras[0]['physical_max'][i],
'digital_min': mne_raw._raw_extras[0]['digital_min'][i],
'digital_max': mne_raw._raw_extras[0]['digital_max'][i],
'transducer': '',
'prefilter': ''}
except:
ch_dict = {'label': mne_raw.ch_names[i],
'dimension': mne_raw._orig_units[keys[i]],
'sample_rate': sfreq,
'physical_min': channels.min(),
'physical_max': channels.max(),
'digital_min': dmin,
'digital_max': dmax,
'transducer': '',
'prefilter': ''}
channel_info.append(ch_dict)
f.setPatientCode(mne_raw._raw_extras[0]['subject_info'].get('id', '0'))
f.setPatientName(mne_raw._raw_extras[0]['subject_info'].get('name', 'noname'))
f.setTechnician('mne-gist-save-edf-skjerns')
f.setSignalHeaders(channel_info)
f.setStartdatetime(date)
f.writeSamples(channels)
for annotation in mne_raw.annotations:
onset = annotation['onset']
duration = annotation['duration']
description = annotation['description']
f.writeAnnotation(onset, duration, description)
except Exception as e:
raise e
finally:
f.close()
return True
@LudovicGardy
Copy link

LudovicGardy commented Nov 3, 2020

Hello, and thank you for your great job!

I have a little problem because when I convert a file using your method, it goes from 1h duration to 8h duration (probably related to the sample rate, which is transformed from 2048 to 256Hz somewhere in the process). However, I change absolutely nothing, I just load my .edf data (duration = 1h), use write_edf, load the new .edf data (duration = 8h).

Edit : I solved the problem by changing the following code. Basically I just changed the 'sample_rate' parameter into the ch_dict.

        try:
            ch_dict = {'label': mne_raw.ch_names[i], 
                       'dimension': mne_raw._orig_units[keys[i]], 
                       #'sample_rate': mne_raw._raw_extras[0]['n_samps'][i], 
                       'sample_rate': mne_raw.info["sfreq"],
                       'physical_min': mne_raw._raw_extras[0]['physical_min'][i], 
                       'physical_max': mne_raw._raw_extras[0]['physical_max'][i], 
                       'digital_min':  mne_raw._raw_extras[0]['digital_min'][i], 
                       'digital_max':  mne_raw._raw_extras[0]['digital_max'][i], 
                       'transducer': '', 
                       'prefilter': ''}
        except:
            ch_dict = {'label': mne_raw.ch_names[i], 
                       'dimension': mne_raw._orig_units[keys[i]], 
                       #'sample_rate': sfreq, 
                       'sample_rate': mne_raw.info["sfreq"],
                       'physical_min': channels.min(), 
                       'physical_max': channels.max(), 
                       'digital_min':  dmin, 
                       'digital_max':  dmax, 
                       'transducer': '', 
                       'prefilter': ''}

@bonniekcn
Copy link

Hello, thanks a lot for this amazing script. I realize that the resulting file saved from this script will be in the folder which the original data is located. I wonder if there is any way we can change where the output file will be within the script. Thank you.

@LudovicGardy
Copy link

LudovicGardy commented Dec 23, 2020

Hello, thanks a lot for this amazing script. I realize that the resulting file saved from this script will be in the folder which the original data is located. I wonder if there is any way we can change where the output file will be within the script. Thank you.

Hi, you can just set "fname" to the full path you want (folderpath/filename.edf) when you call the write_mne_edf() function.

@const7
Copy link

const7 commented Feb 25, 2021

Hi, thanks for your great work.
I'm trying to fit this code into mff files, but I'm not sure what does channels *= 1e6 do. Can someone give more explanation? Thanks.

@skjerns
Copy link
Author

skjerns commented Feb 25, 2021

It is assumed that the data is passed from MNE is in Volts (see disclaimer of the function, this is default in MNE afaik), even though most values recorded with EEG are in the mV or uV range. However, as EDF+ is only using 16-bit precision, such small values easily lose much precision. Therefore values are converted from Volt to Microvolt, where less precision is lost. The physical dimension is then added to the EDF+ header, so it should load correctly. To be honest, now that I'm re-reading the code I'm struggeling myself to trust that this also works in cases where data is not EEG (e.g. with data in nT like MEG). I haven't used MNE ever since, so I don't really know.

@PARODBE
Copy link

PARODBE commented Jul 3, 2021

Hi,

One question. If you have a numpy array with channels and the information:
image

And Also I have the labels:

image

How I could convert this in edf file, please?

Thanks!

@skjerns
Copy link
Author

skjerns commented Jul 3, 2021

just use pyedflib

see here for use case examples https://github.com/holgern/pyedflib#highlevel-interface

@PARODBE
Copy link

PARODBE commented Jul 5, 2021

Thanks @skjerns , but the labels? How could I introduce them? Thanks again!

@skjerns
Copy link
Author

skjerns commented Jul 5, 2021

you can add them as annotations

signals = np.random.rand(5, 256*300)*200 # 5 minutes of random signal
channel_names = ['ch1', 'ch2', 'ch3', 'ch4', 'ch5']
signal_headers = highlevel.make_signal_headers(channel_names, sample_frequency=256)
header = highlevel.make_header(patientname='patient_x', gender='Female')
annotations = [[0, 0, "Wake"], [30, 0, "S2"]] # format [onset, duration, description], I think time is in seconds or ms,  I don't remember
header['annotations'] = annotations
highlevel.write_edf('edf_file.edf', signals, signal_headers, header)

@PARODBE
Copy link

PARODBE commented Jul 9, 2021

Thanks for your support! The code has worked for me, but I have a problem. My shape is (channels, epochs,51230), If I do a flatten multiplying epochs by 51230, the result is:

image

I can't see anything clear, however these signals had a different view...For you, what is the problem?

Thanks!
Pablo

@skjerns
Copy link
Author

skjerns commented Jul 9, 2021

likely some wrong values for digital min/max and physical min/max in EDF. Else just a display problem in EDFBrowser, try pressing '+' to increase the signals. However, this is not the place to get support for that - it has little to do with this blob.

@paulokanda
Copy link

paulokanda commented Oct 24, 2021

Hi
I'm trying to open an edf in mne, edit it, and save result in a new file using your gist. Unfortunately, when I do:
write_mne_edf(EEG_raw, fname, picks=None, tmin=0, tmax=None,
overwrite=False)

console-->
saving to EDF_test, filetype 2
'name'

the resulting file is very small and its content is :

Error! C:\Python\Python38\EDF_test is not UTF-8 encoded
Saving disabled.
See Console for more details.

edf original:
https://drive.google.com/file/d/1uuiZ4hswH2i4CLZq5BjqHAvwhhR9xARJ/view?usp=sharing

Could you help me . Thanks in advance

header:

{'technician': '', 'recording_additional': '', 'patientname': 'RLSR', 'patient_additional': 'righthanded 4 Kg', 'patientcode': '00000', 'equipment': 'EMSA Equipamentos Medicos S.A.', 'admincode': '0000001', 'gender': 'Female', 'startdate': datetime.datetime(2004, 11, 1, 8, 19, 53), 'birthdate': '02 nov 1995', 'annotations': []}

@skjerns
Copy link
Author

skjerns commented Oct 25, 2021

I edited the gist, it should work now. (I also removed some patient-sensitive data from your post, please also remove the file from your google drive as it contains patient specific data that you're probably not allowed to share)

@paulokanda
Copy link

paulokanda commented Oct 25, 2021

Dear Mr Kern

I updated the gist

original file without sensitive info:

https://drive.google.com/file/d/1kJKHQRE5FslZkm7RkOoZkVPZ1b7P_5bB/view?usp=sharing

but the result doesnot copy the data:

https://drive.google.com/file/d/14Sog6jkKrfLIOwFgNGAGlorYz2KHRhrs/view?usp=sharing

@skjerns
Copy link
Author

skjerns commented Oct 26, 2021

I cannot reproduce the problem.

raw = mne.io.read_raw('0651701_Copy (1).edf')
write_mne_edf(raw, 'test.edf')

I can read and save the file with no problems. Sorry, I will not be able to give individual support for specific problems that are not directly related to the gist.

@paulokanda
Copy link

Sure, I do it for fun and I'll find a way, thank you for your time, have a great day.

PKanda
Brazil

@mshree736
Copy link

I am getting an os error when trying to save the preprocessed eeg data in EDF format. Earlier it worked. I am now unable to save the epoched eeg data into a folder in EDF format. Could you tell me how to solve this issue? I'm getting the error shown below
"OSError: The filename (/Users/sreelakshmiraveendran/Desktop/Research papersMAC/Python programming/out_data) for file type raw must end with .fif or .fif.gz"

@skjerns
Copy link
Author

skjerns commented Nov 8, 2021

You are not using the script, but probably raw.save(), which is MNE buitlin. Also I have no idea what you are doing, please provide a context with code that reproduces the issue. This does not seem like an error in the script, but rather a general Python programming mistake.

@jinshengliu
Copy link

The original file is edF file, now want to convert to BDF file format, can this library be used successfully?Thank you very much,My email is 1738388008@qq.com

@skjerns
Copy link
Author

skjerns commented Jun 21, 2022

You can do so easily with pyedflib. However, you will gain nothing from it, as your precision will be obviously capped by the EDF file.

EDF and BDF are the same file format with the difference of having 16 and 24 bits of precision.

import pyedflib
signals, sheads, header = pyedflib.highlevel.read_edf(filename)
pyedflib.highlevel.write_edf('out.bdf', signals, sheads, header)

@jinshengliu
Copy link

jinshengliu commented Jun 21, 2022 via email

@jinshengliu
Copy link

jinshengliu commented Jun 21, 2022 via email

@skjerns
Copy link
Author

skjerns commented Jun 21, 2022

you might need to manually alter the physical min/max in the signal headers of some channels. Sorry, cannot give any further help than this :-/

@jinshengliu
Copy link

jinshengliu commented Jun 21, 2022 via email

@GABowers
Copy link

I'm encountering an error with the current gist here and BDF files, using the latest versions of mne and pyedflib (1.3.1 and 0.1.32 respectively)

Using the files directly from BioSemi I mentioned in an earlier message here (https://gist.github.com/skjerns/bc660ef59dca0dbd53f00ed38c42f6be?permalink_comment_id=3187890#gistcomment-3187890), I'm running this script:

import mne, save_edf, os

if __name__ == "__main__":
    path = '~~~/Downloads/BDFtestfiles'
    files = [x for x in os.listdir(path) if 'mod' not in x]
    for file in files:
        minus = '.'.join(file.split('.')[:-1])
        nneww = '{}/{}_mod.bdf'.format(path, minus)
        dat = mne.io.read_raw_bdf('{}/{}'.format(path, file))
        save_edf.write_mne_edf(mne_raw=dat, fname=nneww, overwrite=True)

There is another file named save_edf.py that is the gist on this page. I get the following error:
error

I'm not sure but I suspect this is related to BioSemi's default units--I think their devices save in micro-Volts. The only field I see than mentions that (in the mne.io.Raw object) is the _orig_units field; however the "original" in that name makes me hesitant to treat it as a ground truth for the current state of the file. Any suggestions for handling this?

@skjerns
Copy link
Author

skjerns commented Apr 27, 2023 via email

@alexrockhill
Copy link

MNE has an mne.io.export_raw now too which might be helpful if that's all handled within that function

@GABowers
Copy link

GABowers commented Apr 27, 2023

I think the only thing that you can try is trial and error: downscale the data and change dimension accordingly and check with EdfBrowser if the signal is loaded correctly. If they save in microvolts I'm surprised that there are values of 1e13, which would be death for most organisms-----------Sent from mobileAm 27.04.2023 um 13:52 schrieb GABowers @.>:Re: @. commented on this gist.I'm encountering an error with the current gist here and BDF files, using the latest versions of mne and pyedflib (1.3.1 and 0.1.32 respectively)Using the files directly from BioSemi I mentioned in an earlier message here (https://gist.github.com/skjerns/bc660ef59dca0dbd53f00ed38c42f6be?permalink_comment_id=3187890#gistcomment-3187890), I'm running this script:import mne, save_edf, os if name == "main": path = '~~~/Downloads/BDFtestfiles' files = [x for x in os.listdir(path) if 'mod' not in x] for file in files: minus = '.'.join(file.split('.')[:-1]) nneww = '{}/{}_mod.bdf'.format(path, minus) dat = mne.io.read_raw_bdf('{}/{}'.format(path, file)) save_edf.write_mne_edf(mne_raw=dat, fname=nneww, overwrite=True) There is another file named save_edf.py that is the gist on this page. I get the following error:I'm not sure but I suspect this is related to BioSemi's default units--I think their devices save in micro-Volts. The only field I see than mentions that (in the mne.io.Raw object) is the _orig_units field; however the "original" in that name makes me hesitant to treat it as a ground truth for the current state of the file. Any suggestions for handling this?—Reply to this email directly, view it on GitHub or unsubscribe.You are receiving this email because you authored the thread.Triage notifications on the go with GitHub Mobile for iOS or Android.

Your comment led me to look a bit deeper. It seems that the mne_raw._raw_extras dictionary is missing some keys, which causes the code to use values from the data for the various channel infos--the "except" in the following:

try:
                ch_dict = {'label': mne_raw.ch_names[i], 
                           'dimension': mne_raw._orig_units[keys[i]], 
                           'sample_rate': mne_raw._raw_extras[0]['n_samps'][i], 
                           'physical_min': mne_raw._raw_extras[0]['physical_min'][i], 
                           'physical_max': mne_raw._raw_extras[0]['physical_max'][i], 
                           'digital_min':  mne_raw._raw_extras[0]['digital_min'][i], 
                           'digital_max':  mne_raw._raw_extras[0]['digital_max'][i], 
                           'transducer': '', 
                           'prefilter': ''}
            except:
                ch_dict = {'label': mne_raw.ch_names[i], 
                           'dimension': mne_raw._orig_units[keys[i]], 
                           'sample_rate': sfreq, 
                           'physical_min': channels.min(), 
                           'physical_max': channels.max(), 
                           'digital_min':  dmin, 
                           'digital_max':  dmax, 
                           'transducer': '', 
                           'prefilter': ''}

And it's getting those values from, surprise surprise, the BioSemi Status channel.

I think the simplest solution is to get that physical_min and max from all channels except any named Status--I'll write something up for that in a few hours when I have some time.

Edit: See below. Simple change.

except:
                physical = channels if 'Status' not in mne_raw.ch_names[-1] else channels[:-1]
                ch_dict = {'label': mne_raw.ch_names[i], 
                           'dimension': mne_raw._orig_units[keys[i]], 
                           'sample_rate': sfreq, 
                           'physical_min': physical.min(), 
                           'physical_max': physical.max(), 
                           'digital_min':  dmin, 
                           'digital_max':  dmax, 
                           'transducer': '', 
                           'prefilter': ''}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment