Documentation3 of 11
Output files
Every file a conversion writes, column by column, including time semantics, column naming and value precision
What a conversion writes#
A conversion writes a directory, not a single file. Given sleep-study.edf and no --out, the
directory is sleep-study_csv beside the input: the file's name with its extension removed and
_csv appended.
edf2csv sleep-study.edf
sleep-study_csv/
signals.csv the samples
channels.csv one row describing each channel in the recording
annotations.csv the EDF+ event list, when the recording has one
metadata.json what was converted, from what, and what was unusual about it
Which of these appear is governed by four rules:
signals.csvis written unless you pass--annotations-only, or unless there are no samples to put in it — a file with nothing in it is not written, and the run says which of the two happened. Either every channel you selected carries zero samples per data record:warning: No signal file is written: every channel selected carries zero samples per data record, so there is nothing to put in one. Nothing about them is lost: every channel's samples per record is in the channel table --info prints, and in the channels.csv a conversion writes.or the recording has no signal channels at all, holding only EDF+ annotations — in which case nothing was selected and there is nothing for
channels.csvto describe:warning: No signal file is written: there is no signal data in this recording to put in one. annotations.csv holds whatever events it carries. channels.csv lists signal channels, so it has none to list.When channels were recorded at more than one sampling rate,
signals.csvis replaced by onesignals_<rate>hz.csvper rate. See one file per sampling rate.annotations.csvis written only when the recording has an EDF+ or BDF+ annotation channel. A plain EDF file has nowhere to store events, so no file is written rather than an empty one. When the channel exists but holds no events, the file is written with its header row and nothing else.channels.csvandmetadata.jsonare always written, including under--annotations-only.
If the output directory already exists, the conversion stops with exit code 1 and writes nothing.
Pass --force to overwrite, or --out to choose somewhere else.
The CSV dialect#
All three CSV files use the same conventional dialect:
| Property | Value |
|---|---|
| Encoding | UTF-8, no byte order mark (--bom adds one) |
| Line ending | LF (\n), including a final newline at end of file |
| Delimiter | Comma |
| Header | Exactly one row, always present |
| Quoting | RFC 4180, minimal |
| Missing value | Empty field, never NA or null |
Minimal quoting means a field is wrapped in double quotes only when it contains a comma, a double
quote, a carriage return or a line feed. Embedded double quotes are doubled. Nothing else is
quoted, so numeric columns are never quoted and a label like EEG Fpz-Cz is written as it stands.
This is what pandas.read_csv, readr::read_csv and csv.reader assume by default, so no dialect
arguments are needed on the reading side.
The header row isn't a comment and isn't preceded by any preamble. Row one is column names, row two is data.
signals.csv#
One row per sample instant, one column per converted channel, plus a leading time column.
time_s,EEG Fpz-Cz,EOG horizontal,ECG
0.000,0.061,-12.454,0.30273
0.010,15.324,-12.332,0.31494
0.020,30.281,-12.210,0.32715
Columns appear in the order the channels appear in the file, which is the same order as
signal_index in channels.csv. Every value in the file was recorded. Nothing is interpolated,
smoothed, resampled or filled.
The time_s column#
time_s is seconds elapsed since the start of the recording — the instant
recording.start_datetime_local names in metadata.json — so adding one to the other gives an
absolute instant. It isn't a wall clock and isn't a Unix timestamp.
Zero is usually the first sample, and does not have to be. An EDF+ file's first data record
carries a timekeeping annotation stating where that record sits relative to the header's start
time, and edf2csv uses it rather than assuming zero: a recording whose first record says +0.5
writes its first row as 0.500, and one saying +30 writes 30.000. That is a property of the
annotation, not of continuity — a plain EDF+C file can begin anywhere. --info prints a
Timed from line whenever it is not zero, and --start and --end are read on the same clock.
Three properties of the column:
It stays absolute when you convert a window. --start 30m --duration 5m produces a file whose
first row reads 1800.000, not 0.000. Times refer to positions in the recording, so a section
converted on its own lines up with the full conversion, with the annotation onsets, and with any
other section.
Window edges are half-open. A row is written when time_s >= start and time_s < end, so
--start 0 --end 30 and --start 30 --end 60 together produce every row exactly once, with none
repeated at the seam.
On a discontinuous recording it jumps. In an EDF+D file each data record carries its own start
time in a timekeeping annotation, and edf2csv uses that time rather than assuming records sit end
to end. A recording that pauses for eight seconds after two seconds of data produces this:
1.800,2.259
1.900,2.381
10.000,2.503
10.100,2.625
Rows are written in file order, so if a file's records are stored out of chronological order the
column won't increase monotonically, and a DISCONTINUOUS warning says so. If a record's
timekeeping annotation is missing or unreadable, that record is timed as if it were contiguous and
an ANNOTATION_DECODE_FAILED warning names the affected records.
How many decimals time_s carries#
Sample times are written with a fixed number of decimals chosen from the sampling rate, with a minimum of three.
The interval between samples is 1 / rate. That fraction has a terminating decimal expansion of
d places exactly when 10^d divides evenly by the rate. edf2csv searches for the smallest such
d up to fifteen places and uses it, so sample times are written exactly rather than rounded, and
time_s * rate comes back as a whole row number instead of 8191.999999. Fifteen is the bound
because 10^16 is past 2^53, where the integer test stops being able to tell.
| Sampling rate | 1 / rate | Decimals in time_s |
Exact? |
|---|---|---|---|
| 1 Hz | 1 | 3 | yes |
| 100 Hz | 0.01 | 3 | yes |
| 250 Hz | 0.004 | 3 | yes |
| 256 Hz | 0.00390625 | 8 | yes |
| 500 Hz | 0.002 | 3 | yes |
| 512 Hz | 0.001953125 | 9 | yes |
| 1000 Hz | 0.001 | 3 | yes |
| 1024 Hz | 0.0009765625 | 10 | yes |
| 4096 Hz | 0.000244140625 | 12 | yes |
| 3 Hz | 0.333... | 4 | rounded |
256 Hz is the case that comes up most in practice. Written with three decimals, sample 1 of a
256 Hz channel would be 0.004, and dividing that back by the sample period wouldn't return 1.
Written with eight, it's 0.00390625, the exact value, and time_s * 256 is an integer for every
row in the file.
One kind of rate falls outside this: one whose reciprocal doesn't terminate at all, such as 3 Hz. It gets enough places to keep consecutive samples distinct and no more, and is marked "rounded" above — the times are accurate to within a fraction of a sample period, but multiplying them by the rate won't land on exact integers.
Every power of two through 32768 Hz terminates inside fifteen places, so every rate a recording is likely to use is written exactly. Up to 0.5.23 this section said the search stopped at nine and listed 1024 Hz as rounded at seven places; the bound has been fifteen since 0.4.55, and 1024 Hz gets ten and is exact.
--decimals doesn't affect this column. It sets the precision of the signal values only.
Column names#
A channel's column is its EDF label, copied verbatim. EEG Fpz-Cz stays EEG Fpz-Cz, spaces,
hyphens, case and all. Nothing is slugified, lowercased or stripped, since the label is how you
recognise the channel and rewriting it would break the correspondence with the recording's own
documentation.
Three exceptions:
- Empty label. A channel with a blank label becomes
signal_<index>, for examplesignal_4. AnEMPTY_LABELwarning is raised. - Duplicated label. When two or more channels share a label, every one of them gets a
_ch<index>suffix naming its position in the file. Two channels both labelledT8-P8at positions 0 and 1 becomeT8-P8_ch0andT8-P8_ch1. This happens in real clinical archives, and position is the only thing that reliably tells the channels apart. ADUPLICATE_LABELwarning is raised. - A label of
time_s. The time column is not one of the channels — the writer puts it in front of them — so a channel labelledtime_swould give the file two columns of that name. It takestime_s_ch<index>instead, and aDUPLICATE_LABELwarning names it. Legal, since EDF labels are free text, and what a montage exported from a tool that already had a time column looks like.
Names are derived from the whole file, not from your selection. A channel produces the same column
name whether you convert everything or ask for it alone with --channels, so files from different
runs can be joined without renaming anything. The mapping from column name back to signal position
is recorded in channels.csv.
Column names go through the same minimal quoting as any other field, so a label containing a comma is quoted and a label containing a double quote has it doubled.
How many decimals each value carries#
Precision is chosen per channel from that channel's own calibration, not fixed globally.
An EDF sample is an integer from the analog-to-digital converter, and the header says which physical range that integer range spans. The smallest physical difference the channel can express is one digital step:
step = |physical_max - physical_min| / |digital_max - digital_min|
Both differences are magnitudes. Either pair may be written the wrong way round — the header is
free to say physical_min 100, physical_max -100, or to reverse the digital pair, and both
happen — and a step is a size, so the sign is dropped. That is what makes an inverted channel
get the same precision as the upright one it inverts, which is the only answer that keeps every
one of its distinct codes distinguishable.
edf2csv writes ceil(-log10(step)) + 2 decimals, clamped to the range 0 to 100. The two extra
places put rounding error well below the resolution the hardware recorded, so no two distinct
digital codes round to the same text, without padding the file with digits that carry no
information.
| Channel | Physical range | Digital range | Step | Decimals |
|---|---|---|---|---|
| EEG Fpz-Cz | -250 to 250 uV | -2048 to 2047 | 0.1221 uV | 3 |
| ECG | -5 to 5 mV | -2048 to 2047 | 0.002442 mV | 5 |
| Temp rectal | 34 to 40 degC | -2048 to 2047 | 0.001465 degC | 5 |
| A1 (24-bit BDF) | -262144 to 262144 uV | -8388608 to 8388607 | 0.03125 uV | 4 |
The upper clamp is 100 because that is the most toFixed will print — 101 is a RangeError — and
nothing short of that is a principled place to stop. It was 20 until 0.4.74, on the stated grounds
that 20 was toFixed's limit, which it is not. The difference showed on the channel type this
paragraph already named: a magnetometer spanning ±1e-16 T over a 16-bit converter steps by
3.05e-21 and needs 23 places, so at 20 its values landed on a 1e-20 grid, roughly three digital
codes to a printed value, and 69% of them could not be recovered. Nothing warned.
Reaching 100 takes a step below 1e-98, which an 8-character physical bound can still express —
1e-99 is five characters. A channel that does raises a VALUE_RESOLUTION warning rather than
losing precision in silence.
Two details of the formatting:
- Values are written with a fixed number of decimals, so
0.061and15.324line up and a column never mixes1e-5notation with plain decimals. - A value that scales to a very small negative number is written as
0.000, not-0.000. Negative zero isn't a distinct measurement.
Pass --decimals <n> to override the derived precision and use the same number of places on every
channel. That's useful for diffing two conversions or for shrinking a file, but it can round
distinct samples together, which is why it isn't the default.
The value itself is computed as gain * (offset + digital), EDFlib's arrangement of the EDF
calibration formula rather than the specification's literal ordering. The two are algebraically
equal but not numerically equal: the literal form computes a large intermediate and then subtracts
a large constant, and the cancellation drops low bits. The arrangement used here returns the
correctly rounded result, and it's bit-for-bit identical to pyEDFlib and EDFbrowser, which share
the same arithmetic.
One file per sampling rate#
Recordings often mix rates. A sleep study may hold EEG at 256 Hz, ECG at 128 Hz and rectal temperature at 1 Hz. These can't share one wide table — a column per channel — without inventing values for the slow channels, so each distinct rate gets its own file:
edf2csv recording.edf --out ./converted
converted/
signals_256hz.csv time_s, EEG Fpz-Cz
signals_128hz.csv time_s, ECG
signals_1hz.csv time_s, Temp rectal
channels.csv
metadata.json
Each file has its own time_s column with its own decimal precision, and every row in every file
is a sample that was recorded. A MIXED_SAMPLING_RATES warning tells you this happened.
The filename is signals_<rate>hz.csv, where a fractional rate has its decimal point replaced by an
underscore so the name is safe on every filesystem: 12.5 Hz becomes signals_12_5hz.csv and 0.5 Hz
becomes signals_0_5hz.csv.
When every converted channel shares one rate, there's one group and the file is called
signals.csv. This means the filename depends on the recording and, if you use --channels, on
your selection: selecting only the 256 Hz channels out of a mixed-rate file yields a plain
signals.csv. Read conversion.rate_groups in metadata.json if a script needs to know the names
without guessing.
Joining the rates means deciding what to do about the mismatch. Merging on time_s with a nearest
or backward-fill strategy is one answer, and it's a decision to make in your own code with the
original sample times in front of you.
The other answer is not to make the rates share a row at all. --layout long
writes one file whatever the rates are — time_s, channel, value, one row per sample — so each
sample keeps its own time and nothing has to line up:
time_s,channel,value
0.00000000,EEG Fpz-Cz,0.061
0.00000000,ECG,0.00122
0.00000000,Temp rectal,37.00073
0.00390625,EEG Fpz-Cz,9.096
Nothing is invented there either; it is the same samples in a different shape. The cost is size, since every row repeats the time and the channel name.
channels.csv#
One row per signal channel in the recording, whether or not it was converted. The EDF+ annotation channel isn't a signal and isn't listed.
column,signal_index,label,unit,sampling_rate_hz,samples_per_record,physical_min,physical_max,digital_min,digital_max,transducer,prefiltering,output_file,converted
EEG Fpz-Cz,0,EEG Fpz-Cz,uV,256,256,-250,250,-2048,2047,,,signals_256hz.csv,yes
ECG,1,ECG,mV,128,128,-5,5,-2048,2047,,,signals_128hz.csv,yes
Temp rectal,2,Temp rectal,degC,1,1,34,40,-2048,2047,,,signals_1hz.csv,yes
| Column | Meaning |
|---|---|
column |
The column name this channel uses in the signals file, after the empty-label and duplicate-label rules. Join on this to attach units to a signals column. |
signal_index |
Position of the channel in the file, counting from 0 and counting the annotation channel if present. This is the identifier --channels "#2" addresses, and the only stable one when labels collide. |
label |
The label exactly as stored in the EDF header, with no disambiguating suffix. Where two rows share a label they'll differ in column. |
unit |
The physical dimension from the header, verbatim: uV, mV, degC, %. Files vary in spelling and some leave it blank. Nothing is normalised. |
sampling_rate_hz |
samples_per_record / record_duration_seconds. This decides which output file the channel lands in, and is written in the same notation that file is named in — until 0.8.39 it was rendered on its own, so a rate of 1e-19 sat in the row naming signals_1_000e-19hz.csv. Written Infinity when the record duration is too small to divide into, where both JSON documents have to write null. |
samples_per_record |
Samples this channel stores in each EDF data record, straight from the header. |
physical_min, physical_max |
Calibration range in the unit above, as declared. Written as plain decimal at any magnitude, the way signals.csv writes the values they scale: a channel calibrated to ±1e-16 T reads -0.0000000000000001, not -1e-16, so the column holds one notation whatever its rows mix. |
digital_min, digital_max |
Calibration range in raw converter counts, as declared, in the same plain decimal. |
transducer |
Free-text electrode or sensor description from the header, often blank. |
prefiltering |
Free-text filter description from the header, for example HP:0.1Hz LP:75Hz N:50Hz. Often blank. Read it before you filter the data again. |
output_file |
Name of the CSV holding this channel's samples, or empty when the channel wasn't converted. |
converted |
yes or no. |
The four calibration columns carry the values the header declares, so a channel whose
physical_min sits above its physical_max survives into the file that way rather than being
corrected. The notation is this file's rather than the header's — -1.00e-9 in the header is
-0.000000001 here — since a column that is decimal text in one row and exponent text in the
next is a column a reader has to parse twice. That by itself is not what makes a channel inverted. The gain is
(physical_max - physical_min) / (digital_max - digital_min), so it is the sign of the whole
fraction that decides: reverse one pair and the polarity is inverted, reverse both and the gain
comes out positive and the channel is perfectly ordinary — no warning, and none is warranted.
When the gain really is negative, the values in signals.csv are converted exactly as the header
specifies, inversion included, and an
INVERTED_PHYSICAL_RANGE warning names the
channel and whichever pair is the wrong way round.
converted is no in three situations: you used --channels and didn't ask for this one, you
used --annotations-only so nothing was converted, or the channel declares zero samples per record
and therefore holds no data. In every case the row is still present, so channels.csv describes
the whole recording and not only what you exported.
annotations.csv#
Written whenever the recording has an EDF+ or BDF+ annotation channel. One row per event.
onset_s,duration_s,description,record_index
0.5,1,Sleep stage W,0
1.25,,Lights off,1
2,0.5,Seizure onset,2
| Column | Meaning |
|---|---|
onset_s |
Seconds from the start of the recording, on the same scale as time_s in the signals files, so the two join directly. |
duration_s |
Length of the event in seconds, or empty when the event carries no duration. Also empty when the file stated one that is not a number, which raises an ANNOTATION_DECODE_FAILED warning saying how many rows that happened to — the cell itself cannot tell the two apart. |
description |
The annotation text, copied verbatim — decoded as UTF-8 where the bytes are UTF-8 and as latin1 where they are not, so nothing the file does not hold appears in the cell. Quoted per the CSV rules when it contains a comma, a quote or a newline. It is free text, and where it lands matters: one starting with =, + or @ is a formula to a spreadsheet and raises FORMULA_LABEL, and one carrying a control byte or a bidirectional override raises NONPRINTABLE_LABEL. Neither is rewritten — the cell says what the recording says — but neither is silent either. |
record_index |
The data record the annotation was stored in, counting from 0. Useful for tracing an event back to its position in the source file. |
An absent duration is written as an empty field, never as 0. The distinction is real: EDF+ lets
an annotation mark an instant with no extent, and writing that as a zero-second event would be a
claim the file doesn't make. In pandas the column reads as NaN with no extra arguments; treat
NaN as "instantaneous or unspecified" rather than "zero length".
Rows are sorted by onset_s, with ties broken by record_index. onset_s and duration_s are
written in their natural numeric form, so 0.5, 1.25 and 2 all appear as such, without padding
to a fixed decimal count.
Two things don't appear as rows. The timekeeping annotation that starts each data record carries
the record's position in time and no text, so it's used for timing and not exported as an event.
And annotations whose onset falls outside a requested --start / --end window are excluded, on
the same half-open rule as the signal rows. The bounds are the ones you asked for rather than the
window after it was clamped to the recording, so an end you did not give stays unbounded: --end 999h
and --start 0 both keep an event sitting at or past the last sample, exactly as a run with no time
options does. The whole annotation channel is read even when a window
was requested, because an event inside the window may be stored in a record outside it.
If an annotation is malformed, it's skipped rather than aborting the conversion, and an
ANNOTATION_DECODE_FAILED warning reports how many were lost.
metadata.json#
A record of what was converted, from what, when, and what was unusual about it. This is what makes a conversion reproducible six months later.
{
"tool": {
"name": "edf2csv",
"version": "..."
},
"source": {
"path": "/data/recordings/sleep-study.edf",
"bytes": 19643392,
"modified": "2026-03-14T09:12:44.000Z",
"sha256": "aa8b902eb999a58b20122396a39b8db7a12d4e9c93b8447e6b3f374d43e7dc2c"
},
"recording": {
"format": "EDF+ (continuous)",
"version": "0",
"patient_id": "X X X X",
"recording_id": "Startdate 02-MAR-2002 X X X",
"start_datetime_local": "2002-03-02T23:10:00",
"start_date_raw": "02.03.02",
"start_time_raw": "23.10.00",
"data_records": 28800,
"data_records_declared": 28800,
"record_duration_seconds": 1,
"duration_seconds": 28800,
"signal_count": 6,
"annotation_channels": 1
},
"conversion": {
"converted_at": "2026-03-20T11:35:02.418Z",
"start_seconds": 0,
"end_seconds": 28800,
"whole_recording": true,
"records_converted": [0, 28800],
"annotations_written": 7,
"layout": "wide",
"bom": false,
"files": [
{ "name": "signals_100hz.csv", "rows": 2880000 },
{ "name": "signals_10hz.csv", "rows": 288000 },
{ "name": "signals_1hz.csv", "rows": 28800 },
{ "name": "annotations.csv", "rows": 7 },
{ "name": "channels.csv", "rows": 5 }
],
"rate_groups": [
{
"file": "signals_100hz.csv",
"sampling_rate_hz": 100,
"channels": ["EEG Fpz-Cz", "EEG Pz-Oz", "EOG horizontal"],
"decimals": [3, 3, 3]
},
{
"file": "signals_10hz.csv",
"sampling_rate_hz": 10,
"channels": ["Resp oro-nasal"],
"decimals": [6]
},
{
"file": "signals_1hz.csv",
"sampling_rate_hz": 1,
"channels": ["Temp rectal"],
"decimals": [5]
}
]
},
"notes": [
{
"code": "MIXED_SAMPLING_RATES",
"severity": "warning",
"message": "Channels use 3 different sampling rates (100 Hz, 10 Hz, 1 Hz)."
},
{
"code": "LARGE_OUTPUT",
"severity": "warning",
"message": "At least one output file will have more than 1,048,576 rows, which is more than Excel or Numbers can open."
}
]
}
The file is UTF-8, indented with two spaces, and ends with a newline.
tool and source: where the data came from#
tool.name and tool.version identify the converter. Quote the version in a methods section; a
future version that changes any formatting rule will say so here.
source.path is the absolute path of the input as resolved at conversion time. source.bytes and
source.modified describe the file as it was when the conversion opened it — the same size every
record count and window below was derived from — rather than whatever is at that path when the run
finishes.
source.sha256 is null unless you passed --checksum, which reads the input a second time to
hash it. With a hash recorded, anyone holding the original can establish that the CSVs came from
that exact file, and you can detect a re-export or a partial copy that kept the same size and name.
It costs one extra read of the input, which is worth it for anything you intend to publish or
archive.
The hash is taken before any record is read, and published only if the file held still for the
whole conversion. If the size or the modification time moved at any point, sha256 comes back
null and the run raises INPUT_CHANGED: a file overwritten in place keeps its inode, so the bytes
that were converted are simply gone by then, and a plausible hash of the wrong bytes is worse than
no hash at all. The CSVs and the rest of metadata.json are still correct for the data that was
read. This is the ordinary outcome of converting a recording that is still being written; convert
again once it is finished.
warning: The input changed while it was being converted, so this output covers the file as it
was when the conversion started, not as it is now.
No checksum was recorded: the bytes that were converted are no longer
there to hash. Convert again once the recording is finished.
recording: what the header said#
formatis one ofEDF,BDF, orEDF+/BDF+with(continuous)or(discontinuous).versionis the header's version field:0for EDF,BIOSEMIfor BDF.patient_idandrecording_idare the header's two identification fields, copied verbatim. In research files these are usually anonymised placeholders, but the EDF format allows real names, dates of birth and hospital numbers, and some files carry them. Check these two fields before sharing a converted directory.start_datetime_localis the recording start as a zone-less wall clock, resolved from the header's date and time fields. It'snullwhen those fields are unusable, which is not rare.start_date_rawandstart_time_rawpreserve the originaldd.mm.yyandhh.mm.sstext either way, so nothing is lost to the interpretation.data_recordsis how many complete data records the file actually contains, derived from its size.data_records_declaredis what the header claims, and is-1when the header doesn't say, which the specification permits for a recording still in progress. When the two disagree, the real count wins and aRECORD_COUNT_MISMATCHnote appears; a truncated file is the usual cause.duration_secondsisdata_records * record_duration_seconds, so for a discontinuous recording it's the amount of data, not the span of time the recording covers.signal_countcounts every channel in the header, annotation channels included.annotation_channelssays how many of those were annotation channels.
conversion: what this run did#
converted_atis when this run finished, as an ISO 8601 instant in UTC.start_secondsandend_secondsare the resolved time window, in seconds from the start of the recording, half-open.end_secondsisnullwhen the recording's end is not a number JSON can hold — a header stating 1e308-second records overflows a double at its third one — as isrecording.duration_secondson the same file, where--infoprintsDuration unknown. Both ends are clamped to the recording, so a window asked for wider than the recording is recorded as the part of it that exists:--start=-500 --end 999hon a two-second file comes back as0and2, and their difference is always a span the conversion actually covered.whole_recordingistruewhen the window covers everything, which saves a script from comparing floats.whole_recordingis true when the conversion covered the recording from its first sample to its last. A window that happens to name exactly those bounds counts as whole — the length of a recording isrecords × record_duration, which for 6003 records of 0.1s is 600.3000000000001 rather than the 600.3 it prints as, and up to 0.5.94--end 600.3on such a file was recorded as partial while writing every sample.records_convertedis the half-open range of data record indexes the converted window covers,[first, last). Under--annotations-onlyno signal records are read at all and this still describes the window; the annotation channel is read in full whatever it says.annotations_writtenis the number of rows inannotations.csv, excluding its header.fileslists every CSV written with its data row count, again excluding the header row. Add one per file if you're checking line counts on disk.metadata.jsondescribes the run and isn't listed among the files the run produced.layoutis"wide"or"long", matching--layout. It is what tells a pipeline which shape the signal table is in, since the two have different columns and nothing else in the archive distinguishes them.bomistruewhen--bomwas given, so every CSV in this directory begins with a UTF-8 byte order mark. Recorded for the same reasonlayoutis: nothing else here shows it.--gzipnames itself infiles, where the entries end.csv.gz; the mark is three bytes at the front of a file and leaves no other trace. It is also the one that decides whether reading the table back works — pandas strips it either engine, and Python's owncsv.readerover a plainopen()does not, nor doesfs.readFileSync(path, 'utf8'), so the first column name comes back as\ufefftime_sand a lookup oftime_smisses. Readers that want it gone ask forutf-8-sig.rate_groupsrecords the grouping decision: for each group, the file it was written to, its sampling rate, its channels in order, and the decimal precision used for each. Itssampling_rate_hzisnullwhen the rate is not a number JSON can hold, for the reasonduration_secondsabove is;channels.csvwritesInfinityin that cell instead. This is the machine-readable answer to "which file holds which channel", and it's the field to read if a pipeline needs to locate the output without knowing in advance whether the recording was single-rate or mixed.Read it against
layout. In the wide layout there is one entry per file and itschannelsare that file's columns, in order, aftertime_s. In the long layout every entry names the one shared table, whose columns aretime_s,channel,value, and itschannelsare values appearing in that table'schannelcolumn rather than columns of it — so a mixed-rate recording produces three entries all namingsignals.csv, one per rate, which is the grouping and not a list of files.
notes: every diagnostic, in the archive#
notes carries every diagnostic the conversion raised, each with a code, a severity and a
message. These are the same warnings printed to standard error during the run, preserved so they
stay attached to the data rather than scrolling out of a terminal. All but one: STALE_OUTPUT is
noticed after this file has been written, and says something about the destination rather than about
the recording, so it reaches the terminal and --json and never the archive. An empty array means
the recording parsed cleanly, and that leftovers from an earlier conversion are the one thing it
does not rule out.
Read the diagnostics before you analyse the data. MIXED_SAMPLING_RATES explains why you have
three signal files. RECORD_COUNT_MISMATCH says the recording is shorter than its header promised.
DEGENERATE_DIGITAL_RANGE says a channel's calibration is self-contradictory, which is why that
column is empty. DISCONTINUOUS says the gaps in the time column are real.
Leftovers from an earlier run#
--force overwrites the files a conversion writes, but it doesn't empty the directory first.
Converting a mixed-rate recording into a directory and then converting a single-rate one into the
same place leaves signals_256hz.csv sitting next to a fresh signals.csv, with only one of them
current.
edf2csv detects this and warns with STALE_OUTPUT, naming the files that weren't rewritten. It
deletes nothing, since which of the two conversions you meant to keep isn't something the converter
can determine. Delete them yourself, or convert into a fresh directory.
Read this page as plain Markdown, or the whole documentation as one text file.