EDF files' specification
It’s 2024 and ChatGPT have been trained on all the Internet’s available information. So, it should contain inforabout a widely used file format from the 90s, right? In particular, I needed help on a data-file format used in biomedical datasets since 1992: EDF+C (European Data Format for contiguous stored signals). And, several LLMs hallucinated the results, regurgitating that EDF+C was a gzip-compressed format. Because +C, the LLMs inferred that it meant “compression” and not “contiguous”. Given the age of the standard and its popularity among open-access datasets, it surprised me a lot.
But, I needed to known about the EDF+C binary format, and I digged into the specification to explore how this format is implemented. This is the tutorial for dummies like me.
Let’s recall that when signals are recording, we expected to have the following multidimensional pattern:
We should rememeber that this is an abstract representation of the data we will use for our algorithms, and not how the data will be stored in disk. Also, because there are additional information that could be valuable for our analysis such as when this data was recorded, a subject identifies, channel names, and so on, that should be stored somewhere.
A proper standard should standardize the way to store such information, while also allowing a fast data-access, and that’s precisely what the EDF format does. In particular, it uses the following structure by adding metadata and partioning the entire dataset into records (in DL nomenclature, this would be equivalent to batches):
Now, a file dataset1.edf can be stored in either, the the EDF1990
or the EDF+2003 standard.
The latter, EDF+2003, is almost compatible with EDF1990 with a couple of differences.
The discrepancy that is most important to me is that EDF+2003 allows that contiguous records do not need to represent consecutive time intervals. This random recording behaviour is denoted as EDF+D (discontiguous EDF), while the classical contiguous structure is categorized as EDF+C. While, I think that EDF+D involves a huge penalty in computationally efficiency, that’s the standard.
General metadata
The first 256 of the file describes the main metadata: patient, date, time, storage mode, number of channels, records and the duration of each record. Note that classically, EDF names channels as signals, I’ll use channels here, becaue it’s less confusing that “signals”.
There are a few things that needs to be emphasized in this header:
- The last field (bits
252-253) contains the total number of channels (or signals) in the file. So, the maximum number of simultaneous channels to be recorded is9999. - This header uses only strings, so the fields marked with
(N)are integers or floats saved as strings, and they will need to be parsed. - The segment
Reserved(StorageMode)was supposed to be ignored in the EDF1990 standard. But in EDF+2003 will contain the string “EDF+C” or “EDF+D” to identify if the recordings are contiguous or not.
Channel-level metadata
Then, per each channel another 256 bytes are used to store channel-specific information. We need to emphasize that here, all values are ascii strings, even though that they represent numbers. I assume that the designers were inspired by the HDLC or TCP packet formats.
If the file only contains a single channel, this would be the header:
However, if there are several channels in the EDF file, each field is repeated for all channels before moving to the next field:
For instance, if we have three channels, the 768-byte metadata will have the following layout:
We need to note that each channel could have channels with different number of samples per record.
Sampling frequency?
If you check carefully, the EDF header does not specify the sampling frequency. Thus, how we get it?
For example, for a channel \(q\), we check the number of samples per record \(M_p\) (10th field in the channel metadata) and the duration of each record per second \(T_r\) (bytes 244-251 in the general metadata). Then, the sampling frequency, in Hertz, for the channel \(p\) will be:
Records
Records, or batches as I prefer to think, came after the channel-level metadata. And, they are also structured as the channel-level metadata: the data from one channel after the data from another channel, and so on.
EDF files organized the data in records, or batches (as I prefer to think about them). The records are saved consecutively:
That means that if we have \(N_{ch}\) channels, the first record (record 0) will start at byte \(256\cdot(1+N_{ch})\), just after the general metadata (first 256 bytes) and the channels’ metadata (next \(256\cdot N_{ch}\) bytes).
Each record has the same size, and it is organized in the following way, the data from one channel followed by the channel of the next one:
Note that each channel can have a different number of bytes assigned. This number (\(M_p\) for a channel \(p\)) is defined in the “samples per record” field in the channels’ metadata.
Thus, as we expected, to get the size of the record, we only need to sum all the sizes for each channel: \(N_{rec} = \sum_{p=0}^{N_{ch}-1} M_p\).
We need to emphasize that all records have the same size, and due to that, it’s possible to move to any record \(q\), because it will be start at the byte \(B_{(q)}\) and it will end exactly at byte \(B_{(q+1)}-1\):
Why I stressing the position locations? Because, if you only need a portion of the data, fseek will be your friend (in C-based languages, of course).
Annotations
If you are familiar with biomedical data formats, you have seen that EDF files often contain annotations. But, until now, we only saw records and channels, and nothing else so far.
The EDF+2003 standard define that the annotations should be stored in the last channel which should have the case sensitive label EDF Annotations. This implies that the values for this channel in each record are ascii strings and not numbers. So, a parser should be aware of that.