# edf2csv documentation Complete documentation for edf2csv 0.9.51, a command-line converter from EDF, EDF+ and BDF biosignal recordings to CSV. Source: https://github.com/tayal-sarthak/edf2csv This file is generated from the documentation at https://edf2csv.vercel.app/docs, which is canonical. If the two disagree, the site is newer. --- # Getting started > Install edf2csv, convert your first recording, and understand each of the files it writes ## What edf2csv is edf2csv is a command-line tool that reads an EDF, EDF+ or BDF/BDF+ biosignal recording — EEG, sleep, ECG, EMG — and writes it out as CSV. Alongside the data it writes a channel table, the EDF+ events, and a metadata file describing what was converted. It runs entirely on your own machine, it doesn't alter the recorded values, and it never resamples a channel to make the output table tidier. ## Requirements Node 20 or newer, and nothing else. edf2csv installs no dependencies at all, makes no network calls, and is MIT licensed. To check what you have: ```bash node --version ``` ## Running it The quickest route is `npx`, which fetches the tool on demand and leaves nothing installed: ```bash npx edf2csv recording.edf ``` If you convert files regularly, install it once: ```bash npm install -g edf2csv ``` After a global install the command is just `edf2csv`. The rest of this page uses that form; add `npx` in front of every command if you skipped the install. ## Your first conversion Point it at a file. No flags are required. ```bash edf2csv recording.edf ``` For a small EDF+ file holding one 100 Hz EEG channel and three events, the output is: ```text Wrote recording_csv signals.csv 300 rows annotations.csv 3 rows channels.csv 1 row Done in 11ms. ``` Some notes on that: - The output directory defaults to the input filename with its extension replaced by `_csv` — `recording.edf` becomes `recording_csv` — created next to the input file. Use `-o` or `--out` to put it somewhere else. - If that directory already exists, edf2csv leaves it alone and exits with status 1. Pass `--force` to overwrite it, or `--out` to write elsewhere, so a new conversion never mixes into an old one. - The summary, any warnings, and the live `converting… 42%` progress line all go to stderr. Only `--info` and `--json` write to stdout — along with `--stdout`, which puts the signal CSV itself there, and `--help` and `--version`, which print and exit — so you can pipe results straight into another program. - Exit status is 0 on success, 1 when a file couldn't be read or written — or when `--strict` was given and the recording raised a warning, where the output is written anyway — and 2 when the command itself was wrong: an unknown flag, or a channel name that doesn't exist. An interrupted run exits 130 for Ctrl-C, or 143 when something sends SIGTERM. - `--quiet` suppresses the summary. Warnings and errors still print. Conversion is streamed rather than loaded into memory. A 40 MB EDF that expands into a 159 MB CSV converts in roughly 1.4 seconds with the Node heap capped at 48 MB, so file size affects disk space rather than memory. ## What is in the output directory ```text recording_csv/ signals.csv the data: one row per sample time, one column per channel channels.csv one row per channel in the recording, with its calibration annotations.csv EDF+ events, written only when the file has an annotation channel metadata.json what was read, what was written, and every warning raised ``` ### signals.csv The first column is `time_s`, seconds elapsed from the start of the recording. Every other column is a channel, named with the label exactly as the file stores it, spaces and punctuation included. ```csv time_s,EEG Fpz-Cz 0.000,0.061 0.010,15.324 0.020,30.464 ``` The number of decimals in `time_s` is chosen so the sample interval is written exactly rather than rounded. At 100 Hz that's three places, as above. At 256 Hz it's eight, so a row reads `0.00390625` and multiplying `time_s` by the rate gives back a whole number instead of something like 8191.999999. That works whenever `1 / rate` terminates in decimal, which covers every rate a recording is likely to use; a rate whose reciprocal does not terminate, such as 3 Hz, is rounded instead, and [Output files](/docs/output-files#how-many-decimals-time_s-carries) says which rates those are. If two channels in the file share a label, both column names get a `_ch` suffix carrying the channel's position — `T8-P8_ch0`, `T8-P8_ch1` — since position is the only thing that reliably tells them apart. If the recording mixes sampling rates, there's no single `signals.csv`. You get `signals_256hz.csv`, `signals_1hz.csv` and so on, one file per rate, with nothing interpolated — or one file of `time_s,channel,value` if you pass [`--layout long`](/docs/cli-reference#--layout). See [Mixed sampling rates](/docs/sampling-rates) for the details. ### channels.csv One row per signal channel, whether or not it was converted. The columns are `column`, `signal_index`, `label`, `unit`, `sampling_rate_hz`, `samples_per_record`, `physical_min`, `physical_max`, `digital_min`, `digital_max`, `transducer`, `prefiltering`, `output_file` and `converted`. `column` is the name that channel has in the signal CSV, `output_file` says which file it landed in, and `converted` is `yes` or `no`. A channel you filtered out with `--channels` is still listed here rather than disappearing. ### annotations.csv Written only for EDF+ and BDF+ recordings that carry an annotation channel. Plain EDF files have no events to export. ```csv 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 ``` `onset_s` is on the same clock as `time_s` in the signal files. `duration_s` is empty for an event that has no stated duration, and also for one whose stated duration is not a number — the run warns when that happens. `record_index` is the data record the event was stored in. ### metadata.json Machine-readable provenance: the tool version, the source path, size and modification time, the recording's format, start time, record count and duration, the exact window converted, the row count of every file written, and the full list of warnings. Add `--checksum` to record a SHA-256 of the input file alongside it. ## Check a file before you convert it `--info` reads the header, and on an EDF+ recording a little of the annotation channel: at most sixteen records of a continuous file to find where it begins, stopping at the first that says, and the whole channel for a discontinuous one, whose record times are stored rather than arithmetic. It returns in milliseconds whatever the file's size either way, and writes nothing. [What it can and cannot tell you](/docs/warnings-and-errors#how-edf2csv-reports-problems) sets out which warnings follow from that. ```bash edf2csv sleep-study.edf --info ``` ```text File sleep-study.edf Format EDF+ (continuous) Recorded 2002-03-02 23:10:00 Duration 8h 00m 0s (28,800 records of 1s) Size 18.7 MB Patient X X X X Recording Startdate 02-MAR-2002 X X X Channels 5 signals + 1 annotation channel # COLUMN LABEL UNIT RATE RANGE OUTPUT 0 EEG Fpz-Cz EEG Fpz-Cz uV 100 Hz -250 to 250 signals_100hz.csv 1 EEG Pz-Oz EEG Pz-Oz uV 100 Hz -250 to 250 signals_100hz.csv 2 EOG horizontal EOG horizontal uV 100 Hz -250 to 250 signals_100hz.csv 3 Resp oro-nasal Resp oro-nasal V 10 Hz -1 to 1 signals_10hz.csv 4 Temp rectal Temp rectal degC 1 Hz 34 to 40 signals_1hz.csv Sampling rates differ, so channels are written to 3 files, one per rate. No channel is resampled. Would write 3,196,800 rows, roughly 108 MB, and annotations.csv. ``` Anything the tool noticed is printed after the table, on stderr — for this recording, two things: ```text warning: Channels use 3 different sampling rates (100 Hz, 10 Hz, 1 Hz). They are written to one file per rate so no channel is resampled. warning: At least one output file will have more than 1,048,576 rows, which is more than Excel or Numbers can open. Use --start and --duration to convert a section, or read the file with pandas or R. ``` The second is the spreadsheet limit, which [Can I open the output in Excel?](/docs/faq#can-i-open-the-output-in-excel) goes into. On a long recording, `--info` tells you four things before you spend any disk: - The row count and approximate output size. - The exact channel labels to pass to `--channels`, spelled the way the file spells them. - Whether the recording is discontinuous or mixed-rate. - Any header problem — a truncated file, a record count that disagrees with the data, a channel whose calibration can't be applied. Because the table goes to stdout and the warnings go to stderr, `edf2csv sleep-study.edf --info > structure.txt` saves the table on its own. Over a folder each warning carries the recording it came from, the way a batch conversion does — several warnings in a row are otherwise unattributable, since the tables they belong to went to the other stream. ## Convert a slice instead of the whole recording Give a start, then either a duration or an end. ```bash edf2csv sleep-study.edf --start 30m --duration 5m ``` ```bash edf2csv sleep-study.edf --start 1h --end 1h05m ``` Times can be a plain number of seconds (`90`), a unit form (`90s`, `5m`, `1h30m`, `250ms`), or a clock form (`00:30:00`, `30:00`). All offsets are measured from the start of the recording, not from the wall clock in the header. Passing `--duration` and `--end` together is an error, since they answer the same question. Combine a window with a channel filter and a destination: ```bash edf2csv sleep-study.edf --start 1h --duration 5m \ --channels "EEG Fpz-Cz,EOG horizontal" --out ./epoch-42 ``` Channel names must match the `LABEL` column from `--info`, though matching is case-insensitive. A name that matches nothing is an error rather than a silent omission. When two channels share a label, address one by position with `#N`, for example `--channels "#0"`. Two things to expect from a slice: - `time_s` isn't rebased. A window starting at one hour begins at `3600.000`, so rows stay comparable with the full recording and with the events. - `annotations.csv` is filtered to the same window, so you get the events inside the slice. ## Opening the result ### pandas ```python import pandas as pd signals = pd.read_csv("recording_csv/signals.csv") eeg = signals["EEG Fpz-Cz"] channels = pd.read_csv("recording_csv/channels.csv") print(channels[["column", "unit", "sampling_rate_hz"]]) ``` Pass `index_col="time_s"` to `read_csv` to get time as the index rather than as a column. ### R ```r signals <- read.csv("recording_csv/signals.csv", check.names = FALSE) eeg <- signals[["EEG Fpz-Cz"]] plot(signals$time_s, eeg, type = "l", xlab = "time (s)", ylab = "uV") ``` Without `check.names = FALSE`, R rewrites `EEG Fpz-Cz` into `EEG.Fpz.Cz` and the column names no longer match the ones in `channels.csv` or in the original file. ### Excel and Numbers Open `signals.csv` directly. It's plain UTF-8 CSV with a header row and needs no import wizard. The limit is the row count: spreadsheets stop at 1,048,576 rows including the header, which is about 68 minutes of a single 256 Hz channel. edf2csv warns you before writing when any output file would exceed that: ```text warning: At least one output file will have more than 1,048,576 rows, which is more than Excel or Numbers can open. Use --start and --duration to convert a section, or read the file with pandas or R. ``` `channels.csv`, `annotations.csv` and short slices open in a spreadsheet without trouble. Full-length signal files usually don't. Two things are worth knowing before you double-click the file, and this page used to mention neither. Excel on Windows reads a CSV with no byte order mark in the system code page rather than as UTF-8, so `µV` — one character, two bytes of UTF-8 — arrives as `µ` in the unit column and in any accented patient or channel text. [`--bom`](/docs/cli-reference#--bom) writes the mark that tells it otherwise: ```bash edf2csv recording.edf --bom ``` It is off by default because it is not free: `csv.reader` over a plain `open()` in Python, and `fs.readFileSync(path, 'utf8')` in Node, both hand back the first column name as `\ufefftime_s`, so a lookup of `time_s` misses. pandas strips it either way. Use it when the destination is Excel, leave it off when the destination is code. And if the conversion raised [`FORMULA_LABEL`](/docs/warnings-and-errors#formula_label), a channel's label or unit starts with `=`, `+` or `@`, which a spreadsheet runs as a formula rather than showing as text. Open that one through the import path instead — Data → From Text/CSV in Excel with the column set to Text — or read it with pandas or R, which evaluate nothing. ## Where to go next - [Output files](/docs/output-files) describes every column of every file the conversion writes, including the whole of `metadata.json`. - [CLI reference](/docs/cli-reference) lists every flag, the exit codes, and the `--json` summary for scripting. - [Mixed sampling rates](/docs/sampling-rates) explains why a mixed-rate recording becomes several files, and what other tools do instead. --- # CLI reference > Every edf2csv flag, its default and its behaviour, plus exit codes and the stdout versus stderr contract `edf2csv` converts EDF, EDF+, BDF and BDF+ recordings into directories of CSV files — one recording, several, or a folder of them in a single invocation. There's no configuration file and no environment variables — everything is on the command line. ## Synopsis ```bash edf2csv [more ...] [options] ``` At least one input is required, except with `--help` and `--version`. It can be a recording or a folder of them, and several can be given at once, so a glob does what a shell loop used to: ```bash edf2csv /data/recordings/*.edf --out /data/csv ``` ``` [1/3] night-01.edf Wrote /data/csv/night-01 signals.csv 8,640,000 rows ... [2/3] night-02.edf ... Converted 3 of 3 recordings. ``` With `--out` the named directory becomes the parent and each recording gets its own inside it, named after the file. Without `--out` each recording converts beside itself into `_csv`, exactly as it would have done alone. What `--out` means is decided by what you named, not by what was found. Name one recording and it is the output directory itself; name a folder — or several recordings — and it is a parent. So `edf2csv study --out csv` writes `csv/night-01/rec/` whether the study holds one night or fifty, and adding a second night never moves the first one's output. A recording that cannot be read is reported and the rest still convert — one unreadable file in a folder of five hundred is a reason to name that file, not to discard the work already done. The closing line says how many succeeded, and the exit code is non-zero if any failed. The same goes for anything the walk cannot look at: a sub-directory it may not list, or a link whose target is not there — a night linked to a drive that is not mounted, say. It is named on stderr and counts against the run, because the walk cannot know what was behind it and converting less than you asked for is not a success. Once each, however many ways it was reached, the same as the recordings: ``` error: study/night-02: could not be read, so whether it holds recordings is unknown. It was skipped. ``` A recording named more than once is converted once, however it was named — twice on the command line, or once directly and once inside a folder that was also given. A shell produces that by accident easily enough (`edf2csv *.edf recording.edf`), and it is not ambiguous. Which of the names its output is called after is decided by the names, not by the order they arrived in: a name the recording actually has beats a symbolic link pointing at it, and two links are settled by the one that sorts first. So `edf2csv data/one.edf data/alias.edf` and the same two swapped both write `out/one`, and a study copied to a machine whose filesystem enumerates the folder differently still produces the same directory names. How a path is *spelled* decides nothing. `study/night-01/rec.edf`, `./study/night-01/rec.edf` and the absolute form are one name, so adding a `./` or switching a script to absolute paths cannot move the output or turn a run that worked into a refusal. The same holds for a folder reached two ways. A study containing `aaa-real/` and `zzz-alias -> aaa-real` is walked through `aaa-real`, so the output keeps that name. Where several names lead to one folder, the shallowest wins, then the one that is not a link, then the first in sort order. Two *different* recordings that would land in the same directory are refused before anything is written. This happens with the common layout of one folder per night, where `n1/rec.edf` and `n2/rec.edf` would both resolve to `/rec`: ``` error: "n2/rec.edf" and "n1/rec.edf" would both be converted into "out/rec", so one would overwrite the other. Convert them separately, or rename one of them. ``` A folder is expanded to every `.edf` and `.bdf` inside it, at any depth, which is usually easier than getting a shell to do it. The extension is matched without regard to case, so `.EDF` and `.Bdf` are found too — clinical exports write both, and a shell glob does not: ```bash edf2csv /data/study --out ./converted --jobs auto ``` The layout is kept — a recording at `study/night-1/rec.edf` comes out at `converted/night-1/rec` — which is also what keeps recordings apart. One folder per night with the file always called `rec.edf` is a normal way to organise a study, and flattening those onto their file names would have every one of them claim `converted/rec`. Symbolic links are followed, both to recordings and to folders, since linking data into a working directory is a normal way to arrange it. A recording reachable two ways is converted once rather than twice, and a cycle of links terminates instead of running forever. Which of its names the output takes is decided by the names, never by the order you typed them. A recording is preferred over a link pointing at it; a recording named both directly and through a folder keeps the position the folder gives it, since that is what the folder promised and it is the name that does not collide with a sibling. Anything in the folder that is not a recording is skipped, and a folder holding none says so rather than converting nothing in silence: ``` error: No EDF or BDF recordings found in "/data/empty". ``` `--stdout` still takes a single recording, since one stream holds one recording's table. ## Flags at a glance | Long | Short | Argument | Default | Effect | | --- | --- | --- | --- | --- | | `--info` | `-i` | none | off | Describe the recording and estimate the output, convert nothing | | `--out` | `-o` | directory | `_csv` beside the input | Where the CSV files are written | | `--channels` | `-c` | comma-separated list | all signal channels | Convert only these channels | | `--start` | | time | start of the recording | First sample to include | | `--duration` | | time | to the end | How much to convert, measured from `--start` | | `--end` | | time | end of the recording | Offset to stop at, instead of `--duration` | | `--annotations-only` | | none | off | Write only the EDF+ event list, no signal data | | `--decimals` | | integer 0 to 20 | derived per channel | Force a fixed number of decimal places | | `--checksum` | | none | off | Record a SHA-256 of the input in `metadata.json` | | `--layout` | | `wide`, `long` | `wide` | `long` writes one file of `time_s,channel,value` | | `--gzip` | | none | off | Compress every CSV, writing `.csv.gz` files | | `--bom` | | none | off | Start each CSV with a UTF-8 byte order mark | | `--jobs` | `-j` | integer or `auto` | 1 | Convert this many recordings at once | | `--force` | `-f` | none | off | Write into an output directory that already exists | | `--quiet` | `-q` | none | off | Suppress the closing summary and the progress meter | | `--json` | | none | off | Print machine-readable JSON to stdout, for a conversion or for `--info` | | `--strict` | | none | off | Exit 1 if the recording raised any warning | | `--stdout` | | none | off | Write the signal CSV to stdout instead of a directory | | `--help` | `-h` | none | | Print usage to stdout and exit 0 | | `--version` | `-V` | none | | Print the version to stdout and exit 0 | Short options are single letters and the version flag is a capital `V`. Unknown flags are rejected; there's no pass-through. A short option's value follows the letter directly or comes as the next argument — `-o out`, `-oout` — and never with an `=`, which POSIX reads as part of the value. `-o=out` is refused rather than writing to a directory called `=out`, and `-q=1` is refused rather than being reported as an unknown option `-=`: ``` error: -o takes its value directly or as the next argument, so "-o=out" asks for a value of "=out". Write it as -o out, or as --out=out. error: -q is a switch and takes no value, but was written "-q=1". Write it on its own: -q ``` The long forms take an `=` as usual: `--out=out` is the same as `--out out`. A value that begins with a dash has to be written as one argument, or it reads as another flag. This comes up with a destination named `-nightly`, or a negative `--start` on a recording timed from before zero. The refusal says which form to use, and the two forms differ: a long option joins with `=`, a short one joins directly. ``` error: --out was given "-nightly", which begins with a dash and so reads as another flag rather than as its value. Write it as one argument instead: --out=-nightly ``` So `--out=-nightly` and `-o-nightly` both work; `-o=-nightly` does not — it makes the destination `=-nightly`. What `--info` prints is checked the same way a conversion's `--stdout` is: redirected into a filesystem with no room, it exits 1 and says so rather than leaving a short file behind and reporting success. A description is usually small, but a 900-channel recording's is 58 KB. Piping into `head` is unaffected — the check declines anything that is not a regular file. ## Input, output directory and overwriting An input can be a recording or a folder of them, and several can be given at once. A recording that cannot be read is a file error (exit 1); a folder holding none is a usage error (exit 2), since the command as written asked for nothing: ``` error: No EDF or BDF recordings found in "/data/empty". ``` A folder the process cannot open is a different answer and gets a different one. "None here" is something the run can state; "could not look" is not, so it says that instead, and exits 1 rather than 2 — the command was fine, the filesystem refused: ``` error: /data/locked: could not be read, so whether it holds recordings is unknown. It was skipped. error: Nothing could be converted. That path could not be read, so whether it holds recordings is unknown. ``` When some recordings *were* found alongside it, the closing line counts the unreadable paths beside the conversions, since how many recordings they held is the thing nobody knows: ``` Converted 1 of 1 recording; 1 path could not be read. ``` Anything that is not a directory is passed to the reader as given, so a missing path or a special file reports itself rather than being skipped. `-o, --out ` sets the destination. Without it, the output directory is the input file's name with its extension replaced by `_csv`, created next to the input: `/data/recordings/sleep-study.edf` becomes `/data/recordings/sleep-study_csv`. The directory is created if it doesn't exist, including missing parents. If the destination already exists, the conversion stops before writing anything: ``` error: "/data/csv/sleep-study" already exists. Pass --force to write into it, leaving whatever else it holds, or --out to choose a different directory. ``` `-f, --force` allows writing into an existing directory. It overwrites files of the same name; it doesn't empty the directory first. The refusal that offers it said otherwise until 0.8.83 — "Pass --force to overwrite it", with the dangling-link hint beside it saying "`--force` replaces a previous output directory" — so the one sentence a reader meets at the moment they decide whether to pass the flag contradicted both this paragraph and the flag list above. That matters when two runs produce different file names. Converting a mixed-rate recording writes `signals_256hz.csv` and `signals_1hz.csv`; converting a single-rate recording into the same directory afterwards writes `signals.csv` and leaves the two older files beside it, both looking current. Nothing is deleted automatically, but you're told: ``` warning: signals_128hz.csv, signals_1hz.csv, signals_256hz.csv are left over from an earlier conversion into this directory and were not rewritten. Delete them, or convert into a fresh directory, so the two runs do not get mixed up. ``` If the destination path exists but is a regular file rather than a directory, that's an error with its own message. `--force` means "replace my previous output", not "write into whatever this happens to be". ## -i, --info Prints a description of the recording to stdout and exits without writing anything. Because it writes nothing, it is not held to the rules about where output would land: two recordings whose names would collide, or one whose output directory would sit inside another's, are both described rather than refused. Those are usage errors for a conversion — and `--info --out` is how you would want to find out about them, so it says so, as a warning on stderr rather than a refusal: ``` warning: "n2/rec.edf" and "n1/rec.edf" would both be converted into "out/rec", so one would overwrite the other. Convert them separately, or rename one of them. ``` How much it reads depends on the file. A plain EDF is the header and nothing else. A continuous EDF+ is the header plus the annotation slot of at most the first sixteen records, which is what finds the offset the recording starts at — 0.4.9 made that offset the point samples are timed from, and a window placed against zero instead lands somewhere else. It reads all sixteen, rather than stopping at the first that answers: until 0.9.17 it returned the moment one record stated a time, so a recording contradicting its own `EDF+C` marker was reported by a conversion and by nothing here, and `--info --strict` — what this page recommends for screening a folder — exited 0 where converting exits 1. The bound is the same one; only the early exit went. A discontinuous EDF+ is the header plus every record's annotation slot, because that is the only place its record times are stored, and the span and the row estimate are wrong without them. All three return in milliseconds on any ordinary recording; only the last scales with record count. So `--info` sees an unreadable timekeeping entry only up to the record that answers the question — the search stops at the first record stating a start time, which on a well-formed recording is record 0 — and does not see an unreadable *event* later in a continuous file, because it never looks there. A file whose record 0 reads cleanly and whose record 1 does not is the case to know about: the conversion reports the unreadable entry and `--info` says nothing, even though record 1 is inside the sixteen. And if none of the sixteen states a start time — records that say nothing rather than records that cannot be read — `--info` reports the recording as beginning at zero while the conversion finds an origin further in and times every row from it, with no warning on either side. [Warnings and errors](/docs/warnings-and-errors#what---info-can-and-cant-tell-you) lists what that means code by code. ```bash edf2csv sleep-study.edf --info ``` ``` File sleep-study.edf Format EDF+ (continuous) Recorded 2002-03-02 23:10:00 Duration 8h 00m 0s (28,800 records of 1s) Size 18.7 MB Patient X X X X Recording Startdate 02-MAR-2002 X X X Channels 5 signals + 1 annotation channel # COLUMN LABEL UNIT RATE RANGE OUTPUT 0 EEG Fpz-Cz EEG Fpz-Cz uV 100 Hz -250 to 250 signals_100hz.csv 1 EEG Pz-Oz EEG Pz-Oz uV 100 Hz -250 to 250 signals_100hz.csv 2 EOG horizontal EOG horizontal uV 100 Hz -250 to 250 signals_100hz.csv 3 Resp oro-nasal Resp oro-nasal V 10 Hz -1 to 1 signals_10hz.csv 4 Temp rectal Temp rectal degC 1 Hz 34 to 40 signals_1hz.csv Sampling rates differ, so channels are written to 3 files, one per rate. No channel is resampled. Would write 3,196,800 rows, roughly 108 MB, and annotations.csv. ``` A recording with no signal channels at all — one holding only EDF+ events — has no table, and none is printed: the `Channels` line above says `0 signals` and the body below says what would be written. Only the column heads would have been left, which is not a table. Reading the table: - The `#` column is the channel's position in the file, counted over every channel including the annotation channel — which is why the data channels above stop at `#4` in a file with six channels: `#5` is `EDF Annotations`. A recording that stores its annotation channel in the middle makes the numbering skip instead. Those `#` values are what the `#N` form of `--channels` addresses. - `COLUMN` is the CSV column header the channel will get, and `LABEL` is the raw label from the header. They differ only when a label is duplicated or empty (see below). - A `Window` line appears under `Duration` when `--start`, `--end` or `--duration` narrows the run, giving the window on the recording's own clock and how many of its data records it touches — or, under `--annotations-only`, that events outside it are not exported, since that mode converts no data records at all and counting them was what 0.8.93 took out. Until 0.8.82 the report did not mention the window at all: the Duration line went on describing the whole file above a row estimate describing part of it, and the two `--info` runs differed by one number and nothing else. Every other flag that changes what gets written is already visible here — `--channels` as `(not selected)`, `--gzip` in the names, `--layout long` in the sentence under the table, `--annotations-only` in place of the estimate. - `OUTPUT` names the file the channel would land in, `(stdout)` when `--stdout` streams it and no file is written, `(not selected)` when `--channels` excludes it, `(no samples)` when the channel declares zero samples per data record and so has nothing to put in a file, or `(no signal data)` when the run writes no signal table at all, which is what `--annotations-only` asks for. The four are distinct on purpose: a channel named on `--channels` and carrying no samples reads `(no samples)`, because it was chosen and the file gives it nothing, and the `NO_SAMPLES` warning below the table says so; every channel of an `--annotations-only` run reads `(no signal data)`, because nothing about that run turned on which channels were named. Under `--stdout` the column read `signals.csv` until 0.8.31 — or `signals.csv.gz` with `--gzip`, a name that exists nowhere — for a run this page documents as creating no directory; the names here are the names a script may open, so a run that writes none has to say so. The two warnings the plan raises say it too since 0.9.9: an empty window reads "so the CSV on stdout carries its header and no data" rather than naming a signal file, and the row-count warning names the stream and advises reading it rather than redirecting it into a spreadsheet. - Under `--json` the estimate is an object of three fields: `rows`, `bytes`, and `exceeds_spreadsheet_limit`, a boolean that is `true` when any single output file would carry more rows than a spreadsheet can open. It is the same condition the `LARGE_OUTPUT` warning reports, in a form a script can branch on without matching the message; it stays `false` when no signal table is written, which is true of a set of no files. - The row and byte estimates honour `--channels`, `--start`, `--duration`, `--end`, `--decimals`, `--layout`, `--bom` and `--annotations-only`, so the figures describe the command you actually typed. Those eight are every flag that moves either figure: `--layout long` has its own row and byte arithmetic, and `--bom` is three bytes per file, which is the difference between an estimate that holds and one that reads under on a one-row conversion. `--gzip` is the flag that changes what lands on disk and does not move these numbers — see [`--gzip`](#--gzip) for why. - Five flags are accepted and ignored: `--checksum`, `--force`, `--out`, `--jobs` and `--quiet`. Each is about writing files, and `--info` writes none — not even the directory `--out` names. `--stdout` refuses the first three in as many words ("`--stdout` writes no files, and `--checksum` has nothing to act on") and this mode does not, on purpose: `--stdout` replaces the destination, so a flag about files contradicts the request, while `--info` is the same command with a word added to preview it. Refusing them would mean editing the command line before you could ask what it would do. With `--annotations-only` the signal channels read `(no signal data)` and no row estimate is printed at all — under `--json`, `estimate.rows` and `estimate.bytes` are `null` there rather than `0`, as they are for a recording holding only annotations — the estimate describes the signal tables, and that run writes none. See [`--info --annotations-only`](#--annotations-only). Up to 0.4.51 it did print `Would write 0 rows, roughly 0 B.`, which was true of the signal tables and false of the run; this sentence went on describing that until 0.5.76. - The record length in the `Duration` line is written in plain decimal, so it is a value `--start` and `--duration` accept — they refuse an exponent with `uses an unknown unit "e"`, which is why `repeating-fast.edf` reads `0.000000000000001s` rather than `1e-15s`. Past forty characters it switches to the shortest exact form instead: a record duration is eight characters of header, so `1e308` fits in one, and its plain expansion is 309 digits on a line whose other half has just said the total cannot be stated. Nothing is typable at that magnitude either way. Until 0.8.95 the line carried all 309 — and until 0.8.98 so did the `Duration` value beside it, which expanded a recording of 3e-308 seconds to 310 characters, the refusal naming a record duration that is not positive, and the hint saying how wide a window has to be to hold a sample. Every message that states a length of seconds out of a header takes the same rule now — including, since 0.9.7, the two that reach it through a different renderer: the `Duration` value where the recording is *long* rather than short, which decomposes into hours and minutes until 2^53 and then prints the seconds, so one data record of 1e308 seconds read as 309 digits; and the bounds in the window refusals, where the same recording made the sentence 1,302 characters. - Every size printed here — the `Size` line and the `roughly N MB` on the estimate — counts in powers of 1024 while writing `KB`, `MB`, `GB` and `TB`, which is the convention `ls -lh` and most file managers use and is not the one `ls -l` reports. A recording `ls -l` gives as 42,399,744 bytes reads `Size 40.4 MB`, and one gigabyte of CSV reads `954 MB`. `--json` gives `bytes` and `estimate.bytes` as exact byte counts and does no rounding at all, so a script should read those rather than parse the text — the same 8,388,608 the API page calls 8 MiB is `8 MB` here. - If the recording has a `Patient` or `Recording` identification field, it's echoed above the table. EDF headers commonly carry patient identifiers, so treat `--info` output as sensitive before pasting it into a ticket. - Header text is free text from the file, so control bytes in it are printed as their escape (`\x1b`, `\x0d`) rather than sent to the terminal. A header carrying ANSI sequences would otherwise be able to clear your screen or repaint the output. `channels.csv` and `metadata.json` still record the field verbatim. Warnings raised while parsing the header — mixed rates, a truncated file, a degenerate calibration — go to stderr, never into the table. ## -c, --channels Restricts the conversion to a subset of channels. Channels that are left out still appear in `channels.csv` with `converted` set to `no`, so the output documents the whole recording. The flag can be repeated, and each occurrence can hold a comma-separated list. These three invocations are identical: ```bash edf2csv recording.edf --channels "EEG Fpz-Cz,ECG" edf2csv recording.edf -c "EEG Fpz-Cz" -c ECG edf2csv recording.edf -c "EEG Fpz-Cz, ECG" ``` Terms are trimmed, so spaces after the commas are fine, and empty terms are dropped. Passing the flag with nothing usable in it is a usage error rather than a silent "convert everything": ``` error: --channels was given but lists no channel names. ``` ### Matching rules A term matches a channel when it equals that channel's **label**, compared case-insensitively, with no partial or prefix matching and no wildcards. `ecg` matches `ECG`; `EEG Fpz` matches nothing. Labels routinely contain spaces and punctuation, so quote them in the shell. Match against the label from the `LABEL` column of `--info`, not the `COLUMN` name. Where the two differ, the label is the one that works: in a file with two channels labelled `T8-P8`, the columns are named `T8-P8_ch0` and `T8-P8_ch1`, and passing one of those says so rather than treating it as a typo: ``` error: "T8-P8_ch1" is a column name, not a channel name: --channels matches the label, which for this channel is "T8-P8". Use "#1" to select just this one, or "T8-P8" for every channel sharing that label. ``` A label that merely looks like a column name is still a label, and wins: if a third channel really is called `T8_ch0`, `--channels "T8_ch0"` selects that channel and not the one whose column happens to be spelled the same way. The suffix rule cannot make a channel unreachable by its own name. A channel with no label at all has nothing to match, so `#` is the only way to ask for it, and the error says that too. So is a channel whose label contains a comma. The comma separates terms and is split on wherever it appears, so `--channels "EEG Fpz-Cz, ref"` asks for two channels rather than one and exits 2 on the first of them — a label that reads perfectly well and cannot be typed as a term. `channels.csv` and the CSV header both carry such a label in full, quoted; `#` is how you select it. The EDF+ annotation channel can't be selected. It isn't a signal, it's never a column in `signals.csv`, and asking for `EDF Annotations` by name is an unknown-channel error. Annotations are exported through `annotations.csv` instead, automatically. Selection order doesn't affect column order. Channels always appear in file order within their rate group, so `-c "ECG,EEG Fpz-Cz"` and `-c "EEG Fpz-Cz,ECG"` produce byte-identical output. ### Selecting by position with #N `#N` selects the channel at position `N`, using the same numbering as the `#` column of `--info`: ```bash edf2csv recording.edf --channels "#0,#3" ``` Use this to reach one specific channel when two share a label. If no channel sits at that position, the error lists the positions that do exist: ``` error: No channel at position #9. This file has signal channels at #0, #1, #2. ``` The listed positions are signal channels only, so an annotation channel's index isn't offered even though it consumes a number. `N` has to be written in plain digits. `#2` is a position; `#0x2`, `#2.0`, `#1e0`, `# 2` and a bare `#` are not, and each is refused rather than resolved: ``` error: "#0x2" is not a channel position: a position is #0, #1, #2 and so on. This file has signal channels at #0, #1, #2. ``` The point is that these used to be accepted. Anything that `Number()` could read became a position, so `#0x2` reached channel 2 through hexadecimal and a bare `#` became channel 0 — a mistyped term converted a different channel and exited 0 rather than saying anything. ### Duplicated labels EDF doesn't require labels to be unique, and real recordings break the assumption. Published scalp EEG collections routinely contain files with two separate channels both labelled `T8-P8`, and some carry a channel whose label is nothing but `-`. edf2csv handles this in two places. In the output, duplicated labels are disambiguated by appending the channel's position: `T8-P8_ch0` and `T8-P8_ch1`. The suffix is derived from the whole file, not from your selection, so a channel gets the same column name whether you converted all channels or just that one. A channel with an empty label becomes `signal_`. In `--channels`, a term matching several channels selects **all** of them and warns: ``` warning: "T8-P8" matches 2 channels (positions #0, #1); all of them were selected. Use --channels "#0" to pick just one. ``` Taking the first silently would drop data you asked for, and refusing outright would make the file unconvertible by label. To get one channel, use `#N`. ### Typos A term that matches nothing is an error rather than a quiet omission, since dropping a requested channel would produce a CSV missing data you asked for with nothing in the file recording that it happened. Close labels are offered as suggestions, ranked by edit distance. Three are named and the rest counted — `Did you mean "ECG1", "ECG2", "ECG3" and 2 more?` — except when there are exactly four, which are all named, since "and 1 more" is longer than the item it would be standing in for: ```bash edf2csv recording.edf --channels ECQ ``` ``` error: No channel named "ECQ". Did you mean "ECG"? Run with --info and no --channels to list the channels in this file. ``` Suggestions appear only when a label is close enough: within an edit distance of 2, or one third of the term's length for longer terms. A label that *contains* the term counts as close whatever its length, so `--channels EEG` on a recording holding `EEG Fpz-Cz` offers that channel — edit distance alone charges one edit per character the term leaves out, which ranks an abbreviation below an unrelated label of the same length as the term. A term with nothing similar in the file gets the bare error and the pointer to `--info`. ### The annotation channel `EDF Annotations` — `BDF Annotations` in BDF+ — is a label the file really carries, and the one channel name a reader of the EDF+ specification meets first. It holds event text rather than samples, so it has no column to select, and asking for it is refused. The refusal says which channel it is rather than denying the file has one: ``` error: "EDF Annotations" is this recording's annotation channel, not a signal: it holds event text rather than samples, so it has no column to select. Its events are already written to annotations.csv by any conversion of this file — pass --annotations-only for those and no signal data. ``` A term that is merely *near* the label gets the same answer, naming the spelling the file carries — `EDF Annotation`, `annotations`, and `EDF Annotations` on a BDF+ recording are how somebody who has read about EDF+ actually types it: ``` error: There is no channel named "annotations"; the nearest thing to it is this recording's annotation channel "EDF Annotations", which holds event text rather than samples, so it has no column to select. ``` Only when no signal label is close, so a "Did you mean" about a real column always wins, and by the same distance rule that suggestion uses. Its **position** gets the same answer since 0.8.79. The annotation channel keeps its index in the file — that is what `signal_index` in `channels.csv` is, "counting the annotation channel if present", and what `#N` addresses — so on a one-signal EDF+ recording the annotation channel is `#1`, and asking for it was refused with `No channel at position #1. This file has one signal channel, at #0.` The first sentence is false, and it is the answer reached by the reader who looked the position up in `channels.csv` rather than typing the label: ``` error: #1 is this recording's annotation channel ("EDF Annotations"), not a signal: it holds event text rather than samples, so it has no column to select. Its events are already written to annotations.csv by any conversion of this file — pass --annotations-only for those and no signal data. ``` A position no channel occupies still gets `No channel at position #N`, with the list of the ones that are signals under it — and a recording with no signal channels at all still gets `This file has no signal channels; it contains only annotations`, since there every position is the annotation channel and that sentence answers all of them at once. Up to 0.5.122 this was `No channel named "EDF Annotations". Run with --info to list the channels in this file` — untrue of the file, and pointing at a table that does not list the channel either, so following it brought the reader back to the same message. The near spellings went on getting it until 0.8.20. A recording that genuinely has no annotation channel still gets that message, because for that file it is true. ### Labels that literally start with # A channel whose label really is `#5` is reachable. When a term begins with `#`, edf2csv first checks whether any channel carries that exact label; if one does, the label wins and the positional interpretation isn't attempted. The positional form is a fallback, so no channel can be made unreachable by an unusual label. ### Interaction with --annotations-only `--annotations-only` skips signal output entirely, so the selection has nothing to act on — but the names are still checked. A term matching no channel is a usage error in this mode too, so a typo is reported rather than silently accepted. ## Time range: --start, --duration, --end `--start` sets the first offset to include, `--duration` says how much to take from there, and `--end` gives an absolute offset to stop at. All three are measured in seconds from the start of the recording, not wall-clock times of day. `--duration` and `--end` are mutually exclusive. Passing both is a usage error: ``` error: Use either --duration or --end, not both. ``` Every other combination is legal. `--start` alone runs from that offset to the end. `--duration` alone takes that much from the beginning. `--end` alone runs from the beginning to that offset. ### Accepted formats The same parser handles all three flags. Values are case-insensitive. | Form | Examples | Meaning | | --- | --- | --- | | Plain number | `90`, `90.5`, `0` | Seconds | | Negative | `-100`, `-1h30m`, `-00:01:40` | `--start` and `--end` only; before the origin | | Clock, with hours | `00:30:00`, `1:02:03.5` | `hh:mm:ss`, fractional seconds allowed | | Clock, without hours | `30:00` | `mm:ss` | | Units | `30s`, `5m`, `1h`, `250ms` | A number followed immediately by its unit | | Compound units | `1h30m`, `1h30m 15s` | Terms are summed | Recognised units are `h`, `hr`, `hrs`, `hour`, `hours`; `m`, `min`, `mins`, `minute`, `minutes`; `s`, `sec`, `secs`, `second`, `seconds`; and `ms` for milliseconds. Note that `m` is minutes and `ms` is milliseconds. Each unit may appear once. `1h30m20s` is fine and so is `1h30min`, but `1h1h` is rejected rather than summed to two hours — a repeated unit is a typo far more often than it is a request, and silently adding it up produces a window that is quietly the wrong length. Aliases count as the same unit, so `30m20min` is caught too. Two details of the unit form. A number must sit directly against its unit, with no space between them: `5min` is accepted and `5 min` isn't — and the refusal says so, rather than sending you back to check the unit. Space between separate terms is fine, so `1h30m 15s` works. And a number must lead with a digit: `1.5h` is accepted, `.5` isn't. `--start` and `--end` also take a leading `-`, for the recordings whose clock begins before zero. The sign applies to the whole value, so `-1h30m` is ninety minutes before the origin rather than sixty before and thirty after, and nothing may sit between the sign and the number. A leading `+` is refused, as it is for `--decimals` and `--jobs`. `--duration` is a length rather than a position and takes no sign at all: `--duration=-5` is `--duration "-5" is not a valid non-negative time`. Because a value beginning with a dash reads as another flag, these are written as one argument — `--start=-100`, not `--start -100`, which the tool says in as many words if you try. In the clock form, the minutes and seconds fields must be below 60, so `60:00` is rejected rather than read as an hour. The hours field is unbounded, which lets `100:00:00` express a long offset. Rejections say what went wrong: ``` error: --start "5x" uses an unknown unit "x". Use h, m, s or ms, or their long forms: hours, minutes, seconds. error: --start "1h banana" is not a time I understand. Try 30s, 5m, 1h30m, 00:30:00, or a plain number of seconds. error: --start "5 min" puts a space between a number and its unit. Write them together: 5min error: --start "+5s" begins with a plus. Write the number on its own: 5s error: --duration is empty. Try a value like 30s, 5m, or 00:30:00. error: --duration "-5" is not a valid non-negative time. error: --start "999…" is further from zero than a number of seconds can hold. ``` The last two are separate answers because they are separate problems. A value that overflows to infinity is refused whichever option it was given to, sign included; a value below zero is refused only for `--duration`, which is a length. Until 0.7.171 both came back as the sentence about signs, so `--start` — an option that takes a sign on purpose — answered a four-hundred-digit number by calling it negative. ### How the window is resolved The window is half-open: a sample at exactly the start offset is included, a sample at exactly the end offset isn't. A requested end past the end of the recording is clamped silently, so `--end 999h` on a two-hour file converts to the end. A start at or past the end of the recording is an error, because the result would be an empty file that looks like a successful conversion: ``` error: --start "4h" is at or past the end of this 2h 12m 30s recording. ``` An end that isn't after the start is likewise an error. A start at the recording's exact length counts as past the end, including when the length is a product that does not land on the number it prints as — 6003 records of 0.1s is 600.3000000000001, and `--start 600.3` on it is refused rather than converting nothing. Sample times in the output are absolute offsets into the recording, not relative to `--start`. Converting from `30m` produces a `time_s` column beginning at `1800`, so a windowed export lines up with the full one. `annotations.csv` is filtered by the same window: events whose onset falls inside it are kept, events outside it are dropped. The annotation channel is still read in full regardless of the window, because an event that occurs inside the window can be stored in a data record outside it. For discontinuous (EDF+D) recordings the window is resolved against real recording time, not against the amount of data present. A ten-second recording with a ninety-five-second gap in the middle ends at 105 seconds, and `--end 100s` means 100 seconds on that timeline. Every data record whose own span overlaps the window is read. A recording does not always begin at zero. The first data record's timekeeping annotation is what `time_s` is counted from, and a file whose first record says `+1000` writes its samples from `1000.000` — so `--start` and `--end` are read on that clock too. On such a file `--start 0 --end 1` converts nothing and says why, and a start past the end names both the recording's length and where it sits, rather than calling the end of its clock its length: ``` warning: No samples fall inside the requested window (0.000s to 1.000s), so the signal file holds its header and no data. This recording starts at 1000.000s, so the whole window sits before it. --start and --end are read on the recording's own clock, which --info prints as "Timed from". error: --start "5000" is at or past the end of this 3s recording, which runs from 1000s to 1003s. ``` `--info` says where it begins whenever that is not zero, in seconds so the number can be typed straight back in — including when it is below zero, which up to 0.5.120 it could not be, since every negative offset was refused as a time the parser did not understand and the whole clock of such a file was therefore unreachable: ``` Duration 3s (3 records of 1s) Timed from 1000.000s (first sample; --start and --end use this clock) ``` Under `--json` the same number is `first_sample_seconds`. `duration_seconds` and `time_span_seconds` are both lengths and neither says where that length sits. ```bash # Five minutes starting half an hour in. edf2csv sleep-study.edf --start 30m --duration 5m # The same window, written the other way. edf2csv sleep-study.edf --start 00:30:00 --end 00:35:00 ``` ## --annotations-only Writes the EDF+ event list and nothing else. The output directory gets `annotations.csv`, `channels.csv` and `metadata.json`, with no signal files. It's fast, since no data records are converted, and it's what you want when you need a scoring or event file out of a large recording without the samples. `--start`, `--duration` and `--end` still filter the events. `--channels` selects nothing here, since no signal table is written — but its names are still checked, as [described above](#interaction-with---annotations-only): a term matching no channel is a usage error in this mode too. It is the one flag that does nothing and can still stop the run, which matters most in a batch, where a term naming a channel only some of the recordings carry fails on the rest. The rest of the flags divide the same way, and it is worth saying which side each falls on, since nothing in a run reports a flag that did nothing. `--gzip` and `--bom` act on the two files that are written, so `annotations.csv.gz` and a byte order mark on `annotations.csv` are both what you get — and the warnings this mode rewrites, which send you to whichever of the two the answer is in, name it as this run writes it. Until 0.8.71 six of them said `channels.csv` and `annotations.csv` to a run writing neither, so a `--gzip --annotations-only` conversion could print both spellings of one file in the same warning list. Four warnings about the events themselves — a duration below zero, a duration that is not a number, and the two about what a description carries into a spreadsheet or a terminal — named `annotations.csv` under `--gzip` until 0.9.10, while the warning beside them about that same file being empty had named `annotations.csv.gz` since 0.8.48. The same rewrites apply one channel at a time since 0.9.19, for the channels `--channels` leaves out: a selection writes no cells for them either, so "its cells are left empty" and "every sample converts to the same value" were as untrue of an excluded channel as they are of a whole `--annotations-only` run — printed three lines under an OUTPUT column reading `(not selected)`, and enough to fail `--strict` over a channel the run does not touch. What the header says about the channel stays; only the clause about what the conversion makes of it moves, and `channels.csv` still records the calibration with `converted: no`. Since 0.9.39 they apply to the third way a run writes no cells and no rows, which is a window that selects no samples: that conversion writes the signal file and nothing in it, and the sentences above the warning saying so described its cells and its time column. The ones naming a channel's column are the exception in the wide layout, where an empty window still writes the header row and so still names every column. A long `signals.csv` is `time_s,channel,value` — a channel appears in it as a *value* in the channel column — so a long-layout run with no rows names no channel anywhere, and since 0.9.40 those three take the rewrite too, pointing at the `channels.csv` such a run does write. And since 0.9.44 one channel at a time again, for the last way a channel ends up with no cells: a rate group is what gets a file, so a window narrower than a slow channel's sample interval leaves its file holding a header while a faster channel in the same recording keeps rows, and the reason reads "for a window holding none of this channel's samples". The run-level sentences are untouched there, since the run does write rows. The set of warnings it rewrites grew again at 0.9.8: four hints about record timing — the two that answer a file whose record positions are not recorded or are contradicted by its own `EDF+C` marker, and the one naming a record whose timekeeping could not be read — all said the times are written as if the records were contiguous, over a run that writes no times at all. The messages stay, because what the header says about itself is still worth saying; it is the clause about what a conversion makes of it that was false. An event's onset is read off the event, not off the record it sits in, so `annotations.csv` is unaffected by any of them. `--checksum` records the input's SHA-256 in `metadata.json` as usual. `--decimals` and `--layout` have nothing to act on — both describe the signal table, and there is none — so they are accepted and do nothing rather than being refused: a batch converting a folder passes one set of flags for every recording in it, and neither can be wrong about a particular one. `--stdout` is the exception that is refused outright, because it writes no files at all and this mode is only files. `--info --annotations-only` names the files rather than estimating rows, since the estimate describes the signal tables and there are none: ``` Would write annotations.csv and channels.csv, and no signal data. How many events there are cannot be told from the header, and finding out means reading the annotation channel record by record. ``` That is true of a continuous recording, which `--info` reads only as far as the first record stating a start time. A discontinuous one has every record start read out of the annotation channel already, because that is where its record times live — so the events are counted and the line says so: ``` Would write annotations.csv with 3 events and channels.csv, and no signal data. ``` The count honours `--start`, `--duration` and `--end`, since those filter the events too. Until 0.4.51 this line read `Would write 0 rows, roughly 0 B.`, which was true of the signal tables and false of the run; until 0.7.61 it declined to count the events of a file whose events it had just finished reading. On a recording with no annotation channel, the conversion still succeeds and still writes `channels.csv` and `metadata.json`, with a warning: ``` warning: --annotations-only was requested but this recording has no annotation channel, so there are no events to export. Plain EDF files carry no annotations. Convert without --annotations-only to get the signals. ``` ## --decimals Takes a whole number from 0 to 20 and applies it to every signal column, replacing the per-channel precision edf2csv would otherwise derive. By default the precision is chosen per channel from its calibration. A channel's smallest expressible step is its physical range divided by its digital range, and the default is two places beyond that step, so two adjacent digital codes never round to the same text and no digits are written that carry no information. An ordinary microvolt EEG channel lands at 3 or 4 decimals; a channel calibrated in volts needs more, and a magnetometer in tesla more again, which is why the derived precision runs up to 100 — the most `toFixed` will print. `--decimals` itself stops at 20, which is a bound on a number you pick by hand rather than on what the format can express. Use `--decimals` when you want a uniform column width across channels, or when you're willing to trade precision for file size. Note what you give up: `--decimals 2` on a channel whose step is 0.0076 uV maps several genuinely different digital codes onto the same printed value. `--decimals` doesn't affect the `time_s` column, whose precision is derived from the sampling rate so that sample times are exact rather than rounded. It doesn't change `channels.csv` or `annotations.csv` either — those carry the header's own calibration figures and the file's own onsets, written as the file states them. `metadata.json` does record it: `conversion.rate_groups[].decimals` is the precision each channel was actually written at, which is how an archived conversion says whether its values were rounded by hand or derived. Out-of-range and non-integer values are usage errors, and so is anything that is not written in plain digits: `0x3`, `0b11`, `0o5`, `3e0` and `+3` are all numbers to JavaScript and none of them is a count of decimals anyone typed, so accepting them would mean converting at a precision the command does not say. Same rule as `--jobs` and `--channels "#N"`. An empty value is rejected explicitly rather than read as zero, since `--decimals ""` would otherwise round every physical value to a whole number: ``` error: --decimals must be a whole number between 0 and 20, got "21". error: --decimals needs a number, for example --decimals 3. ``` ## --jobs Converts several recordings at once. It only means anything for a batch — one recording is one conversion however many jobs are asked for. The value is a plain decimal integer of 1 or more, or `auto`: `0x10`, `1e3` and `+4` are refused rather than read as 16, 1000 and 4, the way `Number()` would have them. Space around the value is trimmed, as it is for `--start`, `--decimals` and `--layout`, and a refusal quotes the value as typed rather than as trimmed — up to 0.5.124 `--jobs " "` came back as `got ""`, which reads as no value at all when the value is the reason it failed. ```bash edf2csv /data/recordings/*.edf --out /data/csv --jobs 4 ``` Eight recordings of 19 MB, each converting to 168 MB of CSV, on an eight-core machine: | | wall clock | | --- | --- | | `--jobs 1` | 9.7 s | | `--jobs 2` | 5.6 s | | `--jobs 4` | 3.3 s | | `--jobs auto` | 3.8 s | `auto` is one job per core less one, so a long batch leaves the machine usable — counted as the cores this process may use rather than the ones the machine has, which are different numbers inside a container, under `taskset`, or anywhere a scheduler has pinned the job. It is not always the fastest setting: past a point the conversions compete for disk rather than CPU, which is why `auto` lands slightly behind `4` above. Start with `auto` and try a smaller number if the disk is the limit. Each conversion runs in its own process, because converting is almost entirely arithmetic and string building — 1.17 s of CPU for 1.24 s of wall clock — and Node runs that on a single thread. Doing it with concurrent promises inside one process was tried and gained 6%, which is the overlap in the file reads and nothing more. Output is held until a recording finishes and then released in one piece, so two conversions ending together cannot interleave one's summary with the other's warnings. Recordings therefore appear in the order they finish rather than the order given, and each is announced by the `[n/m]` line naming it. The converted files are byte-identical to a serial run. Interrupting a parallel batch stops every conversion in flight and names the directories left half-written: ``` interrupted (SIGINT): 3 conversions stopped part way through. Incomplete, and should not be used: out/r5, out/r6, out/r7 ``` A single conversion killed on its own — by the out-of-memory killer, by a scheduler's time limit, by `kill` — is reported the same way, since it also leaves a `signals.csv` that ends mid-row and opens like a whole one: ``` error: study/night-02.edf: stopped by SIGKILL before it finished. Incomplete, and should not be used: out/night-02 ``` The rest of the batch carries on, and the closing count and exit code report it as a failure. `--stdout` ignores it, since that path takes a single recording anyway. Interrupting one conversion — Ctrl-C on a single file rather than a batch — exits 130 and says which of three things happened, because they call for different responses. A conversion writes nothing for the first part of its run: under `--checksum` it hashes the input first, and an EDF+ file has its whole annotation channel scanned for record start times before the output directory is claimed, which on a long recording is seconds. Interrupted in that window there is nothing to distrust and nothing to delete: ``` interrupted (SIGINT): the conversion stopped part way through. Nothing was written: "night-02_csv" was never created. ``` Interrupted after the directory was claimed, the files in it stop mid-recording while still parsing as whole CSVs, which is the case worth warning about: ``` interrupted (SIGINT): the conversion stopped part way through. Files already written to "night-02_csv" are incomplete and should not be used. ``` And with `--force` over a directory that was already there, what is in it may be the previous run's output or this one's, and the message says so rather than guessing. Under `--stdout` there is no directory to name, so it warns about the stream instead. ## --layout How the samples are arranged in the CSV. `wide`, the default, or `long`. Anything else is a usage error, and space around the word is trimmed first — up to 0.5.124 it was not, so a value assembled with a trailing newline was refused for a character nobody typed. `wide` is a column per channel and a file per sampling rate, which is what every earlier version wrote and what most analysis expects. `long` is one file, three columns, one row per sample: ```bash edf2csv sleep-study.edf --out ./converted --layout long ``` ``` time_s,channel,value 0.000,EEG Fpz-Cz,0.061 0.000,EEG Pz-Oz,0.061 0.000,EOG horizontal,0.061 0.000,Resp oro-nasal,0.000244 0.000,Temp rectal,37.00073 0.010,EEG Fpz-Cz,1.648 ``` The reason it exists is the mixed-rate recording. A 100 Hz channel and a 1 Hz channel share no rows, so a wide table holding both means either ninety-nine empty cells in every hundred or inventing the samples to fill them — which is why `wide` splits them across files instead. In the long layout each sample carries its own time and nothing has to line up, so every rate goes in one table with nothing invented. Rows come out sorted by `time_s`, and within one time in the order the file declares its channels. Records are written in file order, and each record's samples all fall inside that record's span, which is what makes the whole file sorted. Two things a discontinuous recording is allowed to do break that, and edf2csv warns about both. Its records may be *stored* in a different order than they are *timed*: ``` warning: 2 data records start earlier than the record before them. Rows are written in file order, so the time column will not increase monotonically. ``` Or they may overlap: each starting after the one before it, but before that one *ends*, so a record's samples run past the start of the record after it. The starts increase, and the column steps backwards anyway — records of one second at 0 s and 0.25 s, two samples each, write 0.000, 0.500, 0.250, 0.750. ``` warning: 1 data record starts before the record before it ends, so its samples overlap in time. Rows are written in file order, and two records describe the same stretch of time. Where they overlap by more than one sample interval the time column steps backwards. ``` Not every overlap does step it back, which is why that hint says "where" and the one above it does not. What decides is whether a record begins after the previous record's *last sample*, and the last sample is one interval short of the record's end — so the same two records at 0 s and 0.7 s write 0.000, 0.500, 0.700, 1.200, every step forwards, while still describing 0.700 to 1.000 twice. Until 0.9.5 both counts shared the reversed one's sentence. Every sample is still written, once, in file order. Sort on `time_s` yourself if you need it and either warning appeared. That also makes it the one layout `--stdout` can stream for a mixed-rate recording, since there is only ever one table: ```bash edf2csv sleep-study.edf --stdout --layout long | head -20 ``` It is the shape most plotting and grouping libraries want directly: ```python import pandas as pd long = pd.read_csv('converted/signals.csv') long.groupby('channel')['value'].describe() ``` And it converts back to the wide form in one call, for the rates that share a time base: ```python wide = long.pivot(index='time_s', columns='channel', values='value') ``` It needs `time_s` and `channel` together to name one sample, which two recordings do not manage: one sampling faster than the time column can separate, and one whose data records overlap in time. Both raise `ValueError: Index contains duplicate entries` rather than dropping a sample quietly, and a conversion of either says so on the way past. The cost is size. A wide row carries one time for every channel; a long row repeats the time and the channel name on every sample, so the same recording is roughly two to three times larger. `--info` reports the long figure when `--layout long` is given, so the estimate always describes the command you typed. `--gzip` recovers most of the difference, since a repeated channel name is exactly what compression is good at. The `time_s` precision is shared across rates in the long layout — the finest any of them needs — because one column cannot mean three things. A recording mixing 256 Hz and 1 Hz writes both at eight decimal places. It is the finest rate *in the conversion*, not in the file, so `--channels` can change the width of the column. Converting a mixed-rate recording to `--layout long` and then converting one of its channels again gives the same instants at whatever precision that channel alone needs — `0.3333` where the full table wrote `0.33333`. The values are identical and so is their order; only the rounding of the shared column moves. In the wide layout this cannot happen, because each rate already has its own file and its own precision. ## --bom Starts each CSV with a UTF-8 byte order mark — the three bytes `EF BB BF`. Off by default. It exists for one reader. Excel on Windows opens a CSV with no mark in the system code page rather than UTF-8, so anything outside ASCII arrives wrong. `µV` is the common case: EDF headers are Latin-1 in practice and exporters write the micro sign as a single byte, which UTF-8 stores as two, and Excel shows as `µ`. Annotation text in French, German or Japanese goes the same way. The mark tells Excel the file is UTF-8 and the text comes through as written: ```bash edf2csv recording.edf --out ./converted --bom ``` It applies to `signals.csv`, `channels.csv` and `annotations.csv`, and to their `.csv.gz` forms — the mark goes inside the compressed stream, so decompressing gives a marked CSV. It applies to `--stdout` too, ahead of the header row and inside the compression when `--gzip` is given as well, since `edf2csv rec.edf --stdout --bom > signals.csv` is a way of producing the file Excel opens and the mark is the reason for asking. That is also the case to know about if the other end is a script rather than a spreadsheet: what arrives on the pipe begins `EF BB BF`. `metadata.json` never gets one: `JSON.parse` rejects a leading U+FEFF, so a mark there would break every reader of the file to help a program that will not open it anyway. The reason it is not the default is that the mark is not invisible to everything. pandas strips it on the way in, either engine. Python's own `csv` module over a plain `open()` does not, and neither does Node's `fs.readFileSync(path, 'utf8')` — the first column name comes back as `\ufefftime_s` and a lookup of `time_s` misses. Readers that want it gone ask for it by name: ```python import csv with open('converted/signals.csv', newline='', encoding='utf-8-sig') as handle: header = next(csv.reader(handle)) # ['time_s', 'EEG Fpz-Cz', ...] ``` So: `--bom` if the destination is Excel, plain if the destination is a script. ## --gzip Compresses every CSV on the way out. Each one gains a `.gz` extension: ``` recording_csv/ signals.csv.gz annotations.csv.gz channels.csv.gz metadata.json ``` CSV of sampled signal data compresses well — long runs of similar values in a fixed-width decimal format — so the saving is large. An hour of 100 Hz EEG that converts to 168 MB of CSV writes 26 MB with `--gzip`, about six times smaller, and the compression adds roughly a second per hundred megabytes. `metadata.json` is left as plain text. It is small, and it is the file you read to find out what the directory contains, which is awkward if reading it requires decompressing it first. `--info` reports the uncompressed size when `--gzip` is given. How well a recording compresses is a fact about its samples, not about its header, and the estimate is taken from the header without reading a data record — so the figure is what the CSV would be, and the file on disk is several times smaller. The ratio above is the one to apply by eye; the sweep that holds the estimate to within three times the truth measures the runs that write CSV for the same reason. The contents are byte-for-byte what an uncompressed run produces, so anything that reads gzip reads the output directly: ```bash edf2csv recording.edf --out ./converted --gzip gunzip -c ./converted/signals.csv.gz | head -5 ``` pandas takes it without any decompression step, inferring the codec from the extension: ```python import pandas as pd signals = pd.read_csv('converted/signals.csv.gz') ``` R's `read.csv` and `readr::read_csv` do the same. DuckDB reads it with `read_csv('converted/signals.csv.gz')`. `--gzip` combines with `--stdout` to compress the stream: ```bash edf2csv recording.edf --stdout --gzip > signals.csv.gz ``` The redirect is not optional there. Without one, stdout is the terminal, and a deflate stream is not something a terminal can display — it is a few hundred bytes of which a good fraction are control codes, some of which the terminal will act on. So that combination is refused when stdout is a terminal, and only then: a pipe and a regular file are both not terminals, so the command above and `| gunzip` are unaffected. ``` error: --stdout --gzip would write compressed bytes straight to the terminal. Redirect it to a file or a pipe: edf2csv sleep-study.edf --stdout --gzip > signals.csv.gz ``` The last line names the recording the refused command named, quoted if the shell would need it to be, so it can be pasted back. It used to read ``, which a shell treats as a redirect rather than as a blank to fill in. `--info` reports the estimate as the size **before** compression, since what compression achieves depends on the data: ``` Would write 10,000,000 rows, roughly 160.1 MB before compression. ``` ## --checksum Computes a SHA-256 of the input file and records it in `metadata.json` under `source.sha256`. Without the flag that field is `null`. This costs one extra full read of the input. It's useful when the CSV outlives the source and you need to establish later which file it came from. The rest of `source` — resolved path, byte size, modification time — is recorded either way. ## --stdout Writes the signal CSV to stdout and creates no directory, for feeding a conversion straight into something else: ```bash edf2csv recording.edf --stdout | duckdb -c "SELECT count(*) FROM read_csv('/dev/stdin')" ``` Only the samples are written — no `channels.csv`, `annotations.csv` or `metadata.json`, since a stream holds one table. Warnings whose advice is one of those files say so instead of naming it: the empty-channel warning ends "`--stdout` writes no channels.csv to describe it in", the discontinuity hint sends you to a directory for the onsets, and since 0.8.80 so does the duplicate-label hint, whose whole advice is looking a renamed column up in `channels.csv` by its `signal_index` — a renamed column that a `--stdout` run leaves in the header row with nothing to map it back. For the same reason it needs the recording to produce exactly one, and refuses a mixed-rate file rather than merging tables that have different row counts: ``` error: --stdout needs exactly one table, but this recording produces 3, one for each sampling rate its channels use (256 Hz, 128 Hz, 1 Hz). Narrow it to one rate with --channels, write --layout long to get them all in one table, or convert to a directory instead. ``` Two answers, and which one fits depends on what you want out of the stream. `--channels` narrows the selection until one rate is left, and gives you the wide table for that rate. [`--layout long`](#--layout) keeps every channel and puts them in one table by giving each sample its own row, which is the one arrangement a mixed-rate recording can take without inventing anything: ```bash edf2csv sleep-study.edf --stdout --layout long | head -20 ``` The row count still goes to stderr either way, so stdout carries nothing but CSV, and the progress meter is never drawn in this mode. `--stdout` and `--json` cannot be combined: both write to stdout, and together they would produce a document that is neither valid CSV nor valid JSON. Passing both is a usage error (exit 2): ``` error: --stdout and --json both write to stdout, so they cannot be combined. Use --stdout for the CSV, or --json for the summary. ``` Every refusal takes that shape — `error:` on the first line, the advice indented under it — so stderr can be grepped for `^error:` and find all of them. The two `--stdout` refusals printed flush left with no prefix until 0.5.79. With `--info` they combine: `--info` writes no CSV for the summary to collide with, and under `--json` the description *is* the JSON — so `edf2csv rec.edf --info --stdout --json` is how a script asks whether `--stdout` would work on a recording. It answers with a `STDOUT_UNSUPPORTED` warning when it would not. So are `--stdout --out`, `--stdout --checksum` (exit 2 since 0.5.5) and `--stdout --force` (since 0.5.100): `--force` means "write into a directory that already exists", and there is no directory. `--jobs` is not refused — a job count is a property of the run rather than a request about this file's output, and `--stdout` converts one recording whatever it is set to. Both were accepted and dropped in silence before that. `--out` named a directory that was never created, so a run that wrote nowhere looked like it had written somewhere; `--checksum` computed a SHA-256 of the input — a second full pass over the file, before the first record is read — and then discarded it, since the only file it is ever written to is the `metadata.json` that `--stdout` does not write. A folder is refused too, even one holding a single recording, because what a folder holds is not known until it is walked. The message names the recording inside it so you can run that instead. And a recording with no signal table at all — one holding only EDF+ annotations, or a `--channels` selection that leaves nothing carrying samples — is refused rather than streamed as an empty result: ``` error: --stdout has no signal data to write: this recording has no signal channels, only EDF+ annotations. Convert to a directory to get its annotations.csv, or drop --stdout. ``` With `--gzip` the hint names `annotations.csv.gz`, which is what the conversion it describes would write; it named the uncompressed file whatever else was on the command line until 0.8.70. Up to 0.5.14 the wide layout answered that with "--stdout needs exactly one table, but this recording produces 0, one for each sampling rate its channels use ()" and pointed at `--layout long`, which wrote zero bytes, no header row, and exited 0. ### Redirecting to a file that will not fit When stdout is redirected to a regular file, `edf2csv` checks at the end that the descriptor grew by as many bytes as it was handed, and fails if it did not: ``` error: Writing to stdout failed: 150,904 of 2,063,736 bytes did not reach the destination, which stopped accepting them part way through. What is there ends mid-row and should not be used. The destination is almost certainly out of space — a short write is how a filesystem reports filling up mid-write, and nothing after it raised an error because there was nothing after it. ``` A reader that stops reading is a different thing, and gets a different line. `edf2csv recording.edf --stdout | head -1` is an ordinary thing to type and not a failure, but it is not a conversion either, so it does not get a conversion's summary: ``` Stopped: the reader closed the pipe after 52,507 of 102,400 rows had been written. The recording was not converted in full. ``` Up to 0.5.11 that read "Wrote 52,507 rows to stdout" — a number that is neither the recording's 102,400 nor the one row `head` took, but however many had been formatted before the closed pipe was noticed. How many reached the reader is not knowable from this side; that it stopped early is. The check exists because this is the one path that has no second file to trip over. `write` returns a short count rather than an error when the filesystem fills partway through a single call, and only the *next* write raises `ENOSPC` — `--out` always has a next write, since `channels.csv` and `metadata.json` come after the samples. Until 0.4.39 a `--stdout` conversion that lost its tail this way exited 0 and announced the full row count. It applies to a regular file only. A pipe, a terminal or a socket has no size to compare, and cannot lose a write this way without reporting it. Appending with `>>` is fine: the starting size is taken before anything is written. ## -q, --quiet Suppresses the closing summary and the progress meter. It doesn't suppress warnings or errors: a conversion that raises a warning about mixed sampling rates or a truncated file still says so on stderr under `--quiet`, because those describe your data rather than the tool's own status. A clean conversion under `--quiet` prints nothing at all and exits 0. In a batch it also suppresses the `[n/m] ` header, which is what pairs each warning with the recording that raised it — so under `--quiet` the warnings carry that name themselves. Without `--quiet` they do not, because the header above them already says it. This holds under `--jobs` too, where it matters most: conversions finish in whatever order they finish, so there is no position to infer the attribution from. The progress meter is separate from the summary. It's drawn only when `--quiet` is off, `--json` is off, and stderr is a terminal. In a script, in a pipeline, or under `nohup`, it never appears, so log files don't fill with carriage returns. It updates at most ten times a second and erases itself when the conversion finishes. ## --json Prints a summary object to stdout as JSON and suppresses the human-readable summary. Warnings that would otherwise go to stderr are carried inside the object instead, so with `--json` the whole result of a successful run is on stdout and stderr stays empty. Naming one recording gives one indented document. Naming a folder, or several recordings, gives [JSON Lines](https://jsonlines.org) instead — one compact object per line, written as each recording finishes rather than held until the run ends, so a batch of five hundred can be consumed while it is still running: ```bash edf2csv ./study --out ./converted --json | jq -r 'select(.warnings != []) | .output_dir' ``` `jq` reads that stream a record at a time. `json.load` does not: use `json.loads` per line, or `pandas.read_json(path, lines=True)`. Which of the two shapes you get is decided by what you named and never by what was found there, so a folder that gains a recording does not change the shape of the output. Here's a complete run over a short three-second, three-channel recording with an annotation channel: ```bash edf2csv recording.edf --out ./converted --json ``` ```json { "tool": { "name": "edf2csv", "version": "..." }, "output_dir": "./converted", "files": [ { "name": "signals_256hz.csv", "rows": 768 }, { "name": "signals_128hz.csv", "rows": 384 }, { "name": "signals_1hz.csv", "rows": 3 }, { "name": "annotations.csv", "rows": 12 }, { "name": "channels.csv", "rows": 3 } ], "annotations": 12, "duration_seconds": 3, "records": 3, "elapsed_ms": 38, "warnings": [ { "code": "MIXED_SAMPLING_RATES", "severity": "warning", "message": "Channels use 3 different sampling rates (256 Hz, 128 Hz, 1 Hz)." } ] } ``` Field by field: | Field | Meaning | | --- | --- | | `tool` | The name and version that produced this record. The same object `metadata.json` carries, so a piped or logged result says which release wrote it | | `output_dir` | The directory that was written, exactly as it will be found on disk | | `files` | Every CSV written, in the order it was produced, with its data-row count excluding the header line. `metadata.json` isn't listed | | `annotations` | Number of events written to `annotations.csv`, after time-window filtering. `0` when the recording has no annotation channel | | `duration_seconds` | Duration of the whole recording, not of the converted window. `null` when it is not a number JSON can hold: `data_records * record_duration_seconds` overflows a double on a header stating both at their extremes, and the text form prints `Duration unknown` for the same reason | | `records` | Number of data records the file actually contains, which can differ from the count its header declares | | `elapsed_ms` | Wall-clock time for the conversion | | `warnings` | One entry per diagnostic, each with a stable `code`, a `severity` — always `"warning"`, the only one this raises — and a human-readable `message`. Empty array when there's nothing to report | Those three fields are all of it. The **hint** — the indented second line under every warning on the terminal, and the part that says what to do about it — is not carried in the JSON, and neither is it in `metadata.json`'s `notes`. It is advice rather than data, and it varies with the run: the same code prints a different hint depending on whether `--checksum` was given, whether the layout is `long`, or which of two causes raised it. Match on `code` and look the remedy up in [warnings and errors](/docs/warnings-and-errors), where every code has its own section. The `code` values are stable identifiers meant for programmatic checks: `MIXED_SAMPLING_RATES`, `DISCONTINUOUS`, `RECORD_COUNT_MISMATCH`, `RECORD_COUNT_UNKNOWN`, `TRAILING_BYTES`, `DUPLICATE_LABEL`, `EMPTY_LABEL`, `LARGE_OUTPUT`, `STALE_OUTPUT`, `ANNOTATION_DECODE_FAILED`, `DEGENERATE_DIGITAL_RANGE`, `DEGENERATE_PHYSICAL_RANGE`, `UNUSABLE_PHYSICAL_RANGE`, `INVERTED_PHYSICAL_RANGE`, `COMMA_DECIMAL`, `NO_ANNOTATIONS`, `NO_SIGNAL_CHANNELS`, `NO_SAMPLES`, `INPUT_CHANGED`, `EMPTY_WINDOW`, `NONPRINTABLE_LABEL`, `FORMULA_LABEL`, `TIME_RESOLUTION`, `VALUE_RESOLUTION`, `EMPTY_RATE_WINDOW`, `STDOUT_UNSUPPORTED`, `START_TIME_UNREADABLE`, `LEAP_SECOND_START`, `START_DATE_MISMATCH`, `MISSING_EDF_PLUS_MARKER` and `HEADER_BYTES_MISMATCH`. Match on `code`, not on `message`. `--json` applies to both. On a conversion it prints the summary object above; with `--info` it prints the recording's description as JSON instead of the table. That is a different document with different fields — it describes a recording rather than a run — and it carries: | Field | Meaning | | --- | --- | | `tool` | The name and version that produced this record, as in the summary above and in `metadata.json` | | `path`, `bytes` | The recording as given, and its size on disk | | `format` | `EDF`, `EDF+ (continuous)`, `BDF+ (discontinuous)` and so on | | `start_datetime_local` | Zone-less `YYYY-MM-DDTHH:MM:SS`, or `null` when the header's date and time cannot be read | | `start_date_raw`, `start_time_raw` | Those two header fields exactly as written, readable or not | | `patient_id`, `recording_id` | The two identification fields. Often carry patient identifiers — treat as sensitive | | `data_records` | Records the file actually holds | | `data_records_declared` | What the header claims, which can differ; `-1` means the writer didn't know | | `record_duration_seconds` | Seconds per data record, possibly fractional | | `duration_seconds` | `data_records * record_duration_seconds` — how much signal exists. `null` when that product overflows a double, which JSON has no value for; the text form prints `Duration unknown` | | `time_span_seconds` | How long the recording covers, which exceeds the above by the length of any gaps. `null` on the same overflow, and for the same reason | | `first_sample_seconds` | Where `time_s` begins, and the clock `--start` and `--end` are read against. Usually 0 | | `start_seconds`, `end_seconds` | The window this run would convert, on that same clock. The whole recording when neither `--start` nor `--end` nor `--duration` was given. `end_seconds` is `null` on a recording whose end overflows a double — a header may state a record duration of `1e308` in five characters — for the same reason `duration_seconds` and `time_span_seconds` are, and the text report writes `to the end` there | | `whole_recording` | `false` when a window was asked for. The same three fields `metadata.json` records under `conversion`, so a survey and an archived conversion read the same way | | `annotation_channels` | How many `EDF Annotations` channels the file declares | | `annotations` | How many events a conversion would write, under whatever `--start`, `--end` or `--duration` was given. `0` when the recording has no annotation channel, which the header settles without reading anything — plain EDF and plain BDF are always this. `null` when `--info` has not counted them: a discontinuous file has its whole annotation channel read, because that is where its record times are, and a continuous one is read only as far as its origin. The text form says the same two things in words | | `channels` | One object per signal channel, with `signal_index`, `column`, `label`, `unit`, the rates and bounds, and `output_file` (`null` when it wouldn't be converted, and `-` under `--stdout`, where it would be converted to a stream and no file is written). `sampling_rate_hz` is `samples_per_record / record_duration_seconds`, and is `null` when that is not a number JSON can hold — a record duration too small to divide into makes it infinite, which is the same overflow the time fields above take. The text table prints `Infinity Hz` there and `channels.csv` writes `Infinity`, so this is the one document of the three that cannot say it | | `estimate` | `rows`, `bytes` and `exceeds_spreadsheet_limit`, as above. `rows` and `bytes` are `null` when no signal table would be written — under `--annotations-only`, or on a recording that has no signal channels — since there is nothing to count. The text output says the same thing in words on that line. `exceeds_spreadsheet_limit` is `false`, which no file exceeding it can make true. One case reports numbers for a run that writes nothing: a `--stdout` run this recording refuses. The figures are what converting into a directory would write — which is what the text form has said in words since 0.9.34, "That conversion would write 1,155 rows, roughly 22.2 KB; `--stdout` writes none of them" — and `STDOUT_UNSUPPORTED` in `warnings` is how a script reading this document knows the stream will not happen. `null` would throw the figures away rather than qualify them | | `warnings` | Same shape as the conversion summary's | Field names match `metadata.json` for everything describing the recording — `data_records`, `record_duration_seconds`, `patient_id`, `start_datetime_local` and the rest — so a survey and an archived conversion can be read by the same code, and the channel objects use the column names of `channels.csv`. Three fields carry the same value under a different name, and all three are about the run rather than the recording: the summary's `records` is `metadata.json`'s `recording.data_records`, its `annotations` is `conversion.annotations_written`, and the `warnings` both documents here carry is `notes` there. Two of those three hold exactly; `notes` is the same list [minus `STALE_OUTPUT`](/docs/warnings-and-errors#stale_output), which is a fact about the destination at the moment of the run rather than about the conversion, and so is not one an archived conversion should keep asserting. It is the one warning a script reading `metadata.json` will never see and `--json` will. Text that came out of the filesystem or the header is escaped as `\uXXXX` where it would drive or reorder a terminal — the control bytes JSON requires escaping, and the bidirectional overrides it does not — so piping a document into `jq`, or running `cat` over a `metadata.json`, cannot repaint the screen. `JSON.parse` gives back the identical string either way, so no consumer sees a different value. In both cases warnings travel inside the document and stderr stays empty — with one exception, and only one: `--strict` announces its verdict there, since a run that exits 1 having written every file it meant to is not something the document says on its own. Nothing else reaches stderr in this mode, `--quiet` does not change it, and a script that needs stderr genuinely empty can drop `--strict` and branch on `warnings` instead, which is the same advice the exit codes above give for telling the two kinds of 1 apart. On failure, nothing is printed to stdout for that recording, so a parse failure and a non-zero exit code always coincide. Over a folder, both are JSON Lines: one object per recording, and a recording that failed contributes no line. To fail a batch job on any warning, use `--strict`: ```bash edf2csv recording.edf --out ./converted --strict ``` `--json` is still the way to react to a *particular* warning rather than to any of them: ```bash edf2csv recording.edf --out ./converted --json > result.json || exit 1 if jq -e '[.warnings[].code] | index("RECORD_COUNT_MISMATCH")' result.json >/dev/null; then echo "recording is incomplete" >&2 exit 1 fi ``` ## -h, --help and -V, --version `-h, --help` prints the usage text to stdout and exits 0. `-V, --version` prints the version on its own line and exits 0. Both are handled before the inputs are looked at, so `edf2csv --help` works with no input file and `edf2csv --version` works even alongside an invalid one. Not before the flags are parsed, though: `edf2csv --help --bogus` is still exit 2 for the unknown flag. ## Exit codes | Code | Meaning | | --- | --- | | `0` | Success. The requested output was written, or `--info` or `--help` or `--version` printed | | `1` | The file or the destination is the problem — or `--strict` was given and the recording raised a warning, where neither is | | `2` | The command line is the problem | | `130` / `143` | Interrupted by SIGINT or SIGTERM part way through | **Exit 2** covers anything decided before touching data: - An unrecognised flag, a flag missing its argument, or a value where none is expected. The message is followed by `Run edf2csv --help to see the options.` - No input file at all. (Several are fine: that is a batch.) - An unparseable `--start`, `--duration` or `--end`, and passing `--duration` together with `--end`. - A time window that can't apply: a start at or past the end of the recording, or an end at or before the start. - A `--channels` term that matches no channel, a `#N` position that doesn't exist, `--channels` given with an empty list, or a term naming the annotation channel, which holds text rather than samples. - A value the flag cannot act on: a `--decimals` that's empty, not an integer, or outside 0 to 20; a `--jobs` that is not a plain decimal integer of 1 or more nor `auto`; a `--layout` that is not `wide` or `long`; an `--out` that is empty. - Two recordings in a batch whose output would land in one place: the same directory, because their names collide once the folder structure is dropped, or one directory sitting inside another. Both are refused before anything is written, since converting them in turn would leave one recording's data under the other's name. - A folder holding no recordings. "None here" is something the run can state; a folder it could not open is exit 1 instead, because "could not look" is not. - `--stdout` with nothing to write to it: given together with `--annotations-only`, or on a recording whose channels use more than one sampling rate in the default wide layout, which would produce more than one table. `--layout long` produces one table whatever the rates are, so it is accepted. - `--stdout` combined with something it contradicts: `--json`, which writes to stdout too; `--out`, `--checksum` or `--force`, which act on files it does not write; a folder, or more than one recording, since a stream is one table out of one file. `--jobs` is not refused — a job count is a property of the run rather than a request about this file's output. - `--stdout --gzip` with no redirect, which would put a deflate stream on the terminal. Refused only when stdout is actually a terminal; into a file or a pipe it is the documented way to compress the stream. The last three categories require reading the file's header first, so exit 2 doesn't mean the file was never opened. It means the command as written can't be carried out. **Exit 1** covers everything else that stops the run: - The input can't be read: it doesn't exist, permission is denied, or it isn't a regular file. (A directory is not in this list: a directory is expanded to the recordings inside it. A folder holding none is exit 2.) - The file isn't usable as EDF: smaller than a 256-byte header, a header field that isn't a number, zero or negative signal count, a non-positive record duration, no complete data record, or no channel carrying any samples. - The file changes size mid-read, which happens when a recording is still being written. - The output directory already exists and `--force` wasn't given, or the destination path is a regular file, or it can't be created. - A write fails partway through, for example because the disk fills. The message says explicitly that the files written so far are incomplete and must not be used. Warnings never change the exit code by default. A conversion that reports a truncated recording, mixed sampling rates or a discontinuous file still exits 0, because the output it produced is correct and complete for the data that was there. Pass `--strict` to turn any warning into exit 1: ```bash edf2csv recording.edf --strict || echo "check the warnings before using this" ``` The output is still written. A warning describes the recording rather than a failure to convert it, so discarding the work would be the wrong response — the exit code is the signal, and the files are there to inspect. Either way the verdict is stated rather than left to the exit code: `--strict: 1 warning raised, so this run is reported as a failure.` `--strict` works with `--info` too, which makes it a cheap way to screen a directory for recordings that need a closer look before anyone converts them — and it says the same thing there, since exit 1 out of `--info` otherwise means the recording could not be read, and a screening script has to be able to tell those apart. Errors are printed as a single `error:` line plus an indented hint saying what to do about it. Every one of them carries one, and a test holds that: it was optional until 0.7.260, and the six that went without were not the obscure six. Node stack traces are never printed for any of the conditions above. ## stdout and stderr **stdout carries the result you asked for; stderr carries everything else.** | Stream | Contents | | --- | --- | | stdout | The `--info` table, the `--json` summary, the `--help` usage text, the `--version` string | | stderr | Warnings, the progress meter, the closing "Wrote ..." summary, all error messages | That's why a normal conversion prints nothing to stdout. The result of a conversion is a directory of files rather than text, so there's nothing to put there. The summary goes to stderr: ``` Wrote /data/csv/sleep-study signals_256hz.csv 7,564,800 rows signals_128hz.csv 3,782,400 rows signals_1hz.csv 29,550 rows annotations.csv 12 rows channels.csv 3 rows Done in 2.3s. ``` The split keeps stdout parseable. You can pipe `--info` or `--json` straight into another program without warnings landing in the middle of it, and still see the warnings on your terminal: ```bash # The channel table goes into the file; the mixed-rate warning still reaches the terminal. edf2csv sleep-study.edf --info > channels.txt # Feed the summary to jq while warnings stay visible. edf2csv sleep-study.edf --json | jq -r '.files[] | "\(.name)\t\(.rows)"' ``` Output is plain text with no colour codes and no terminal escapes, in both streams, so redirecting to a file or a log gives exactly what appeared on screen. The one exception is the progress meter, which uses carriage returns and only draws when stderr is an interactive terminal. Closing stdout early isn't treated as a failure. `edf2csv recording.edf --info | head -5` exits 0 rather than reporting a broken pipe, which is what a shell pipeline expects. --- # 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. ```bash 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.csv` is 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.csv` to 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.csv` is replaced by one `signals_hz.csv` per rate. See [one file per sampling rate](#one-file-per-sampling-rate). - `annotations.csv` is 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.csv` and `metadata.json` are 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_`, for example `signal_4`. An `EMPTY_LABEL` warning is raised. - **Duplicated label.** When two or more channels share a label, every one of them gets a `_ch` suffix naming its position in the file. Two channels both labelled `T8-P8` at positions 0 and 1 become `T8-P8_ch0` and `T8-P8_ch1`. This happens in real clinical archives, and position is the only thing that reliably tells the channels apart. A `DUPLICATE_LABEL` warning 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 labelled `time_s` would give the file two columns of that name. It takes `time_s_ch` instead, and a `DUPLICATE_LABEL` warning 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.061` and `15.324` line up and a column never mixes `1e-5` notation 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 ` 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: ```bash 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_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`](/docs/cli-reference#--layout) 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`](/docs/warnings-and-errors#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`](/docs/warnings-and-errors#formula_label), and one carrying a control byte or a bidirectional override raises [`NONPRINTABLE_LABEL`](/docs/warnings-and-errors#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. ```json { "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 - `format` is one of `EDF`, `BDF`, or `EDF+`/`BDF+` with `(continuous)` or `(discontinuous)`. - `version` is the header's version field: `0` for EDF, `BIOSEMI` for BDF. - `patient_id` and `recording_id` are 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_local` is the recording start as a zone-less wall clock, resolved from the header's date and time fields. It's `null` when those fields are unusable, which is not rare. `start_date_raw` and `start_time_raw` preserve the original `dd.mm.yy` and `hh.mm.ss` text either way, so nothing is lost to the interpretation. - `data_records` is how many complete data records the file actually contains, derived from its size. `data_records_declared` is what the header claims, and is `-1` when the header doesn't say, which the specification permits for a recording still in progress. When the two disagree, the real count wins and a `RECORD_COUNT_MISMATCH` note appears; a truncated file is the usual cause. - `duration_seconds` is `data_records * record_duration_seconds`, so for a discontinuous recording it's the amount of data, not the span of time the recording covers. - `signal_count` counts every channel in the header, annotation channels included. `annotation_channels` says how many of those were annotation channels. ### conversion: what this run did - `converted_at` is when this run finished, as an ISO 8601 instant in UTC. - `start_seconds` and `end_seconds` are the resolved time window, in seconds from the start of the recording, half-open. `end_seconds` is `null` when 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 is `recording.duration_seconds` on the same file, where `--info` prints `Duration 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 999h` on a two-second file comes back as `0` and `2`, and their difference is always a span the conversion actually covered. `whole_recording` is `true` when the window covers everything, which saves a script from comparing floats. - `whole_recording` is 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 is `records × 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.3` on such a file was recorded as partial while writing every sample. - `records_converted` is the half-open range of data record indexes the converted window covers, `[first, last)`. Under `--annotations-only` no signal records are read at all and this still describes the window; the annotation channel is read in full whatever it says. - `annotations_written` is the number of rows in `annotations.csv`, excluding its header. - `files` lists 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.json` describes the run and isn't listed among the files the run produced. - `layout` is `"wide"` or `"long"`, matching [`--layout`](/docs/cli-reference#--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. - `bom` is `true` when [`--bom`](/docs/cli-reference#--bom) was given, so every CSV in this directory begins with a UTF-8 byte order mark. Recorded for the same reason `layout` is: nothing else here shows it. `--gzip` names itself in `files`, 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 own `csv.reader` over a plain `open()` does not, nor does `fs.readFileSync(path, 'utf8')`, so the first column name comes back as `\ufefftime_s` and a lookup of `time_s` misses. Readers that want it gone ask for `utf-8-sig`. - `rate_groups` records 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. Its `sampling_rate_hz` is `null` when the rate is not a number JSON can hold, for the reason `duration_seconds` above is; `channels.csv` writes `Infinity` in 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 its `channels` are that file's columns, in order, after `time_s`. In the long layout every entry names the one shared table, whose columns are `time_s,channel,value`, and its `channels` are values appearing in that table's `channel` column rather than columns of it — so a mixed-rate recording produces three entries all naming `signals.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. --- # Mixed sampling rates > Why edf2csv writes one CSV per sampling rate instead of resampling, and how to work with the files it produces ## EDF files routinely mix rates EDF doesn't store one sampling rate for the whole recording. It stores a record duration in the file header, and then, for every channel separately, how many samples that channel contributes to each data record. A channel's rate is those two numbers divided: ``` sampling rate (Hz) = samples per data record / record duration in seconds ``` Because the count is per channel, one file can hold channels at very different rates, and real recordings do. A polysomnography montage might carry EEG at 256 Hz, EOG at 100 Hz, respiratory effort at 10 Hz and a rectal thermistor at 1 Hz, all in the same file. That's the format working as designed: each sensor is sampled at a rate suited to it. ## One wide CSV can't hold them without inventing samples A CSV table has one row per time value and one column per channel. In a single table, the fastest channel decides how many rows there are. Take three seconds of recording with EEG at 256 Hz and temperature at 1 Hz. The EEG contributes 768 samples and the temperature contributes 3. A single table with 768 rows has 768 cells in the temperature column and only 3 real numbers to put in them. The remaining 765 can be filled three ways: 1. **Repeat the last value.** The column then shows 768 readings, 765 of which the thermistor never produced. Anything that counts samples, estimates a spectrum, or computes a variance is working on fabricated data. 2. **Interpolate between readings.** Same problem, except the invented values look smoother and more plausible than the real ones. 3. **Leave the cells blank.** This is accurate, but it produces a column that's more than 99% empty, and many readers treat blanks as zero or drop the rows. Interpolation itself isn't the problem — it's often exactly what you want. The problem is that once the numbers are in a CSV they're indistinguishable from measurements. Nothing in the file records which values came off a sensor and which came out of an algorithm. There is a fourth way, and it is the one that gets you a single file honestly: stop insisting the table be wide. It has its own section below. ## What MNE does Load a three-second file with one 256 Hz channel, one 128 Hz channel and one 1 Hz channel using `mne.io.read_raw_edf`, and every channel reports 768 samples. The 3 genuine temperature readings become 768 values, upsampled to the fastest channel in the file. No warning is printed. That's a reasonable choice for MNE. MNE is an analysis library built around `Raw`, a single 2D array of shape `(n_channels, n_times)` with one shared time axis. Filtering, epoching, ICA, source localisation and the plotting routines all assume that array, and a ragged structure would break them. Given that design, expanding the slow channels is the only option, and for MNE's purposes it rarely matters. It's a different question for a converter, whose job is to hand you the file's contents in another container. If the CSV contains 765 numbers the recording never contained, the conversion has added information and you have no way to tell which rows to distrust. ## What edf2csv writes instead One file per distinct sampling rate. Nothing is resampled, upsampled, downsampled, interpolated or padded. Here is the same three-second file converted: ```bash edf2csv recording.edf --out ./converted ``` ``` warning: Channels use 3 different sampling rates (256 Hz, 128 Hz, 1 Hz). They are written to one file per rate so no channel is resampled. Wrote ./converted signals_256hz.csv 768 rows signals_128hz.csv 384 rows signals_1hz.csv 3 rows channels.csv 3 rows Done in 12ms. ``` The row counts are the sample counts. The temperature file has three rows because the thermistor produced three readings: ``` time_s,Temp rectal 0.000,37.00073 1.000,37.14725 2.000,37.29377 ``` Each file is a complete, self-contained CSV: a `time_s` column followed by one column per channel at that rate, with the channel's label as the column heading. The files are written in descending rate order and named after the rate they hold: | Rate | File name | | --- | --- | | 256 Hz | `signals_256hz.csv` | | 128 Hz | `signals_128hz.csv` | | 1 Hz | `signals_1hz.csv` | | 12.5 Hz | `signals_12_5hz.csv` | | 0.5 Hz | `signals_0_5hz.csv` | | 1e-7 Hz | `signals_1_000e-7hz.csv` | | 4e+300 Hz | `signals_4e+300hz.csv` | | Infinity Hz | `signals_Infinityhz.csv` | A fractional rate has its decimal point replaced by an underscore, so the name is a safe filename on every platform. The last three rows are the rest of that rule, and they are reachable: a rate the tool writes in exponent notation carries the exponent into the name, `e` and sign included. The last is a record duration too small to divide into, which overflows the rate; the file is opened and stays at its header row, with [`TIME_RESOLUTION`](/docs/warnings-and-errors#time_resolution) saying so. Nothing else is substituted, so those names hold a `-` or a `+` — both legal in a filename everywhere, and both worth knowing about before a script builds a glob. `conversion.rate_groups` in `metadata.json` names every file the run wrote, which is the way to get them without matching on a shape. The `time_s` column is seconds from the start of the recording and means the same thing in every file. That shared clock is what makes the separate files joinable later. The number of decimal places is chosen per rate so sample times are written exactly rather than rounded: 256 Hz gets 8 places because 1/256 terminates at 8 decimal places, 128 Hz gets 7, and 1 Hz gets 3. Multiplying `time_s` by the rate gives back a whole sample index rather than something like 8191.99999. That holds for a rate whose reciprocal terminates in decimal at all, which is every rate a recording is likely to use and not every rate: 3 Hz and 39 Hz do not, so their times are rounded to enough places to keep consecutive samples distinct and `time_s * rate` lands near a whole number rather than on one. [How many decimals time_s carries](/docs/output-files#how-many-decimals-time_s-carries) gives the rule and marks which is which. ## The other shape: one row per sample Everything above is about the wide table — a column per channel, a row per time value — because that is what a CSV of signal data usually means and what the default layout writes. The constraint it runs into is structural: one row has to hold a value for every column, and channels sampled at different rates do not have a value at the same times. `--layout long` drops that constraint by dropping the shape. One file, three columns, one row per sample: ```bash edf2csv recording.edf --out ./converted --layout long ``` ``` 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 0.00781250,EEG Fpz-Cz,18.010 0.00781250,ECG,0.12088 ``` Every row is a sample the recording holds, carrying the time it was recorded at. The 1 Hz channel contributes 3 rows and the 256 Hz channel contributes 768, and neither has to account for the other — so all three rates fit one file with nothing repeated, interpolated or blank. Rows come out sorted by `time_s` — unless the recording is a discontinuous one whose records are stored in a different order than they are timed, which is rare, allowed by the format, and [warned about](/docs/warnings-and-errors#discontinuous). This is the shape most plotting and grouping libraries want anyway: ```python import pandas as pd long = pd.read_csv("converted/signals.csv") long.groupby("channel")["value"].describe() ``` And the wide form is one call away, for whichever rates you want it for: ```python wide = long.pivot(index="time_s", columns="channel", values="value") ``` Note what that `pivot` produces for a mixed-rate file: a frame with 768 rows where the temperature column is 765 blanks. That is option 3 from the list above, arrived at deliberately, in your code, with the original file still on disk — which is the difference the whole page is about. That call needs `time_s` and `channel` together to name one sample, and two recordings break it: one whose channels sample faster than the time column can separate, and one whose data records overlap in time. Both raise `ValueError: Index contains duplicate entries, cannot reshape` rather than quietly keeping one sample of the pair. A conversion of either warns about the shape that causes it, and the warning's advice is the answer here too — those rows are told apart by their position in the file rather than by their time. Three costs. The file is larger, because every row repeats the time and the channel name rather than sharing one time across a row; two to three times, and `--gzip` recovers most of it. And `time_s` takes one precision for every rate — the finest any of them needs — because a single column cannot mean three things, so a 256 Hz and 1 Hz mix writes both at eight decimal places. That is the finest rate in the conversion rather than in the file: narrowing with `--channels` narrows the set, so the column can come back at a different width, carrying the same instants. The third is `value`, and it is the one to know before you aggregate. Every channel's numbers are in that one column, each in its own unit and at its own precision — µV, mV and °C down the same column, at three, five and five decimal places — because the alternative is a column of numbers padded to a width the recording does not support. So `long["value"].mean()` averages microvolts with degrees and returns something, and `long.groupby("channel")["value"]` is the form that does not. The unit belongs to the channel, and `channels.csv` is where each one's is; `conversion.rate_groups` in `metadata.json` carries the per-channel `decimals` beside it. The wide layout does not have this to answer: there, a channel is a column, and the column is the thing you name. ## Seeing the split before you convert `--info` converts nothing, returns in milliseconds whatever the file's size, and shows which file each channel is destined for. It reads the header, and on an EDF+ recording a little of the annotation channel: at most sixteen records of a continuous file to find where it begins, and the whole channel for a discontinuous one, whose record times are stored rather than arithmetic. ```bash edf2csv recording.edf --info ``` ``` Channels 3 signals # COLUMN LABEL UNIT RATE RANGE OUTPUT 0 EEG Fpz-Cz EEG Fpz-Cz uV 256 Hz -250 to 250 signals_256hz.csv 1 ECG ECG mV 128 Hz -5 to 5 signals_128hz.csv 2 Temp rectal Temp rectal degC 1 Hz 34 to 40 signals_1hz.csv Sampling rates differ, so channels are written to 3 files, one per rate. No channel is resampled. Would write 1,155 rows, roughly 22.2 KB. ``` Run it on an unfamiliar file before committing to a conversion. ## Where each channel ended up Two of the sidecar files record the mapping, so you don't have to infer it from filenames. `channels.csv` has a row per channel and an `output_file` column naming the file that channel's samples went to, plus a `converted` column that's `yes` or `no`. Channels you didn't select are still described here, marked `no`, so the sidecar documents the whole recording rather than just the part you took: ``` column,signal_index,label,unit,sampling_rate_hz,samples_per_record,...,output_file,converted EEG Fpz-Cz,0,EEG Fpz-Cz,uV,256,256,...,signals_256hz.csv,yes ECG,1,ECG,mV,128,128,...,signals_128hz.csv,yes Temp rectal,2,Temp rectal,degC,1,1,...,signals_1hz.csv,yes ``` `metadata.json` records the same grouping under `conversion.rate_groups`, in a form that's easier to read from a script: ```json "rate_groups": [ { "file": "signals_256hz.csv", "sampling_rate_hz": 256, "channels": ["EEG Fpz-Cz"], "decimals": [3] }, { "file": "signals_128hz.csv", "sampling_rate_hz": 128, "channels": ["ECG"], "decimals": [5] }, { "file": "signals_1hz.csv", "sampling_rate_hz": 1, "channels": ["Temp rectal"], "decimals": [5] } ] ``` ## Single-rate recordings get a single file If every channel shares one rate, which is the common case for a plain EEG or ECG montage, there's one group and one file, called `signals.csv`. No rate suffix and no extra files. The same applies when your selection happens to be uniform. Asking for a single channel out of a mixed-rate file leaves one rate in play, so you get a plain `signals.csv`: ```bash edf2csv recording.edf --channels "Temp rectal" --out ./temperature ``` ``` signals.csv 3 rows channels.csv 3 rows ``` The mixed-rate warning describes the conversion rather than the recording, so narrowing to a single rate raises nothing at all — there is no split to explain. Narrowing to two of three rates reports those two. `--info` still lists every channel in the file, including the ones marked `(not selected)`, so nothing about the recording is hidden. One consequence to watch for: if you convert a mixed-rate recording and then a single-rate one into the same directory with `--force`, the old `signals_256hz.csv` isn't deleted and will sit next to the new `signals.csv`, both looking current. edf2csv warns about the leftovers and deletes nothing. Converting into a fresh directory avoids the situation. ## Working with several rate files The files share one time base, so joining them is a normal table operation. To attach the slow channel to the fast one, use `pandas.merge_asof`, which matches each fast row to the most recent slow reading at or before it: ```python import pandas as pd eeg = pd.read_csv("converted/signals_256hz.csv") temp = pd.read_csv("converted/signals_1hz.csv") merged = pd.merge_asof( eeg, # 768 rows, one per EEG sample temp, # 3 rows, one per thermistor reading on="time_s", direction="backward", tolerance=1.0, # do not carry a reading forward more than one second ) ``` `merge_asof` needs both frames sorted on `time_s`, which they are for a continuous recording. The result has 768 rows, and the temperature column repeats each reading until the next one arrives. That's still interpolation in its crudest form, but it happens in your script with a `tolerance` you chose, and anyone reading the code can see that the temperature column is carried forward rather than measured 768 times. To go the other way and summarise the fast channel at the slow channel's resolution, aggregate into bins the slow channel defines. This reduces data rather than inventing it, so it's usually the safer direction: ```python import pandas as pd eeg = pd.read_csv("converted/signals_256hz.csv") temp = pd.read_csv("converted/signals_1hz.csv") per_second = ( eeg.assign(second=eeg["time_s"] // 1.0) .groupby("second")["EEG Fpz-Cz"] .agg(["mean", "std", "count"]) .reset_index() ) aligned = per_second.merge(temp, left_on="second", right_on="time_s") ``` The `count` column is a useful check: for a complete 256 Hz recording every bin should hold 256 samples, and a bin that doesn't indicates something about the recording. A time window applies consistently across the files. Converting one second out of the middle of the same recording gives 256, 128 and 1 rows, and each file's `time_s` still carries absolute offsets from the start of the recording rather than restarting at zero: ```bash edf2csv recording.edf --start 1s --duration 1s --out ./one-second ``` If you need wall clock times rather than offsets, `metadata.json` records the recording's start as `recording.start_datetime_local`, and adding it to `time_s` gives an absolute timestamp. ## Resampling is left to you edf2csv doesn't resample under any flag. Upsampling temperature to 256 Hz and downsampling EEG to 1 Hz both produce a single tidy table, but they answer different questions and introduce different distortions. Downsampling without an anti-aliasing filter folds high-frequency content back into your band of interest. Upsampling inflates every sample count, which breaks anything that uses n as a denominator. If you want a uniform grid, resample explicitly in your own code, where the choice is recorded: ```python import pandas as pd from scipy.signal import resample_poly eeg = pd.read_csv("converted/signals_256hz.csv") # 256 Hz to 128 Hz, with the anti-aliasing filter resample_poly applies for you. downsampled = resample_poly(eeg["EEG Fpz-Cz"].to_numpy(), up=1, down=2) time = eeg["time_s"].to_numpy()[::2] out = pd.DataFrame({"time_s": time, "EEG Fpz-Cz": downsampled}) ``` The resampled file then exists because you chose it, with a method you named, rather than as a side effect of the conversion. --- # EDF+ annotations and gaps > How edf2csv reads the EDF+ annotations channel, exports events, and preserves the real timing of discontinuous recordings Plain EDF stores nothing but numbers. EDF+ adds a way to store text alongside the signals: sleep stages, stimulus markers, technician notes, seizure onsets, and the timestamps that tell you where in the recording each data record actually sits. edf2csv reads that channel in full and writes it to `annotations.csv`, and it uses the same information to make sure the `time_s` column in `signals.csv` reflects when the data was really measured. ## The annotations channel An EDF+ file declares one or more channels with the reserved label `EDF Annotations`. BDF+ files, produced by BioSemi hardware, spell the same thing `BDF Annotations`. edf2csv recognises both. These channels occupy space in every data record exactly like a signal does, but the bytes are UTF-8 text rather than samples — where the writer managed UTF-8. Where it did not, they are read as latin1 instead, one character per byte, which is how this parser reads every other text field in the file. Decoding them strictly as UTF-8 and taking what comes back means a description written `café` by an older recorder arrives as `caf�`: a character the file does not contain, in a text column. Bytes that decode as UTF-8 are UTF-8, including a replacement character the file really holds; bytes that do not decode are not UTF-8, and nothing is invented either way. That's why the annotations channel never appears in the channel table, never gets a column in `signals.csv`, and is never listed in `channels.csv`. `--info` reports it separately: ```bash edf2csv sleep-study.edf --info ``` ``` Channels 5 signals + 1 annotation channel ``` The text is stored as a run of Time-stamped Annotation Lists (TALs). Each TAL begins with a signed onset, optionally followed by a duration, then zero or more text strings, and is terminated by a NUL byte. The separators are control characters: `0x15` between onset and duration, `0x14` before and after each text string. Written out with the control bytes made visible, one TAL looks like this: ``` +1.25<0x15>0.5<0x14>Seizure onset<0x14><0x00> ``` That says: at 1.25 seconds from the start of the recording, an event lasting 0.5 seconds, described as `Seizure onset`. ## What an annotation carries Three things, and only three things: | Field | Meaning | | --- | --- | | onset | Seconds from the start of the recording. Always present. | | duration | Seconds. Optional. A TAL may omit it entirely. | | text | The description. A single TAL may carry several. | `annotations.csv` has one row per annotation, with a fourth column recording which data record the annotation was physically stored in: ``` 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 ``` An omitted duration is written as an empty cell, not as `0`. The two mean different things: a duration of zero states that the event was instantaneous, while an empty cell records that no duration could be read. In pandas the empty cell reads back as `NaN`, which is the right value for something that was never recorded. The empty cell covers two cases the file distinguishes and the column cannot: a TAL that stated no duration, and one that stated something which is not a number. The event is kept either way, and the second raises an `ANNOTATION_DECODE_FAILED` warning saying how many rows it happened to — the cell itself cannot tell them apart, so the warning is the only place the difference survives. Descriptions are escaped by normal CSV rules, so an annotation containing a comma, a quote, or a newline is quoted rather than allowed to break the column count. A single TAL can carry several text strings sharing one onset and duration. Each becomes its own row, all with the same `onset_s` and `duration_s`. ## EDF+C and EDF+D Bytes 192 to 235 of the header hold a reserved field. EDF+ files write one of two markers there: - **EDF+C**, continuous. Every data record follows the one before it with no gap. Record `n` starts at `origin + n * record_duration` seconds, where the origin is what the first record's timekeeping TAL says — usually `+0`, and not always. A continuous recording whose first TAL reads `+0.5` writes its first row as `0.500` and its records still sit end to end. What continuity fixes is the spacing, not the starting point. - **EDF+D**, discontinuous. The records are still in file order, but they aren't adjacent in time. There are holes between them. BDF+ writes `BDF+C` and `BDF+D` for the same two states. edf2csv normalises both spellings to one internal marker, so a BioSemi discontinuous file behaves exactly like an EDF+D one. Discontinuity isn't an exotic case. It's what you get when an ambulatory recorder is paused and resumed, when a system drops out and reconnects, when a long recording is segmented and the segments are concatenated, or when a vendor tool exports only the annotated epochs of a much longer study. The important consequence: in an EDF+D file, the arithmetic `record_index * record_duration` is no longer the record's position in time. It's only a count of how much data came before. The real position is stored in the first TAL of every data record, the mandatory timekeeping annotation. That TAL is the only place a discontinuous file records where its data sits. It usually carries an onset and nothing else, and it is allowed to carry event text after the onset as well — writers do. Those events are exported like any other, which is why an unreadable first TAL costs both the record's position and whatever events came with it, and why the warning for one counts them separately from the warning for the other. `--info` names the format directly: ``` Format EDF+ (discontinuous) ``` and conversion warns before it starts: ``` warning: This recording is marked discontinuous (EDF+D): its data records need not be contiguous in time. Each row carries its true recording time, so gaps stay visible instead of being closed. ``` ## Gaps stay visible edf2csv reads the timekeeping TAL of every record and uses it as the base time for every sample in that record. Sample `i` of a record that declares itself at `t` seconds is written at `t + i / sampling_rate`. Nothing is inserted to bridge a gap, and nothing is shifted to close one. A gap therefore appears in the output as a jump in `time_s` between two consecutive rows. The test fixture `discontinuous.edf` demonstrates this in miniature. It holds three one-second records of a single 10 Hz channel, and its timekeeping TALs place those records at 0 s, 1 s and 10 s. The first two are adjacent; the third sits after a nine-second hole. ```bash edf2csv discontinuous.edf --out ./converted head -1 ./converted/signals.csv sed -n '20,23p' ./converted/signals.csv ``` If the gap were ignored and the records were laid end to end, the output would run 0.0 s to 2.9 s with nothing to indicate that anything was missing. What edf2csv actually writes is: ``` time_s,EEG Fpz-Cz ... 1.800,2.259 1.900,2.381 10.000,2.503 10.100,2.625 ... ``` That's thirty rows, one per recorded sample, and the jump from `1.900` to `10.000` is the gap. Anything that computes a sampling interval by differencing `time_s` sees the discontinuity rather than a smooth ramp that misplaces every sample after the hole by nine seconds. This has three practical consequences. **The recording's span isn't the amount of data it holds.** This file contains three seconds of samples but covers eleven seconds of wall time. Time windows are resolved against the real span, so a window can legitimately reach past where the data would have ended if it were contiguous: ```bash edf2csv discontinuous.edf --start 5 --out ./converted ``` That isn't an error, and the record at 10 s isn't clipped away. It writes the ten samples from `10.000` to `10.900`. The same window against a naively flattened version of this file would have been rejected as starting past the end. **`--info` reports both the amount of data and the span.** For this file it prints `Duration 3s (3 records of 1s)`, because that's how many seconds of signal exist, and `Time span 11s (includes discontinuities)` on the line under it, because that's how long the recording covers. The gap is the difference between them, and is reported again by the EDF+D warning. A span *shorter* than the duration is the other way the two can disagree — records that overlap, which is what a device does when it re-sends a buffer — and the line says `(records overlap in time)` instead, since a recording covering less time than its own records account for has no gaps in it at all. **Rows are written in file order.** If a file's timekeeping TALs are themselves out of order, so that a record claims to start before the one preceding it, edf2csv writes the rows anyway and warns that `time_s` won't increase monotonically. It doesn't silently reorder your data. ## The whole annotation channel is always read When you request a time window, edf2csv still scans the annotation channel of the entire file, from the first record to the last. This is deliberate. Nothing in the EDF+ specification obliges a writer to store an annotation in the data record whose time span contains its onset. Some tools write every annotation in the file into the first record. Others batch them. Reading only the records that fall inside the requested window would silently drop events that belong in that window but happen to be stored elsewhere, and the resulting `annotations.csv` would look complete. The fixture `annotations-front-loaded.edf` is exactly this shape: ten one-second records, with all three annotations stored in record 0, at onsets 0.5 s, 5.5 s and 8.5 s. Asking for the window from 5 s to 7 s produces the event that belongs there: ```bash edf2csv annotations-front-loaded.edf --start 5 --duration 2 --out ./converted cat ./converted/annotations.csv ``` ``` onset_s,duration_s,description,record_index 5.5,,middle,0 ``` Note `record_index` is `0` while `onset_s` is `5.5`. That column exists precisely so you can see when a writer has done this. The scan is cheap. edf2csv seeks straight to the annotation channel's byte range inside each record rather than reading whole records through memory, so on a multi-gigabyte recording the cost is a few kilobytes of I/O rather than all of it. Annotations are filtered to the requested window by their onset, using the half-open interval `[start, end)`. An annotation whose onset falls inside a gap in a discontinuous file is still exported, because the window covers the recording's real span rather than only the parts that contain samples. ## Exporting only the events Some analyses need the event list and nothing else: building a hypnogram, counting stimulus markers, checking that a scoring pass covers the whole night. Converting an eight-hour 256 Hz study to get a few hundred rows of text is wasteful. ```bash edf2csv sleep-study.edf --annotations-only --out ./events ``` This skips signal conversion entirely. No `signals.csv` is written and no samples are read. The output directory contains `annotations.csv`, `channels.csv` describing the channels that weren't converted, and `metadata.json`. `--start`, `--duration` and `--end` still apply, so you can pull the events from a single hour. Asking for annotations from a file that has no annotation channel isn't an error, but it doesn't pass silently either: ``` warning: --annotations-only was requested but this recording has no annotation channel, so there are no events to export. Plain EDF files carry no annotations. Convert without --annotations-only to get the signals. ``` A file whose only content is annotations, with no signal channels at all, converts fine. It produces `annotations.csv` and an empty `channels.csv`, plus a warning saying the file carries no signal channels. ## When timekeeping is missing or unreadable The timekeeping TAL is the only record of where a discontinuous file's data sits. When it's absent or can't be parsed, that position is simply not knowable from the file. edf2csv doesn't stop, and it doesn't guess quietly. The affected record is timed as if it were contiguous with the records around it, at `origin + index * record_duration` — where `origin` comes from the first record that does state a time, since a recording need not begin at zero. Writing it as `index * record_duration` would put the record at the wrong instant on every file whose first record says anything but `+0`. The substitution is reported by name: ``` warning: 1 of 3 data records carries no readable timekeeping annotation (record 1), so its true position in time is unknown. That record is timed as if it were contiguous; treat its timestamp as unreliable. ``` Up to eight record indices are listed, and the rest are counted rather than dropped: `... 8, 9 and 2 more`. The same warning appears in `metadata.json` under `notes`, and in the `warnings` array of `--json` output, so a scripted pipeline can detect it without parsing stderr. Two related cases get their own warnings. A file marked EDF+D that has no annotation channel at all is self-contradictory: it claims gaps and provides nowhere to record them. Times are written as if the records were contiguous, and edf2csv says so plainly, noting that any gaps are lost. Individual TALs that can't be decoded are skipped rather than aborting the conversion, and the count is reported: ``` warning: 2 annotation entries were unreadable and could not be exported. Every entry that could be read is exported. The file may have been written by a non-conforming tool. ``` One malformed annotation shouldn't cost you an entire conversion, but dropping it silently would leave you with an event list you had no reason to question. ## How other tools handle this Discontinuity is where EDF readers differ most, so it's worth knowing what your existing tooling does. **pyEDFlib** refuses EDF+D files outright, raising rather than returning data. That guarantees it never gives you wrong timestamps, but it also means discontinuous recordings give you nothing at all. Converting with edf2csv first is one way to get the data into a form you can work with. **mne.io.read_raw_edf** reads EDF+D files and presents them as continuous. The gaps are closed. Samples from either side of a hole end up adjacent in the array, and the returned time vector counts uniformly from zero as though no interruption occurred. Downstream, every sample after the first gap carries a timestamp that's wrong by the accumulated gap length, and nothing in the object marks which samples those are. Annotations are read from the file with their original onsets, so on a discontinuous recording the event times and the sample times refer to different clocks. edf2csv takes a third position: read the file, keep the real times, and report the structure. If a gap matters for your analysis, deciding what to do about it is yours to make with the gap in front of you. ## Reading the output Loading the two files together in pandas is enough to line events up against signal: ```python import pandas as pd signals = pd.read_csv("converted/signals.csv") events = pd.read_csv("converted/annotations.csv") # Find gaps: any interval larger than the sampling period. dt = signals["time_s"].diff() gaps = signals.loc[dt > dt.median() * 1.5, "time_s"] # Samples covered by one annotation. event = events.iloc[0] end = event.onset_s + (event.duration_s if pd.notna(event.duration_s) else 0) window = signals[(signals.time_s >= event.onset_s) & (signals.time_s < end)] ``` Because `time_s` carries the true recording time in both files, the comparison is valid across a gap. `duration_s` is `NaN` wherever the file gave no duration — or gave one that is not a number, which the run warns about — which is why the check above is explicit rather than assuming zero. A duration below zero is written out as the file gave it and warned about too: added to `onset_s` it ends the window before the event starts, so `end` above would come out less than the onset and select nothing. ## Programmatic access The annotation decoder is exported, so you can read events without producing CSV at all: ```javascript import { EdfFile } from 'edf2csv'; const file = await EdfFile.open('sleep-study.edf'); const { annotations, recordStarts, malformed } = await file.readAnnotations(); for (const a of annotations) { console.log(a.onset, a.duration, a.text, a.recordIndex); } // recordStarts[i] is the declared start of record i, or null when the // timekeeping annotation was missing or unreadable. console.log(recordStarts[0], recordStarts.at(-1), malformed); await file.close(); ``` `readAnnotations` returns annotations sorted by onset, then by record index for ties. `recordStarts` has one entry per data record actually present in the file. `decodeRecordAnnotations` is also exported for decoding a single record's annotation bytes directly. --- # Warnings and errors > Every diagnostic and error code edf2csv can raise, what causes it in a real recording, and what to do about it edf2csv makes a sharp distinction between two things. A **warning** means the conversion succeeded but something about the recording is worth knowing before you analyse the numbers. A **fatal error** means the file can't be read into trustworthy output, so nothing is converted at all. Warnings never change the exit code. A recording can produce six warnings and still exit 0, because the CSV it produced is correct: the warnings describe the recording, not a failure of the tool. ## How edf2csv reports problems Every diagnostic has a stable machine-readable code (`MIXED_SAMPLING_RATES`, `TRAILING_BYTES`, and so on), a severity, a message, and often a hint on the line below telling you what you can do. Warnings and errors go to **stderr**. Requested data (`--info`'s channel table, `--json`'s summary) goes to **stdout**. The separation lets you pipe a conversion summary into another program without warning text mixed into it. ``` warning: Channels use 3 different sampling rates (256 Hz, 128 Hz, 1 Hz). They are written to one file per rate so no channel is resampled. ``` The severity field is `warning`, and only `warning`. It was declared `warning | info`, and nothing has ever raised an `info` — a published union is a promise about what a caller may receive, so a `--json` consumer branching on `severity` was told to handle a second value that cannot arrive. The type says one value now, and this field is kept rather than dropped because `metadata.json`, both JSON streams and the `Diagnostic` interface all carry it, and removing it from a document people have archived is a worse change than narrowing what it can say. The prefix a diagnostic prints is that field, so a `warning:` line is the only one there is. Four places show you the same diagnostics in different forms. | Where | Behaviour | | --- | --- | | Terminal, default | Printed to stderr before the conversion summary, hint included | | `--quiet` | Suppresses the summary only. Warnings and errors still print | | `--json` | Diagnostics aren't printed as text. They appear in the `warnings` array on stdout, each with `code`, `severity` and `message`. Hints aren't included | | `metadata.json` | The `notes` array records `code`, `severity` and `message` for every diagnostic the run raised up to the moment it is written — the header's, the plan's, and the conversion's own. `INPUT_CHANGED` is decided after every CSV is on disk and is in here | There's one exception. `STALE_OUTPUT` is detected after `metadata.json` has already been written, so it appears on the terminal and in `--json` output but never in `metadata.json`. ### What `--info` can and can't tell you `--info` reads the header and builds a conversion plan, so it surfaces every structural, calibration and output-shape warning without converting anything. It reads the annotation channel too, for an EDF+ recording: the whole of it for a discontinuous file, whose record times are stored rather than arithmetic, and the first sixteen records of a continuous one — or of a file that has an annotation channel and no marker at all — to find where the recording begins. Every annotation channel of those sixteen records since 0.9.18: only the first carries a record's start time, which is what the scan was written for, so it read that one and stopped — and an unreadable entry in a second channel is an event lost out of `annotations.csv` exactly as it is in the first, reported by a conversion and by nothing here. So it also raises `ANNOTATION_DECODE_FAILED` and the `DISCONTINUOUS` variants that come from inspecting record timestamps — records out of order, records overlapping, an origin too far from zero, and, since 0.9.17, a continuous file whose records contradict its marker within those sixteen. It raises them for what it read, which on a continuous file is however many records it took to find one stating a start time — the search stops there, so usually that is the first record and only ever at most sixteen. A timekeeping entry that cannot be read *after* that point is not seen: give the second record of a three-record continuous file a corrupt TAL and the conversion raises `ANNOTATION_DECODE_FAILED` while `--info` says nothing, even though record 1 is well inside the sixteen. An unreadable *event* further into a continuous recording is not seen either, so `two-annotation-channels.edf` raises `ANNOTATION_DECODE_FAILED` when converted and nothing under `--info`. Its byte-identical discontinuous twin raises it either way, because there the whole channel is read. The same bound hides `NO_ANNOTATIONS` under `--annotations-only`. Whether an annotation channel carries *events* rather than only the timekeeping entries that place each record is a question about the whole channel, and on a continuous file `--info` has not read it: `contiguous-fractional.edf` converts with "This recording's annotation channel carries no events, so annotations.csv holds its header and no rows" and describes as if it had some, so `--info --annotations-only --strict` exits 0 where converting the same file exits 1. A discontinuous file is read end to end and is answered either way, as is a recording with no annotation channel at all — that one is a header fact, which is the raising 0.7.101 gave `--info`. The bound costs more than a warning when nothing inside it states a time at all. A continuous recording is timed from its first record's start, and any one record settles it — but if none of the first sixteen carries a timekeeping entry, `--info` finds no origin and reports the recording as beginning at zero, while a conversion reads every record, finds one further in and times every row from it. Twenty records of one second whose only timekeeping entry is in record 16, saying `+21.5`, convert with `time_s` running from 5.500; `--info` on the same file prints no `Timed from` line and puts `first_sample_seconds` at `0`. That is not a missing warning, and neither side raises one: the records before it are not unreadable, they simply say nothing, so there is no `ANNOTATION_DECODE_FAILED` to notice. `--start` and `--end` are read against that clock, so a window `--info` places against zero lands elsewhere in the conversion. What it cannot raise is the handful that need a conversion to exist: - `STALE_OUTPUT`, which is noticed after `metadata.json` has been written and so needs there to be an output directory to be stale. - `INPUT_CHANGED`, which asks whether the recording moved while it was being converted. `--info` opens the file, reads a header and closes it, so there is no window for it to have moved during — and no output whose description of the file could have stopped being true. - A file [marked continuous whose own records disagree with it](#discontinuous), which is noticed while the full record-start array is built rather than while the origin is found. `--info` on a continuous file stops at the first record that states a start time, so the records that would contradict it are never read at all. Converting such a file warns that some of its records start somewhere other than where continuity puts them; `edf2csv liar.edf --info --strict` exits 0 where converting the same file exits 1. `EMPTY_WINDOW` used to be on that list, and was not one of them: it is a fact about the plan, which `--info` builds. So was `NO_ANNOTATIONS`, until 0.7.101: whether a recording has an annotation channel is a header fact, and `--info --annotations-only` had been printing it in prose all along. So was the `NO_SAMPLES` that reports a signal file *not written*, until 0.7.84 made `--info` raise it — whether a conversion writes one is settled by the header and the plan, both of which `--info` has. The per-channel `NO_SAMPLES`, about a channel carrying no samples, has always come from the header and been raised. Until 0.5.37 this section said the opposite — that `--info` touches neither the data records nor the annotation channel, and cannot raise `ANNOTATION_DECODE_FAILED` or any timestamp-derived `DISCONTINUOUS`. The `DISCONTINUOUS` section further down said "`--info` raises `DISCONTINUOUS` too, since it has to read those record times", on the same page. ```bash edf2csv sleep-study.edf --info ``` If you want to know about a file before committing to a conversion, `--info` is the cheapest way to see most of what edf2csv would say. ## Warnings at a glance | Code | One line | | --- | --- | | `HEADER_BYTES_MISMATCH` | The header's own declared size disagrees with the signal count | | `RECORD_COUNT_UNKNOWN` | The header says `-1` records instead of a number | | `RECORD_COUNT_MISMATCH` | The file holds a different number of records than the header claims | | `TRAILING_BYTES` | Bytes after the last complete data record were ignored | | `COMMA_DECIMAL` | A header number used a comma as its decimal separator | | `DEGENERATE_DIGITAL_RANGE` | A channel's digital minimum equals its digital maximum | | `DEGENERATE_PHYSICAL_RANGE` | A channel's physical minimum equals its physical maximum | | `UNUSABLE_PHYSICAL_RANGE` | A channel's physical span is too wide to represent, or too small — both leave it with no mapping | | `INVERTED_PHYSICAL_RANGE` | A channel's calibration inverts its polarity: exactly one of its two bounds pairs is reversed | | `NO_SAMPLES` | A channel declares zero samples per data record, or no signal file was written — because every channel selected carries none, or because the recording has no signal channels at all | | `EMPTY_LABEL` | A channel has a blank label | | `DUPLICATE_LABEL` | Two or more channels share a label, a channel's own label was taken by another's `_ch` suffix or by `time_s`, or a `--channels` term matched several | | `DISCONTINUOUS` | The recording is marked EDF+D, or its records are out of order, overlap in time, contradict an EDF+C marking, sit too far from zero to tell apart, or have nowhere to record where they are | | `ANNOTATION_DECODE_FAILED` | An annotation entry, a record's timestamp, or an event's duration couldn't be read — or a duration read perfectly well and states a length below zero | | `NO_ANNOTATIONS` | `--annotations-only` found no events to export: no annotation channel, a channel holding only timekeeping, or a window that excluded every event | | `MIXED_SAMPLING_RATES` | The channels being converted run at different rates, so one output file is written per rate — or one file for all of them under `--layout long` | | `NO_SIGNAL_CHANNELS` | The file contains annotations and nothing else | | `LARGE_OUTPUT` | An output file will be too big for a spreadsheet application | | `STALE_OUTPUT` | Files from an earlier conversion are still sitting in the output directory | | `NONPRINTABLE_LABEL` | A channel's label, unit, transducer or prefiltering contains control characters, or an annotation's description carries text a terminal does not print as itself; the warning says which | | `FORMULA_LABEL` | A channel's label, unit, transducer or prefiltering — or an annotation's description — starts with a character a spreadsheet reads as the start of a formula | | `EMPTY_WINDOW` | The requested window lands where the recording has no data, so the signal files hold only their headers | | `EMPTY_RATE_WINDOW` | The requested window holds samples of some of the recording's sampling rates and none of others, so those rates' files hold only their headers | | `INPUT_CHANGED` | The input changed while it was being converted | | `TIME_RESOLUTION` | Samples arrive faster than the time column can distinguish, so consecutive rows share a `time_s` — or the rate overflowed to `Infinity` and no rows are written at all | | `VALUE_RESOLUTION` | A channel steps by less than the decimals written can express, so consecutive samples share a value | | `MISSING_EDF_PLUS_MARKER` | An annotation channel puts the records somewhere the missing EDF+ marker cannot honour | | `START_TIME_UNREADABLE` | The header's start date or time is not a date or a time | | `LEAP_SECOND_START` | The header's start time names the sixtieth second, which no calendar date has | | `START_DATE_MISMATCH` | An EDF+ recording identification field states a different start date from the header's own | | `STDOUT_UNSUPPORTED` | `--info --stdout` on a recording `--stdout` would refuse | ## File structure and integrity These come from parsing the header and comparing it against the file's actual size. They tell you whether the file on disk matches what it says about itself. ### HEADER_BYTES_MISMATCH The header contains a field stating its own length in bytes. This warning fires when that number disagrees with the length implied by the signal count, which is always 256 bytes for the fixed header plus 256 bytes per signal. **Cause.** Almost always a writer that filled in the field carelessly, or a file that was edited by hand or by a script that changed the signal list without updating the length. **What edf2csv does.** Ignores the declared value and uses the computed one. Every data record offset is derived from the computed length, so the samples land where they should. ``` warning: Header says it is 99 bytes, but 2 signals require 768 bytes. Using the value computed from the signal count. Where the data starts is worked out from the signal count, not from this field, so nothing is read from the wrong offset. A writer that got this one wrong may have got others wrong too. ``` **What to do.** Nothing, if the rest of the conversion looks right. Check the `--info` channel table: if the labels and units are readable text and the sampling rates are plausible, the header was parsed correctly and only the length field was wrong. Garbled labels alongside this warning point at a genuinely damaged file. ### RECORD_COUNT_UNKNOWN The header declares `-1` data records rather than a count. **Cause.** The EDF specification permits `-1` for a recording still in progress: the writer doesn't know the final count until it stops. Some acquisition software leaves the placeholder in place even after the recording ends. **What edf2csv does.** Derives the count from the file size and converts every complete record present. ``` warning: The header does not say how many data records the file has (-1), which the spec allows for recordings still in progress. Using the 4 records the file actually contains. That count comes from the size of the file, so a recording still being written converts as much of it as was on disk when this run read it. ``` **What to do.** Confirm the recording is finished and not still being written to. Converting a file that's actively growing risks a mid-read failure (see `UNREADABLE` below). If the file is closed, the derived count is the right one. ### RECORD_COUNT_MISMATCH The header declares a specific number of data records and the file contains a different number. **Cause.** When the file is shorter than declared, the usual causes are an interrupted recording, a copy that didn't finish, or a transfer that was cut off. When the file is longer than declared, the writer under-reported, or something was appended. **What edf2csv does.** Trusts the file over the header and converts every complete record that's actually there. The hint changes depending on the direction: a short file gets a note that the recording may have been cut short, and a long file gets a note that the file exceeds its own claim. ``` warning: The header declares 10 data records but the file contains 4. Only the 4 records that are present can be converted. ``` **What to do.** If the file is short, decide whether the missing tail matters for your analysis, and check whether a complete copy exists elsewhere. The duration reported by `--info` reflects the records actually present, not the declared ones, so it's safe to reason from. `metadata.json` records both numbers as `data_records` and `data_records_declared`. ### TRAILING_BYTES Some bytes sit after the last complete data record, too few to form another record. **Cause.** A recording stopped part way through writing its final record, or a file transfer was truncated mid-record. **What edf2csv does.** Ignores them. A partial record can't be decoded into a full set of samples across every channel, and guessing at it would invent data. ``` warning: 7 bytes after the last complete data record were ignored. A record is the unit this format is addressed in, and a partial one says nothing about which samples it holds or when they were taken. Every complete record is converted. ``` **What to do.** Usually nothing. A handful of ignored bytes at the end of a long recording is a fraction of a second. If the count is large relative to one record's size, that's a sign of a more serious problem and is worth investigating alongside `RECORD_COUNT_MISMATCH`. ### COMMA_DECIMAL At least one numeric field in the header used a comma as its decimal separator, which the EDF specification doesn't allow. **Cause.** Software written in a locale where the comma is the decimal separator, formatting numbers without forcing a neutral locale. It shows up in physical minimum and maximum fields most often, and in the record duration. **What edf2csv does.** Reads a comma as a decimal point, but only when the field contains no dot at all, and raises this warning once for the whole file no matter how many fields were affected. The fields covered are the header length, record count, record duration, signal count, and each signal's physical minimum, physical maximum, digital minimum, digital maximum and samples per record. ``` warning: Some header numbers use a comma decimal separator, which the EDF spec does not allow. They were read as decimal points. Check the values in the channel table. ``` **What to do.** Look at the channel table in `--info` or at `channels.csv` and confirm the physical ranges are what you expect for those channels. A range of `-250 to 250` for an EEG channel in microvolts is plausible; `-250000 to 250000` would suggest the separator was interpreted differently than the writer intended. ### INPUT_CHANGED The recording's size or modification time moved between the moment it was opened and the moment the conversion finished. **Cause.** Almost always a file still being written — acquisition software appending records, or a copy still in flight. Replacing the file at that path while the conversion runs does it too. **What edf2csv does.** Finishes, and says so. The CSVs are correct for the records that were read, and `metadata.json` describes the file as it was opened, so the record and the output agree with each other. What stops being true is that they describe the file as it now stands. ``` 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. Convert again once the recording is finished to pick up the rest. ``` Under `--checksum` the hash is dropped rather than guessed at, and the hint says so instead: the bytes that were converted are no longer there to hash, so `source.sha256` in `metadata.json` is `null`. A checksum that is present therefore means the file demonstrably held still while it was read. **What to do.** Wait for the recording to finish and convert again. If you need the checksum, that second run is the one that can produce it. ## Channel calibration and labelling These describe individual channels. They are raised per signal, and never for the EDF+ annotations channel, which carries text rather than samples and has no meaningful calibration. Only one of the three calibration warnings is raised per channel. The checks run in order: a degenerate digital range is reported first and suppresses the other two, then a degenerate physical range, then an inverted physical range. ### DEGENERATE_DIGITAL_RANGE A channel declares the same value for its digital minimum and digital maximum. **Cause.** A header field filled in with a placeholder, or a channel that was configured but never properly calibrated. It's common on unused or dummy channels that acquisition software adds to fill out a montage. **What edf2csv does.** The digital-to-physical mapping is defined by two calibration points. With both points at the same digital value the mapping doesn't exist, so there's no physical value to compute for any sample. Those cells are written empty. ``` warning: Signal 0 ("flat") has digital minimum equal to digital maximum (0), so its values cannot be scaled. Its cells are left empty rather than filled with a value the header cannot justify. ``` An empty field is the same convention `annotations.csv` uses for an absent duration, and it reads back as `NaN` in pandas and `NA` in R. Earlier versions wrote the channel's physical minimum instead; a column of repeated numbers is indistinguishable from a genuinely flat recording once the CSV is opened somewhere else, which is the sort of invented data this tool exists to avoid. Channels either side of the degenerate one are unaffected. **What to do.** Don't analyse that channel. If it matters to you, go back to the acquisition system for a correctly calibrated export. Otherwise exclude it with `--channels` and convert the rest: ```bash edf2csv recording.edf --channels "EEG Fpz-Cz,ECG" ``` ### DEGENERATE_PHYSICAL_RANGE A channel declares the same value for its physical minimum and physical maximum, while its digital range is valid. **Cause.** Same family of causes as above: an uncalibrated or placeholder channel, or a writer that left both physical fields at zero. **What edf2csv does.** The mapping is well defined but flat, so every digital code converts to the same physical number. The channel is converted normally and produces a constant column. ``` warning: Signal 1 ("flatphys") has physical minimum equal to physical maximum (5), so every sample converts to the same value. Its cells carry that value rather than being left empty, since the mapping is defined — it just has one point in it. The distinct digital codes behind them are not recoverable from the CSV. ``` Note the difference from `DEGENERATE_DIGITAL_RANGE` above. There the mapping doesn't exist and the cells are left empty; here it exists and simply has no slope, so the value it gives is a real reading and is written as one. **What to do.** Treat the column as carrying no information. Distinct digital codes were recorded, but the header says they all mean the same physical value, so the distinction can't be recovered from the CSV. If you need the raw codes, the header calibration in `channels.csv` gives you `digital_min`, `digital_max`, `physical_min` and `physical_max` to work from. ### UNUSABLE_PHYSICAL_RANGE The distance between a channel's physical minimum and maximum overflows a double, so no gain can be computed from it. **Cause.** EDF's physical range fields are eight ASCII characters and accept exponent form, so a header can legitimately say `-1e308` to `1e308`. Real hardware does not, but generated or corrupted headers do. **What edf2csv does.** Treats it as an undefined mapping and leaves those cells empty, the same as `DEGENERATE_DIGITAL_RANGE`. Earlier versions wrote the physical minimum instead, which filled the column with one enormous constant — every distinct sample rendered as the same 300-digit number — and raised no diagnostic at all. ``` warning: Signal 0 ("huge") declares a physical range from -1e+308 to 1e+308, whose span is too large to represent, so its values cannot be scaled. Its cells are left empty rather than filled with a value the header cannot justify. ``` A span can also be too *small*. The gain is the span divided by the digital range, so 2e-320 across 65,536 codes is 3e-325 — below the smallest number a double can hold, so it underflows to zero and there is no mapping, exactly as when it overflows. Until 0.5.83 that case took the flat-range path instead: every one of the channel's 65,536 distinct readings was written as the same number, with no diagnostic and `--strict` exiting 0. ``` warning: Signal 0 ("MAG") declares a physical range from -1e-320 to 1e-320, whose span is too small to represent, so its values cannot be scaled. Its cells are left empty rather than filled with a value the header cannot justify. ``` A range that is genuinely flat — minimum equal to maximum — is a different thing and keeps its constant, because that mapping is defined and every sample really is that value. It has [`DEGENERATE_PHYSICAL_RANGE`](#degenerate_physical_range) of its own. **What to do.** Treat the channel as unreadable and check where the header came from. Channels either side of it are unaffected. ### INVERTED_PHYSICAL_RANGE A channel's calibration inverts its polarity. The gain is `(physical_max - physical_min) / (digital_max - digital_min)`, so what makes a channel inverted is the sign of that fraction, not the physical pair on its own. Reversing exactly one of the two pairs makes it negative; reversing both leaves it positive, and such a channel is not inverted at all: | physical bounds | digital bounds | gain | raised | | --- | --- | --- | --- | | reversed | normal | negative | yes | | normal | reversed | negative | yes | | reversed | reversed | positive | no | **Cause.** This is sometimes a mistake and sometimes deliberate. Some hardware records a channel with inverted polarity and expresses that by swapping one pair of bounds, which is a legitimate reading of the specification. Others simply wrote the fields in the wrong order. **What edf2csv does.** Converts exactly as the header specifies, inversion included. Overriding the header would silently flip the sign of real data. ``` warning: Signal 3 ("inverted") declares physical minimum 100 above physical maximum -100, which inverts its polarity. The values are converted exactly as the header specifies, inversion included. ``` The message names whichever pair is reversed, so a file with its digital bounds the wrong way round says so rather than reporting the physical ones. **What to do.** Check the sign of a feature you can recognise, for example the direction of the R wave on an ECG channel or the polarity of a known artefact. If the polarity is wrong for your purposes, negate the column in your analysis. Don't assume edf2csv corrected it. ### NO_SAMPLES A channel declares zero samples per data record, so it carries no data anywhere in the file. **Cause.** A channel that was defined in the montage but never recorded, or one that was disabled after the header was laid out. **What edf2csv does.** Describes the channel in `channels.csv` with `converted` set to `no`, and leaves it out of the signal files entirely. There's no empty column and no zero-hertz output file. ``` warning: Signal 0 ("ch1") carries no samples at all (0 per data record). It is described in channels.csv but left out of the converted data. ``` **What to do.** Nothing, unless you expected data on that channel. A zero-sample channel is left out of the sampling-rate comparison entirely, so it can't make a single-rate recording look mixed — up to 0.2.4 its nominal 0 Hz was counted as a rate, and a file with one real rate warned that it used "2 different sampling rates (4 Hz, 0 Hz)". The same code also reports the file that was *not* written, when the conversion ends up with no signal table to make at all. Which of the two ways that happened is said explicitly, because the advice differs. Every channel selected carries no samples: ``` warning: No signal file is written: every channel selected carries zero samples per data record, so there is nothing to put in one. channels.csv still describes them. Run with --info to see which channels do carry samples. ``` Or the recording has no signal channels at all, holding only EDF+ annotations — in which case nothing was selected, and `channels.csv` has no rows to 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. ``` Until 0.5.54 the second case got the first case's wording, which said three things about channels to a file that has none. Note that `NO_SAMPLES` is also a fatal error code. As a warning it means one channel is empty, or that no signal file was written; as an error it means every channel is empty, which leaves nothing to convert. See the fatal errors section below. ### EMPTY_LABEL A channel's label field is blank. **Cause.** A writer that didn't fill the field in, or a channel that was never named in the acquisition setup. **What edf2csv does.** Names the column `signal_` using the channel's position in the file, which is stable and unambiguous. ``` warning: Signal 0 has no label. It will appear as "signal_0". The name is built from the position rather than read from the header, so it moves if the file's channels do. Select the channel as --channels "#0" to say which one you mean without depending on it. ``` Unless another channel is literally labelled `signal_0`, which EDF permits — labels are free text and nothing enforces anything about them. Then the synthesised name and the real one collide and both columns are suffixed with their position, and the warning says so rather than naming a column that will not exist: ``` warning: Signal 0 has no label, so it takes the name "signal_0" — which signal 1 already carries as a label, so both columns are suffixed with their position instead. ``` That also covers the second channel, which loses its own column name to the collision. `DUPLICATE_LABEL` does not fire for it, because the two labels are not the same label. **What to do.** Consult your recording notes to work out what the channel is. `channels.csv` gives you the transducer type, prefiltering string, unit and physical range for that signal, which together are often enough to identify it. ### DUPLICATE_LABEL This code is raised in two different situations. **From the header.** Two or more channels in the file share exactly the same label. This is common in real data: some standard EEG montages ship recordings with two channels both labelled `T8-P8`. Labels in EDF are free text and the format doesn't require them to be unique. edf2csv preserves labels verbatim in output and only disambiguates when the file itself is ambiguous. When a label is duplicated, every channel carrying it gets a `_ch` suffix on its column name, where the index is the channel's position in the file. One warning is raised per duplicated label, naming all of the positions involved. ``` warning: 2 signals share the label "T8-P8" (positions #0, #1). Their names are suffixed with the signal number so they stay distinguishable: a column name each in the wide layout, and a distinct value in the channel column under --layout long. ``` **From channel selection.** A `--channels` term matched more than one channel. Matching is case-insensitive on the whole label, so a term that names a duplicated label selects all of them. ``` warning: "T8-P8" matches 2 channels (positions #0, #1); all of them were selected. Use --channels "#0" to pick just one. ``` **What to do.** If you only want one of the duplicates, address it by position with `#N`: ```bash edf2csv recording.edf --channels "#0,ECG" ``` Column names are derived from the whole file, not from your selection, so a given channel always produces the same column name whether you convert one channel or all of them. That means `T8-P8_ch0` is stable across runs and safe to reference in downstream scripts. The suffix is checked against every other label in the file, not just against the one it disambiguates. A file that carries `T8`, `T8` and a third channel genuinely labelled `T8_ch0` would otherwise produce two columns called `T8_ch0`; instead the two that collide take their own positions as well, and the channel that lost its own label is named: ``` warning: Signal 2 is labelled "T8_ch0", which is also the column name another channel's "_ch" suffix produces, so its column is "T8_ch0_ch2". Column names are unique; look this channel up in channels.csv by its signal_index. ``` `time_s` is checked the same way, and it is the one name on that list no file supplies — the writer puts it in front of the channels. A channel labelled `time_s` moves aside for it: ``` warning: Signal 0 is labelled "time_s", which is the name of the time column every signals.csv starts with, so its column is "time_s_ch0". Column names are unique; look this channel up in channels.csv by its signal_index. ``` Until 0.5.113 it did not, and the header came out `time_s,time_s,ECG` with nothing said. Every read-back in these pages — `index_col="time_s"`, `pop("time_s")`, `pivot(index="time_s")` — resolves a repeated name to one of the two columns without saying which, and pandas and Python's own `csv.DictReader` resolve it opposite ways round. No two columns in `signals.csv` ever share a name, so the `channels.csv` join always resolves. ### TIME_RESOLUTION Sample times are written to at most fifteen decimal places, which separates everything a terminating rate can reach — every power of two through 32768 Hz, and far past it. Only a rate whose reciprocal never terminates, and whose interval is finer than fifteen places, makes the column repeat. **Cause.** A sample interval finer than fifteen decimal places can express, and with no terminating expansion to find — 3e15 Hz, say, where 1/3e15 repeats forever. A rate that terminates gets as many places as it needs up to fifteen, so every power of two through 32768 Hz is written exactly; one that repeats gets as many as it takes to keep consecutive samples apart, to the same limit. EDF's record-duration field is 8 characters and accepts `1e-15`, so the format permits these rates; nothing that records biosignals comes within nine orders of magnitude of them. ``` warning: Channels at 3000000000000000 Hz sample faster than the time column can distinguish, so consecutive rows in signals.csv carry the same time_s value. Every sample is written, in order. Use the row number rather than time_s to tell them apart: the column already carries the fifteen places a double can hold exactly, so no option or selection separates them. ``` The same code covers the limit of that, where the rate is not a number at all. A sampling rate is samples per record divided by the record duration, and the record-duration field accepts values small enough that the quotient overflows a double: four samples in a 1e-308 second record is `Infinity`. Those samples cannot be placed in time, so none is written, and the warning says which of the two happened: ``` warning: Channels in signals.csv work out to a sampling rate of Infinity Hz — their samples per record over a record duration too small to divide into — so their samples cannot be placed in time and no rows are written for them. Check the record duration in the header. One power of ten larger and the same file converts, with consecutive rows carrying the same time_s. ``` Until 0.5.84 nothing was raised for it: `1 / Infinity` is zero, the check tests for a step greater than zero, and every sample was dropped in silence — under an `EMPTY_WINDOW` warning saying the records "carry no samples in range", on a run that asked for no range. Up to 0.5.23 this section gave the bound as nine places and a gigahertz, and showed that warning at 10 GHz — a rate that in fact terminates at ten places and is written exactly. Both were true before 0.4.55 raised the search bound. **What edf2csv does.** Writes every sample, in file order. Nothing is dropped — what stops being true is that `time_s` identifies a row, so joining or plotting on it collapses samples that are genuinely distinct. **What to do.** Use the row number. Until 0.4.55 this went further than a repeated column: the boundary slack used when deciding which samples fall inside the requested window was a flat nanosecond, larger than the sample interval itself, and a recording of two 1 ns records holding ten samples each wrote ten of its twenty rows with no warning at all. ### VALUE_RESOLUTION The same failure as `TIME_RESOLUTION`, one column over: the value this time rather than the time. **Cause.** A channel whose quantization step — `|physical_max - physical_min| / (digital_max - digital_min)` — is below 1e-98. Decimals are derived per channel as `ceil(-log10(step)) + 2`, and 100 is where that stops, because 100 is the most `toFixed` will print. EDF's physical bound fields are 8 characters and `1e-99` is five of them, so the format permits such a calibration; no instrument produces one. ``` warning: gravimeter steps by less than any number of decimals this can print, so some consecutive samples round to the same value in signals.csv. Every sample is written, in order, and the physical values are computed at full precision either way. What is lost is only in the printed text. ``` Asked of the ceiling, not of the precision in use — so it holds whatever `--decimals` says, and it stays quiet for an ordinary channel however coarse a precision you ask for. `--decimals 2` on a channel needing 3 is a trade you made knowingly; up to 0.5.10 it raised this on every channel of an ordinary EEG, which also made `--decimals 2 --strict` impossible, since `--strict` turns any diagnostic into a non-zero exit. 0.5.10 fixed that by skipping the check whenever `--decimals` was given, and so silenced the real case too: at `--decimals 20` a channel stepping by 1e-106 printed every code it had as `0.00000000000000000000` and said nothing. 0.5.21 asks the question that actually matters — whether any precision this can print would separate consecutive codes. **What edf2csv does.** Writes every sample, at the finest precision `toFixed` supports. The physical values are computed at full double precision whichever way — what is lost is only in the printed text, so `--json` metadata, row counts and ordering are all unaffected. **What to do.** Nothing, for any real recording. If you are generating such a file deliberately, the digital codes are still in the EDF and reading them directly is exact. This warning did not exist until 0.4.74, and the clamp it reports was 20 rather than 100 — set there on the stated grounds that 20 was `toFixed`'s limit, which it is not. A magnetometer channel spanning ±1e-16 T over a 16-bit converter steps by 3.05e-21 and needs 23 places; at 20 its values landed on a 1e-20 grid, about three digital codes to a printed value, and 69% of them could not be recovered from the CSV. It exited 0 and said nothing. ## Timing, continuity and annotations These come from reading the EDF+ annotation channel and working out where each data record really sits in time. `--info` raises `DISCONTINUOUS` too, since it has to read those record times to report the span and the row estimate correctly; the other two need a conversion, which is the only thing that reads every annotation. ### DISCONTINUOUS This code covers six related conditions, and a single file can raise more than one of them. **The recording is marked discontinuous.** The header's reserved field says `EDF+D` (or `BDF+D`), meaning the data records need not be contiguous in time. Sleep studies with paused acquisition and long-term monitoring with interrupted telemetry both produce these. ``` warning: This recording is marked discontinuous (EDF+D): its data records need not be contiguous in time. Each row carries its true recording time, so gaps stay visible instead of being closed. ``` The sentence is about the marker, not about the records: this warning is raised by the header parser, which has read none of them, and a file marked `EDF+D` whose records happen to sit end to end is unusual and perfectly legal. Until 0.9.6 it said "its data records are not contiguous in time" — and on such a file `--info` omits the `Time span` line, because the span and the duration agree, three lines above a warning saying they do not. Where the records really do contradict their marker it is reported where the evidence is, which is what the `EDF+C` condition below does. That hint is withdrawn on a file that cannot keep it. A recording marked `EDF+D` whose record times are not recorded anywhere — no annotation channel, or none that can be read — is written as if contiguous, and the warning says so and points at the one below it. Until 0.5.106 both printed as they are, and the second denied the first. edf2csv reads each record's true start time from its timekeeping annotation and writes that time into the `time_s` column. A gap in the recording becomes a jump in `time_s`, exactly as it should. This is the behaviour that distinguishes edf2csv from the common alternatives: `mne.io.read_raw_edf` closes these gaps silently, and pyEDFlib refuses `EDF+D` files outright. **Marked discontinuous, but there's no annotation channel.** The record start times are stored in the annotation channel, so a file with no annotation channel has no record of where its records sit. edf2csv falls back to timing the records as if they were contiguous and says so. Any gaps are lost, because the file doesn't contain the information needed to reconstruct them. **Records start earlier than the record before them.** The timekeeping annotations aren't monotonically increasing. ``` warning: 1 data record starts earlier than the record before it. Rows are written in file order, so the time column will not increase monotonically. ``` Rows are written in file order, not sorted by time, so `time_s` will step backwards at those points. **The file is marked continuous, and its own records disagree.** `EDF+C` means the records sit end to end, and each record's timekeeping annotation says where it really is. When those two disagree by more than the recording can express, the file is contradicting itself. ``` warning: This file is marked continuous (EDF+C), but 2 of its 3 data records say they start somewhere other than where continuity puts them. Times are written as if the records were contiguous, which is what EDF+C means. If the recording really has gaps, the file should have been marked EDF+D. ``` A BDF+ file gets its own spelling — `BDF+C` and `BDF+D` — the same as the discontinuous entry above. Until 0.5.105 this half of the code printed the EDF markers whatever the format, so a BDF+ recording was told about a string it does not contain and pointed at a marker BDF+ does not define. Compared against what the file can express rather than for equality, since a recording of 0.1 s records sitting at 0.1, 0.2, 0.3 is contiguous by construction and `0.1 + 2 * 0.1` is `0.30000000000000004`. Anything below half of one sample of the fastest channel is arithmetic, not a gap. **Records start before the record before them ends.** The timekeeping annotations increase, but not by as much as a record lasts, so consecutive records cover overlapping spans of time. A device re-sending a buffer produces this. ``` warning: 2 data records start before the record before them ends, so their samples overlap in time. Rows are written in file order, so the time column will not increase monotonically. ``` Until 0.5.25 only the strictly-backwards case above was looked for, so this went unreported: starts of 0, 0.5 and 1.0 on one-second records are increasing, and the column steps backwards anyway, because the first record's samples run to 0.75 while the second begins at 0.5. **The stated origin is too far from zero for the file's own sample interval.** A double spaces its values further apart the larger they get: near 1e16 seconds the gap between representable numbers is two seconds, so adding a one-second sample interval leaves the number unchanged and every sample in a record lands on one instant. ``` warning: This recording's timekeeping annotations place it -10000000000000002s from its own start date, which is too far out for its 1s records to be told apart: at that magnitude adding a sample interval leaves the number unchanged. Sample times are written from zero instead, so every row is present and the column increases. Add the onsets in annotations.csv to recover absolute times if you need them. ``` The magnitude is what matters, not the sign — a negative origin the same distance out fails identically. Until 0.5.17 the check looked only in the positive direction, seeded from zero, so an all-negative recording never reached it: twelve rows became four, exit 0, and nothing was said, while the byte-for-byte positive mirror of the same file wrote all twelve and explained itself. **What to do.** For the first case, nothing: gaps in `time_s` are real and your analysis should respect them. Don't assume a fixed sample interval when converting a discontinuous file. For the second case, treat all timestamps as nominal offsets rather than true recording times. For the third, the file is contradicting itself and its timestamps should be treated as suspect until you know which half is wrong. For the fourth and fifth, either sort by `time_s` in your analysis or investigate the file, since out-of-order or overlapping records usually mean the annotations were written incorrectly. For the sixth, nothing needs doing — every row is written and `time_s` is measured from the start of the recording rather than from an origin the arithmetic cannot hold — but the absolute timestamps in that file's annotation channel should be treated as unreliable, since the file is claiming a position no double can express at that resolution. ### ANNOTATION_DECODE_FAILED This code covers five conditions, which are counted separately because they lose different things. **Annotation entries couldn't be decoded.** The annotation channel stores text as a run of Time-stamped Annotation Lists, each beginning with an explicitly signed onset. A chunk that doesn't begin with `+` or `-`, or whose onset isn't a finite number, can't be decoded. ``` warning: 1 annotation entry was unreadable and could not be exported. Every entry that could be read is exported. The file may have been written by a non-conforming tool. ``` edf2csv skips the bad entry and keeps going. A single malformed annotation shouldn't cost you a whole conversion, but losing it in silence would mean you never learn that an event is missing from `annotations.csv`. Until 0.9.16 the hint read "The rest were exported normally", which asserts there was one. A writer that cannot state an onset tends not to manage it anywhere, so a file whose every entry is unreadable is the ordinary way to reach this — and the reassurance sat four lines above a summary reading `annotations.csv 0 rows`. The sentence says what it was for instead, which holds however many entries were readable: nothing that could be read was dropped. Saying it by counting would have split `--info` from a conversion, since the bounded scan behind `--info` never counts the events at all. **A record's timekeeping entry couldn't be decoded, in a continuous file.** The first entry of every record states where that record sits in time rather than describing an event, so it is never exported. Until 0.4.41 these were counted with the events above, which described the wrong loss twice: a file with one unreadable timekeeping entry and three good events announced that one entry "could not be exported" while exporting all three, and said nothing about the timing that had actually gone missing. ``` warning: 1 data record carries a timekeeping annotation that could not be read, so it does not say where in time it sits. No event was lost — a timekeeping annotation states a record's start time and is never exported. Times are derived from the records that could be read. ``` In a continuous recording the records sit end to end, so any record that *can* be read fixes the origin for all of them: a record stating 1.5 s in a file of one-second records puts the recording's start at 0.5 s. Only if no record at all states a time does the file fall back to being timed from zero. A first entry may also carry events after the start time — the format allows both in the one entry, and writers use it. When one of those cannot be decoded, the events go with it, so it is counted in the entries above as well and the hint says so rather than denying it: ``` warning: 2 annotation entries were unreadable and could not be exported. Every entry that could be read is exported. The file may have been written by a non-conforming tool. warning: 2 data records carry a timekeeping annotation that could not be read, so they do not say where in time they sit. 2 of them also carried event text, which went with them and is counted above. A timekeeping annotation itself states a record's start time and is never exported. Times are derived from the records that could be read. ``` Until 0.5.114 such an entry was counted only as lost timekeeping, so a file whose first entry read `+1,5` rather than `+1.5` exported two of its six events under a warning saying that none had been lost. **Records carry no readable timekeeping annotation.** In a discontinuous file, the first annotation entry of each record must carry that record's start time. When it's missing or unreadable, that record's true position in time is unknown. ``` warning: 1 of 3 data records carries no readable timekeeping annotation (record 2), so its true position in time is unknown. That record is timed as if it were contiguous; treat its timestamp as unreliable. ``` Up to eight record indices are listed by number, and the rest are counted rather than elided — `... 8, 9 and 2 more`, the same cut every other list in a message here takes. The affected records are timed arithmetically as a fallback, and this warning exists precisely because that fallback produces a timestamp indistinguishable from a real one. **An event's duration couldn't be read.** A TAL may state a duration after its onset, separated by `0x15`. When that text isn't a number — `abc`, `1e400`, which overflows to infinity, or `0x10`, which JavaScript reads as sixteen and EDF+ never writes — the event is kept whole apart from that one field, and `duration_s` is written empty. ``` warning: 1 annotation states a duration that is not a number, so its duration_s cell is empty. The onset and the description were read normally. An empty duration_s otherwise means the file stated no duration, so these rows cannot be told apart from those. ``` The hint is the reason this is counted at all: an empty `duration_s` is documented as meaning the file gave no duration, so without the count these rows are indistinguishable from the ones that genuinely had none. Before 0.5.55 nothing was raised. **An event's duration is below zero.** A duration is a length of time, and one below zero is not one. The value is written to `annotations.csv` exactly as the file gave it — a zero invented here would be a number no writer wrote — so nothing about the row looks wrong on its own. ``` warning: 1 annotation states a duration below zero, which is not a length of time. The value is written to annotations.csv as the file gave it. Adding it to onset_s ends the event before it starts, so check these rows before using the durations. ``` Counted apart from the condition above because that one failed to parse and lost its value, while this one parsed and kept it: what is wrong with it is arithmetic. A duration of exactly zero is not negative and raises nothing. Before 0.5.58 nothing was raised. **What to do.** Compare the number of rows in `annotations.csv` against the number of events you expect. If entries are missing that you need, the recording may have to be re-exported by the acquisition software. For the timekeeping case, treat the timestamps of the named records as unreliable and, if the exact timing matters, exclude those records from analysis. ### STDOUT_UNSUPPORTED Raised only by `--info --stdout`, on a recording the conversion would refuse. **Cause.** `--stdout` writes one table, and the wide layout gives a mixed-rate recording one per rate. It also has nothing to stream for `--annotations-only`, or for a recording with no signal channels. **What edf2csv does.** Says so, in the words the conversion itself would use, and goes on describing the recording: ``` warning: --stdout would refuse this run: needs exactly one table, but this recording produces 3, one for each sampling rate its channels use (256 Hz, 128 Hz, 1 Hz). Narrow it to one rate with --channels, write --layout long to get them all in one table, or convert to a directory instead. ``` A warning rather than a refusal for the reason the destination guards are: `--info` writes nothing, so a rule about the output has no business stopping it from describing the recording — and being told the command will not work is exactly what was asked. Until 0.5.87 `--info` ignored `--stdout` entirely and predicted rows and named files for a command that writes neither. The report body says it too. The estimate under the channel table read `Would write 1,155 rows, roughly 22.2 KB.` until 0.9.34 — of a run that exits 1 having written nothing — on the line under the paragraph explaining that `--stdout` cannot write it. The figures are still worth having, so the subject is corrected rather than the line dropped: `That conversion would write 1,155 rows, roughly 22.2 KB; --stdout writes none of them.` Since 0.9.46 the fourth refusal is previewed too: `--stdout` writes one CSV and so takes one recording, and `edf2csv ./study --info --stdout` used to answer "error: --stdout writes a single CSV, so it cannot take 40 recordings" and describe none of the forty — in the mode whose purpose is surveying a folder before converting it. How many recordings there are is a fact about the input exactly as the rate count is, so it now warns once and goes on describing every recording. Under `--json` it stays a refusal: that mode keeps every warning inside the document and writes one document per recording, so a sentence about how many there are has nowhere to go. The flag contradictions — `--stdout` with `--out`, `--force` or `--checksum` — stay refusals under `--info` as well, being errors in the command line itself rather than facts about the input. **What to do.** Take the advice in the hint, or drop `--stdout`. Nothing is wrong with the recording. ### MISSING_EDF_PLUS_MARKER The file has an annotation channel whose timekeeping says the records begin at a non-zero instant, and a reserved field with neither `EDF+C` nor `EDF+D` in it. **Cause.** A writer that produced EDF+ content and left the marker off, or a file whose reserved field was overwritten. The marker is what makes a file EDF+; the annotation channel is found by its label. **What edf2csv does.** Reads it as plain EDF, which is what the marker says: `time_s` counts from zero. The annotation channel is still found and its events still exported, with the onsets the file gives them — so the two files come out on clocks that differ by the origin: ``` warning: This file has an annotation channel stating that its records begin at 1000s, but its reserved field carries no EDF+C or EDF+D marker — so it is read as plain EDF, time_s counts from zero, and the two disagree by 1000s. annotations.csv keeps the onsets the file gives, so its events and signals.csv are on different clocks. Mark the file EDF+C, or subtract the offset from the onsets, before joining them. ``` `--info` raises it too. Until 0.7.53 it did not: it read the annotation channel only for a file that claimed to be EDF+, which is every file except the ones this warning is about — so a conversion said the two CSVs would be a thousand seconds apart and the command you run first, to find out what a conversion would say, said nothing at all. Until 0.5.104 nothing was raised: `signals.csv` opened at `0.000`, `annotations.csv` put the event at `1000.5`, and the pages promise the opposite — "`onset_s` is on the same clock as `time_s` in the signal files". Reported rather than repaired, because which clock is right is not knowable from inside the file. The marker says plain EDF and the annotation channel says otherwise; applying the origin would move every sample, and ignoring the onsets would move every event, each on a guess about which field was written wrongly. **What to do.** Fix the reserved field if the recording really is EDF+, which is the likely case — the annotation channel is not something a plain EDF writer produces. Otherwise subtract the offset from the onsets before joining the two files. ### START_TIME_UNREADABLE The header's start date and time fields do not parse as a date and a time. **Cause.** EDF gives each of them eight characters and nothing enforces what goes in. A writer that leaves them blank, fills them with placeholders, or writes them in another order produces this. **What edf2csv does.** Says so, and carries on — the fields are echoed raw, and `metadata.json` records `start_datetime_local` as `null`: ``` warning: The header's start date and time ("32.13.99" and "25.61.61") are not a date and a time, so the recording has no start instant. time_s is unaffected — it counts from the start of the recording either way. What cannot be done is turning it into a wall-clock instant, and metadata.json records start_datetime_local as null. ``` Until 0.5.101 nothing was raised: `--info` echoed the fields with "(unparseable)" beside them and a conversion said nothing at all, so a recording with no usable timestamp passed `--strict` and left a bare `null` in the archive. Every other unusable header field reports itself. **What to do.** Nothing, unless you need wall-clock times. `time_s`, the sample values and the annotation onsets are all unaffected — they are relative to the recording's own start, which does not depend on the header's saying when that was. If you do need the instant, it has to come from outside the file. ### START_DATE_MISMATCH The recording identification field states a start date that is not the header's start date. **Cause.** EDF+ requires the recording identification field to begin `Startdate dd-MMM-yyyy` and requires that date to be the one in the header's own start date field. A writer that filled the two independently, or a file whose date field was edited afterwards, produces a header that contradicts itself. **What edf2csv does.** Uses the start date field, which is the one the format defines, and says so. The four-digit year in the recording identification is what settles the century when the two *do* agree — see [the two-digit year](/docs/edf-format#start-date-and-time-and-the-two-digit-year) — so a disagreement is exactly the case where that corroboration is missing. ``` warning: The header's start date ("02.03.02") and the date its recording identification states ("05-MAR-2002") are different dates, which EDF+ does not permit. The start date field is used, since that is the one the format defines. Which of the two is right is not knowable from the file, so start_datetime_local may name the wrong day. ``` **What to do.** Treat the recording's date as uncertain. Nothing else is affected: `time_s`, the sample values and the annotation onsets are all relative to the recording's own start, whatever day that was. ### LEAP_SECOND_START The header's start time names the sixtieth second of a minute. **Cause.** UTC writes a leap second as `23.59.60`, and a recorder synchronised through one puts that in the header. It is a real instant. It is not a time a calendar date has, and it is not one JavaScript's `Date` can hold: asking for the sixtieth second rolls the value into the next minute. **What edf2csv does.** Keeps the nearest instant a date can hold — the fifty-ninth second, one second earlier than the header says — and says so. Rolling forward instead would move it fifty-nine seconds the other way, and refusing the whole field would throw away a date that is otherwise perfectly good. ``` warning: The header's start time ("23.59.60") names the sixtieth second of a minute, which no calendar date has. It is recorded as the fifty-ninth second, one second earlier, since that is the nearest instant a date can hold. time_s is unaffected — it counts from the start of the recording either way. ``` Until 0.7.52 the second was dropped in silence: `--info` printed `Recorded 2020-01-01 23:59:59` and `metadata.json` recorded the same, for a header that says something else, with `--strict` exiting 0. **What to do.** Nothing, unless a second matters to you at that instant. `time_s`, the sample values and the annotation onsets are all relative to the recording's own start and are unaffected; only `start_datetime_local` moves, and only by that second. ### NO_ANNOTATIONS There are no events to export. Raised in three situations: the recording has no annotation channel at all, it has one that carries no events, or a requested window excluded every event it does carry. **Cause.** Plain EDF and plain BDF carry no annotations at all. Only EDF+ and BDF+ files have an `EDF Annotations` or `BDF Annotations` channel. A file that has one may still hold nothing but the timekeeping entries that say where each data record sits, which are never exported. And `--start`, `--end` and `--duration` filter events by onset, so a window can select none of them. **What edf2csv does.** Writes `channels.csv` and `metadata.json` but no `annotations.csv` and no signal files, because you asked for annotations and there are none. The command still exits 0. `--info --annotations-only` raises the same warning without converting anything, so `--strict` catches it either way. ``` warning: --annotations-only was requested but this recording has no annotation channel, so there are no events to export. Plain EDF files carry no annotations. Convert without --annotations-only to get the signals. ``` The other two are raised where the run has no signal table either, so the empty `annotations.csv` is the whole of what it produced. A window that selects no events from a conversion that is also writing signals is ordinary and is not warned about — it would fire on most windows of most annotated recordings. ``` warning: None of this recording's 3 events fall inside the requested window, so annotations.csv holds its header and no rows. --start and --end are read on the recording's own clock, which starts at 0s unless --info shows a "Timed from" line, and an event is kept when its onset falls inside the window. warning: This recording's annotation channel carries no events, so annotations.csv holds its header and no rows. The channel holds only the timekeeping entries that say where each data record sits, and those are never exported. ``` `--info` predicts these two only for a discontinuous recording, whose whole annotation channel it reads to find the record start times. A continuous one is read as far as its origin and no further, so the count is not in hand and `--info` does not guess at it — the same reason its `annotations` field is `null` there. **What to do.** Run `--info` and look at the `Format` line. If it says `EDF` rather than `EDF+`, there were never any events to extract. Convert without `--annotations-only` to get the signal data: ```bash edf2csv recording.edf ``` If the warning is about a window, check `--info`'s `Timed from` line: a recording whose first record sits at 1000 s is asked for with `--start 1000`, not with `--start 0`. ## Output shape These describe what the conversion is about to produce, rather than a problem with the recording. ### MIXED_SAMPLING_RATES The recording's channels don't all run at the same sampling rate. **Cause.** Normal and extremely common. A sleep study typically records EEG at 256 Hz, ECG at 128 Hz and a rectal temperature probe at 1 Hz, all in one file. **What edf2csv does.** Writes one file per rate: `signals_256hz.csv`, `signals_128hz.csv`, `signals_1hz.csv`. A fractional rate becomes something like `signals_12_5hz.csv`. No channel is resampled. Under `--layout long` they share one file instead, still without resampling, and the hint below reads "They share one table, each row carrying its own time, so no channel is resampled." Since 0.9.42 that half gives way to "which the requested window leaves holding its header and no rows" where the window selects no samples, since there are then no rows to carry anything; the wide half is unchanged, because a window holding nothing still writes one file per rate. ``` warning: Channels use 3 different sampling rates (256 Hz, 128 Hz, 1 Hz). They are written to one file per rate so no channel is resampled. ``` **What to do.** Load the files you need. Each has its own `time_s` column, so joining them is a matter of aligning on time. When every channel shares a rate, a single `signals.csv` is written and this warning doesn't appear at all. **It describes the conversion, not the file.** `--channels` is taken into account, because the warning exists to explain why the output was split. Narrowing a three-rate recording to one channel writes one file and raises nothing; narrowing it to two rates reports two, not three. ```bash edf2csv sleep-study.edf --channels "EEG Fpz-Cz" # one channel, one file, so no rate warning — but this recording is eight hours # at 100 Hz, and the other warning it raises does not go away with it: # warning: At least one output file will have more than 1,048,576 rows, which is more than Excel or Numbers can open. edf2csv sleep-study.edf --channels "EEG Fpz-Cz,Temp rectal" # warning: Channels use 2 different sampling rates (100 Hz, 1 Hz). # warning: At least one output file will have more than 1,048,576 rows, which is more than Excel or Numbers can open. ``` `parseHeader` is the exception, and deliberately: it reports what the header says, having no conversion to describe. ### NO_SIGNAL_CHANNELS The file has no signal channels: it contains only an EDF+ annotations channel. **Cause.** Some systems export events into a separate companion file alongside the recording proper. **What edf2csv does.** Writes `annotations.csv`, `metadata.json`, and a `channels.csv` containing only its header row. No signal files are written, because there are no signals. ``` warning: This file has no signal channels; it contains only EDF+ annotations. An EDF+ file of events and no signal is an ordinary thing: a scoring file distributed beside the recording it annotates has exactly this shape. 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. ``` The second is `NO_SAMPLES`, raised beside it by the conversion rather than by the header: one says what the file is, the other what the run therefore did. A BDF+ recording says `BDF+ annotations`, since `BDF Annotations` is the label its channel actually carries. **What to do.** Nothing, if you were after the events. If you expected signal data, you're converting the wrong file of the pair. ### LARGE_OUTPUT At least one output file will have more than 1,048,576 rows, which is the limit for Excel and most other spreadsheet applications. **Cause.** Recording length. A single channel at 256 Hz crosses the limit after about 68 minutes. **What edf2csv does.** Nothing differently. The file is written in full and is a valid CSV. The warning exists so you aren't surprised when a spreadsheet opens it and shows only the first million rows. ``` warning: At least one output file will have more than 1,048,576 rows, which is more than Excel or Numbers can open. Use --start and --duration to convert a section, or read the file with pandas or R. ``` `--info` reports the estimate before you convert anything: ``` Would write 1,075,200 rows, roughly 24.6 MB. ``` **What to do.** Either read the file with a tool that has no row limit (pandas, R, DuckDB, awk), or convert only the section you need: ```bash edf2csv sleep-study.edf --start 2h --duration 30m ``` ### STALE_OUTPUT The output directory contains files that edf2csv produced on an earlier run and didn't rewrite on this one. **Cause.** `--force` overwrites files but doesn't empty the directory. Converting a mixed-rate recording and then a single-rate one into the same place leaves `signals_256hz.csv` sitting next to a fresh `signals.csv`, and both look current. **What edf2csv does.** Names the leftover files and deletes nothing. Only files matching the names edf2csv itself produces are considered: `signals.csv`, `signals_hz.csv`, `annotations.csv`, `channels.csv` and `metadata.json`. Your own files in that directory are never reported and never touched. ``` warning: signals_128hz.csv, signals_1hz.csv, signals_256hz.csv are left over from an earlier conversion into this directory and were not rewritten. Delete them, or convert into a fresh directory, so the two runs do not get mixed up. ``` A directory that has been converted into several times can hold a great many of these — a mixed-rate recording writes one file per rate — so past eight the rest are counted rather than named, the same as every other message here that lists something the run does not control. One leftover reads as one, in the advice as well as in the sentence above it. **What to do.** Delete the named files yourself once you've confirmed you don't need them, or convert into a fresh directory with `--out`. `metadata.json` always describes the run that wrote it, so its `conversion.files` list is the authoritative record of which files belong to the current conversion. ### EMPTY_WINDOW The conversion had signal tables to fill and put no data rows in any of them, so `signals.csv` holds its header and nothing else. A window can select nothing without being past the end of the recording — `--start` at or past the end is a usage error and stops the run before this. This is the narrower case: a window that lies inside the recording but lands where there is no data. The commonest reason is that the window is thinner than the gap between two samples. Sample times sit on a grid of `1 / rate`, so a window at least one interval wide always contains one — and a window narrower than that need not. On a 10 Hz recording, whose samples are 0.1 s apart: ``` warning: No samples fall inside the requested window (1.950s to 2.000s), so the signal file holds its header and no data. It is 0.050s wide and the fastest channel here samples every 0.1s, so no sample time falls inside it. Widen it to at least one sample interval, or convert more of the recording and take the row nearest the moment you want. ``` The fastest channel is the one named, since its grid is the finest: if none of its samples fits, none of a slower channel's does either. The other reason needs a discontinuous recording. On one whose records sit at 0s, 1s and 10s, anywhere in the eight-second gap: ```bash edf2csv study.edf --start 2 --end 10 # asks for a span that holds no records at all ``` ``` warning: No samples fall inside the requested window (2.000s to 10.000s), so the signal file holds its header and no data. The window is inside the recording but lands where there is no data — inside a gap in a discontinuous file, or past the last sample. Convert without --start and --end and read time_s to see where the records actually sit. ``` ### EMPTY_RATE_WINDOW One rate's file holds its header and no rows while the rest of the conversion has data. **Cause.** A rate group is what gets a file, and the same window can hold samples of one rate and none of another: a channel sampled once a second has a sample every 1s, so `--start 0.1 --end 0.4` falls between two of them while a 4 Hz channel in the same recording keeps one. Until 0.9.43 nothing was said about it — the summary listed a file with `0 rows` and the run exited 0 and passed `--strict`, where the same empty file arrived at through a window that empties *every* rate raises `EMPTY_WINDOW` and fails it. ``` warning: No samples fall inside the requested window at 1 Hz, so signals_1hz.csv holds its header and no data. The window does hold samples at 4 Hz. A window narrower than a channel's sample interval can fall between two of its samples, and the slower the channel the wider that gap is. ``` **Its own code, not `EMPTY_WINDOW`.** That one means the run produced nothing, which is what a script watching for a useless conversion matches on. This one means the run produced something and one of its files came out empty; coding them the same would have that script quarantine a conversion that worked. **What edf2csv does.** Writes the file, header and all, so the set of files a mixed-rate conversion produces does not depend on the window. Named as they will be written, so `--gzip` names the compressed file and `--stdout` names the stream. **What to do.** Widen the window to at least one sample interval of the slowest rate named, or convert with `--layout long`, where every rate shares one table and a rate with no samples in the window costs it rows rather than a file. `--channels` narrowing the run to the rates you want also removes it, since the warning is about the files this run writes. Only the wide layout raises it. **What to do.** Convert the whole recording and read `time_s`, which carries one true time per row — `--info` gives the count, the duration and a time span that "includes discontinuities", but not the positions. On an EDF+D file the gaps are the point: the row times are true recording times, so a window chosen from wall-clock arithmetic can miss the data entirely. It is a warning rather than an error because a batch of five hundred recordings shouldn't stop for the one whose gap lines up with the window. Pass `--strict` to make it a failure. ### NONPRINTABLE_LABEL One of a channel's four free-text header fields — label, unit, transducer or prefiltering — contains control characters. The warning names which, because what it costs is not the same: a label becomes the channel's name in `signals.csv` — a column name in the wide layout, and a value in the `channel` column of every row under [`--layout long`](/docs/cli-reference#--layout) — while the other three are cells of `channels.csv` and nothing else — and under `--stdout`, which writes no `channels.csv`, they reach nothing at all, which is why the sentence names the conversion that writes one. **Cause.** A writer that copied a field out of another system without sanitising it, a header edited by a script, or a corrupt file whose label bytes are not text at all. EDF fields are free text and nothing enforces that they are printable. ``` warning: Signal 0's label and unit contain 2 control characters (\x1b), which will appear as the channel's name in signals.csv and in channels.csv's unit cell in any conversion that writes one, exactly as the header has them. Address the channel by position with --channels "#0" rather than by name, since the name cannot be typed. Printing the CSV to a terminal may do more than print it. ``` When only a cell field carries them, the column name is untouched and the channel can still be selected by name — unless it has no label, or its label contains a comma, which [`--channels`](/docs/cli-reference#-c---channels) would read as two names. In both of those the position is the only way in, and the hint says that instead. Whichever branch it takes, the command it prints is one that runs. The cell is named too, since `channels.csv` has fourteen columns: ``` warning: Signal 0's unit contains 1 control character (\x07), which will appear in channels.csv's unit cell in any conversion that writes one, exactly as the header has it. The column name is unaffected, so --channels "ECG" still selects it. Printing the CSV to a terminal may do more than print it. ``` **What edf2csv does.** Passes the label through exactly as the header has it. Losing what the file says is not an improvement, and CSV quoting keeps the row parseable whatever the bytes are — the warning exists so that you know, not because anything is rewritten. `--info` is the exception: it escapes them for display, since an ANSI escape in a header could otherwise drive your terminal. The same goes for paths, which the filesystem supplies and nobody vets — a directory named with an ESC byte, or a file name holding a newline, is escaped everywhere edf2csv prints it, so a summary line stays one line and stays inert. Refusals were the exception until 0.8.92: they are assembled from lines, and the printer splits a message on its own line breaks before escaping each one — so every control byte in a quoted value was escaped except the one that makes a line break, and `edf2csv "recording.edf"` answered with `error: Cannot read "re` and the rest of the sentence on the line below. The same byte in a `--channels` term and in `--out` did it too. Until 0.5.102 only the label and the unit were checked. `transducer` and `prefiltering` are header text of exactly the same kind and land in `channels.csv` exactly as the unit does, so an ESC byte in a transducer reached the CSV with nothing said, and `cat channels.csv` would drive the terminal — the hazard this warning exists for, two columns over. **What to do.** When the label is affected, address the channel by position (`--channels "#0"`) rather than by name. Be careful about printing the CSV to a terminal — `\x1b[2J` clears the screen, so `cat signals.csv` can hide the rest of your session's output. `head`, `less -R` off, or opening the file in an editor are all safe. A tab (`\x09`) is harmless to a terminal but still makes a column name that is hard to match reliably in a script. Raised for every affected channel, so a file with three of them gets three warnings. Since 0.8.0 an annotation's `description` is checked too, and counted rather than raised per event. It is the only free text in the output that can hold a character above U+00FF: header text is decoded latin1, so every byte of it becomes a code point below U+0100, while a description is UTF-8. So it is also the only one that can carry a bidirectional override — U+202A to U+202E, U+2066 to U+2069 — which does not drive the terminal but tells it to display everything after it right to left, so `stage-\u202efdp` arrives on screen as `stage-pdf`. The marks U+200E and U+200F are not flagged: they reorder the neutral characters beside them rather than a run, and they are how a right-to-left description is ordinarily written. ``` warning: 2 annotations have descriptions carrying text a terminal does not print as itself (\x1b, \u202e), written to annotations.csv exactly as the file has them. A control byte can drive the terminal and a bidirectional override reverses what follows it, so read the file with pandas or R rather than with cat. The cell is what the recording says either way. ``` ### FORMULA_LABEL One of a channel's four free-text header fields starts with `=`, `+`, `@` or `-`. Excel, LibreOffice and Google Sheets read a cell beginning with any of those as the start of a formula rather than as text, whatever file it arrived in — so a channel labelled `=1+1` opens as a column headed `2`, one labelled `-2+3` as a column headed `1`, and one labelled `=HYPERLINK("http://...","EEG")` as a link nobody in the reading chain wrote. **Cause.** EDF's label, unit, transducer and prefiltering fields are free text, and nothing in the format says they may not look like a formula. Usually that is a header written by a script that pasted a computed name in; it is also the shape a deliberately hostile recording would take, which is why the [security policy](https://github.com/tayal-sarthak/edf2csv/blob/main/SECURITY.md) already treats these four fields as attacker-controlled. ``` warning: Signal 0's label starts with =, which Excel, LibreOffice and Google Sheets read as the start of a formula rather than as text. The text is written exactly as the header has it, so the cell is what the recording says. Open the CSV with pandas or R, or import it into the spreadsheet as text, if you do not want it evaluated. ``` **What edf2csv does.** Writes the field exactly as the header has it, and says so. The usual mitigation is to prefix the cell with an apostrophe, and that means writing something the recording does not contain — the one thing this tool does not do. `NONPRINTABLE_LABEL` answers control bytes the same way, for the same reason. The two signs have two exceptions each, and until 0.7.62 the minus was one big one — nothing with a leading `-` was flagged at all, on the reasoning that a lone `-` is a real convention for "no unit" and appears in the test recordings. That is true of a lone `-`, which every spreadsheet leaves as text, and of a field that is entirely a number, which reads as that number and so says what the header says. It is not true of anything else after the minus: `-2+3` is arithmetic and `-A1` is a name, and both are evaluated. Those are named now; the two cases the exception was written for still are not. A leading `+` takes the same two exceptions, which until 0.7.106 it did not. The rule in the spreadsheet is one rule — Lotus compatibility converts a leading `+` or `-` to a formula when what follows one parses as a formula, and leaves it as text when it does not — so the sign cannot decide it. `+100` opened as 100 and was warned about; `-100` beside it said nothing, and under `--strict` the difference was an exit code. `+1+1` is arithmetic and is still named. **What to do.** Read the CSV with pandas, R or any CSV library, none of which evaluate anything. If it has to go into a spreadsheet, use its text-import path rather than opening the file directly — in Excel that is Data → From Text/CSV with the column set to Text, and in LibreOffice the import dialog with "Evaluate formulas" off. `--channels` still selects the channel by its literal name. Raised for every affected channel, so a file with three of them gets three warnings. Since 0.8.0 it is raised for `annotations.csv`'s `description` column too, and counted rather than raised per event — a night's scoring is thousands of events, and a warning each is not a report: ``` warning: 2 annotations have descriptions starting with =, +, which Excel, LibreOffice and Google Sheets read as the start of a formula rather than as text. The text is written to annotations.csv exactly as the file has it, so the cell is what the recording says. Open the CSV with pandas or R, or import it into the spreadsheet as text, if you do not want it evaluated. ``` A description is the likelier of the two to arrive this way. A channel label is written once by the recorder; a description is typed by a person at a scoring station, or generated by a tool that pasted a computed name in. The same two exceptions apply — a lone `-`, and a description that is entirely a number — and the count is of the rows that reach the file, after the same `--start` / `--end` filter the writer applies, so a warning never names a row that is not in the output. ## Fatal errors: the recording can't be read These stop the conversion and exit **1**. Nothing is written for all but one of them: they are raised while the header is being read, before the output directory exists. The exception is a recording that changes size *during* the conversion, described at the end of this section — by then rows have been written, and the message says so. ### FILE_TOO_SMALL The file isn't large enough to hold what it declares. Raised in three situations: the file is under 256 bytes and so can't hold even the fixed header; the file is too short to hold the 256 bytes per signal that its declared signal count requires; or the file is smaller than the header size computed from that count. Each says which of its two figures is the short one, rather than quoting the file size either way — a library caller who hands `parseHeader` a header block and a file size that disagree gets told which. ``` error: File is 100 bytes; an EDF header alone needs at least 256. A file this small is truncated, or is not an EDF or BDF recording at all: every one of them opens with a fixed 256-byte header. ``` This is a truncated download, an incomplete copy, or a file that isn't EDF at all. Check the file size against the original. All three raisings carry a second line saying so; until 0.8.4 none of them did, because the check that holds every fatal parser error to having one counted its own trailing comma as an argument. ### BAD_HEADER_FIELD A field that should contain a number doesn't. Raised when a numeric field is empty, when its contents don't parse as a finite number, when a field that must be a whole number is fractional, or when a signal declares a negative sample count. ``` error: Header field "number of header bytes" is not a number (found "adding p"). The file may be truncated, byte-shifted, or not an EDF file at all. ``` This is the error you get when pointing edf2csv at something that isn't an EDF file. It also appears when a file is byte-shifted, so that fields are being read from the wrong offsets. All four raisings carry that second line; until 0.7.259 only the middle one did, and an empty field — which is what a truncated download and a byte-shifted read both produce most often — said nothing about either. ### INVALID_SIGNAL_COUNT The header declares zero or fewer signals. ``` error: Header declares 0 signals; expected at least 1. The file may be truncated, byte-shifted, or not an EDF file at all. ``` A recording with no channels can't be converted. This usually means a corrupt header rather than a genuinely empty recording. ### INVALID_RECORD_DURATION The header declares a data record duration that isn't a positive number. ``` error: Header declares a data record duration of 0s; expected a positive number. The file may be truncated, byte-shifted, or not an EDF file at all. ``` Record duration is the divisor that turns samples per record into a sampling rate, so a zero or negative value makes every rate in the file undefined. ### NO_SAMPLES Every channel in the file declares zero samples per data record, so the file has no data to convert. ``` error: No signal in this file carries any samples (every channel declares 0 samples per record). The header describes the channels and nothing has data behind them, so there is no record to read. A header written before recording began, or copied from another file, produces this. ``` The same code appears as a warning when a single channel is empty. As an error it means all of them are. ### NO_DATA_RECORDS The file contains a complete header but not one complete data record. There are two ways to get there and the message says which. Nothing after the header at all: ``` error: The file contains a header and no data at all. The recording was probably interrupted before any data was written. ``` An acquisition that was started and stopped immediately produces exactly this. So does a transfer that copied the header and then failed. Or data is there, but less than one record of it — which up to 0.5.86 got the message above, so a 606 KB file holding 589 KB of samples was told no data was written: ``` error: The file contains 589,824 bytes of data, which is less than the 983,040 its header says one data record takes. Either the recording was cut short part way through its first record, or the header describes records larger than the ones actually written. Check the samples-per-record fields against the file size. ``` Both numbers are there because the interesting comparison is between them: a header declaring records far larger than what was written is the other way to land here, and it is a header problem rather than a truncation. ### UNREADABLE The file can't be opened or read. Raised when the path doesn't exist, when permission is denied, when it isn't a regular file, and when a read during conversion returns fewer bytes than expected. Not for a bad argument to `readRecords`: that was coded `UNREADABLE` until 0.8.28 and is an `OptionError` now, since the file was fine and the call was not. ``` error: Cannot read "recording.edf": no such file. Check the path is spelled the way it is on disk and that you have permission to read it. ``` There is one more form of this error that the command line no longer reaches: ``` error: "/data/recordings" is a directory, not an EDF file. ``` `EdfFile.open` still raises it, since the library takes one recording and a directory is not one. The CLI expands a directory into the recordings inside it instead, so from the command line a folder is an input rather than a mistake — see [the CLI reference](/docs/cli-reference). The mid-conversion case works differently, and is the one place in this section where the conversion has already written something. If the file shrinks or is being rewritten while edf2csv is reading it, the read comes up short and the conversion stops rather than quietly handing back a CSV missing its tail: ``` error: Expected 2,864,400 bytes of data at record 24600 but only 0 bytes were available; the file appears to have changed size while it was being read. Make sure the recording is not still being written to, then try again. What was written to "out" before it failed is incomplete and should not be used. ``` That last sentence is the part to act on. The rows written before the read failed are on disk, in a `signals.csv` that ends on a row boundary and opens exactly like a finished one — two and a half million of them in the run above, out of 2.88 million. Nothing about the file itself reveals which it is. Delete the directory, or convert into a fresh one. Wait for the recording to finish, or copy it somewhere stable first, then convert. ### INPUT_UNREADABLE The reader failed *after* the conversion had started writing, so the run stopped part way through with output already on disk. **Cause.** The same conditions as [`UNREADABLE`](#unreadable) — a recording that shrinks, a descriptor that stops returning bytes — but reached during the streaming pass rather than while opening the file. **What edf2csv does.** Keeps the reader's own message and its advice, which name the record and the byte counts, and adds what is true of a failure at this point: some of the output exists and is incomplete. The distinction matters because the two used to be reported identically — a read failure was filed under `Writing to "" failed` with a hint about freeing disk space, which sends you to inspect the one part of the system that was working. ``` error: Expected 524,288 bytes of data at record 1024 but only 131,072 bytes were available; the file appears to have changed size while it was being read. Make sure the recording is not still being written to, then try again. What was written to "converted" before it failed is incomplete and should not be used. ``` **What to do.** Treat the directory as unusable and convert again once the recording has stopped moving. Through `--stdout` the same failure names stdout rather than a directory, since that path writes no files. ## Fatal errors: the output can't be written These also exit **1**. ### OUTPUT_EXISTS The output directory already exists and `--force` wasn't given. ``` error: "recording_csv" already exists. Pass --force to write into it, leaving whatever else it holds, or --out to choose a different directory. ``` This is a guard, not a failure. Refusing by default means a second run can't quietly destroy the first one's results. ```bash edf2csv recording.edf --force edf2csv recording.edf --out ./converted-v2 ``` ### OUTPUT_UNWRITABLE The destination can't be used. Raised when the path given to `--out` is an existing regular file rather than a directory, when it is a symbolic link whose target is gone, and when creating the directory fails. ``` error: "notes.txt" is a file, but the converted data needs a directory. Choose a directory with --out. error: "nightly-out" is a symbolic link to something that does not exist, so nothing can be written there. Remove the link, or choose a directory with --out. --force writes into a directory that is already there, and a link to nowhere is not one. error: Cannot create "/mnt/archive/out": the filesystem is read-only. That filesystem is mounted read-only; choose another with --out. ``` The link case read `"nightly-out" already exists` until 0.8.65 — `stat` follows symbolic links, so a dangling one is invisible to the check above it — and `--force`, which that sentence recommends, then failed from inside the writer with "The files written so far are incomplete and should not be used" over files that were never written. The hint is chosen from what actually failed, the same way [`WRITE_FAILED`](#write_failed)'s is and from the same list of sentences. Until 0.8.12 both raisings here carried one line whatever the cause — "Check the path exists and that you have permission to write there" — so a full disk, a read-only volume and a path past the filesystem's length limit were all answered with advice about a path that exists and a permission that is not the problem, two lines under a message that had already named the cause exactly. Filesystem failures are translated into plain language rather than passed through as system codes — here, and since 0.7.258 in `WRITE_FAILED` below, which is where the last of Node's own text was still reaching the screen. The whole list, in the words the message uses: permission denied; the disk is full; you are over your disk quota on this filesystem; part of the path is a file, not a directory; the filesystem is read-only; the path is too long; a directory is sitting there already; too many files are open; part of the path does not exist. ### WRITE_FAILED Writing one of the output files failed part way through, most often because the disk filled up. ``` error: Writing to "recording_csv" failed: the disk is full. The files written so far are incomplete and should not be used. The destination is out of space; free some up or choose another with --out. ``` The hint is chosen from what actually failed. Until 0.4.36 every write failure carried the disk-space advice, which fits exactly one errno — a directory sitting where `signals.csv` belongs, a read-only volume, a permission denial and a path too long for the filesystem all came back telling you to free up space. Wrong advice is worse than none: it sends you to check `df` on a disk that is fine while the real cause stays unexamined. | Cause | What the hint says | | --- | --- | | `ENOSPC` | The destination is out of space | | `EDQUOT` | You are over your disk quota on this filesystem | | `EACCES`, `EPERM` | You do not have permission to write there | | `EROFS` | That filesystem is mounted read-only | | `EISDIR` | A directory is sitting where that file belongs | | `ENOENT` | Part of that path no longer exists — something is removing it while the conversion runs | | `ENAMETOOLONG` | That path is longer than the filesystem allows | | `EMFILE`, `ENFILE` | Too many files are open; a recording with many sampling rates opens one output file per rate, so `--channels` narrows it | | `EPIPE` | Whatever was reading the output closed it before the conversion finished | | anything else | Check the destination and run the conversion again | The partly written files are left on disk either way. They are truncated at an arbitrary point and must not be analysed: fix what the hint names and run the conversion again from the start. Under `--stdout` the sentence changes, because that path writes no files and `--out` is the flag you chose not to pass: ``` error: Writing to stdout failed: the disk is full. What reached stdout before it failed is incomplete and should not be used. The destination is out of space; free some up or redirect it somewhere else. ``` ### INPUT_OUTPUT_COLLISION One of the files this run would write is the recording it is reading. **Cause.** An `--out` that resolves onto the input — most easily by pointing it at the directory the recording sits in, where `channels.csv` or a `signals_hz.csv` can land on a file of that name. A hard link or a second path to the same inode reaches it too, which is why the check compares device and inode numbers rather than just the resolved paths. **What edf2csv does.** Refuses before creating anything, and says so. `--force` does not override it: overwriting your own input is not what `--force` means, and the recording is unrecoverable once a CSV is written over it. ``` error: Output file "recordings/channels.csv" is the same file as the input recording. Choose a separate directory with --out. The input was not modified. ``` **What to do.** Convert into a directory of its own. The check covers every name the run would write, compressed forms included, so a `--gzip` run is refused on the same grounds. ### CALLBACK_FAILED The `onProgress` callback a library caller passed to `convert` threw. **Cause.** A bug in the caller's own code. The command line never raises this — its progress meter is internal — so it appears only through the [programmatic API](/docs/api). **What edf2csv does.** Stops the conversion and reports the callback as the cause, keeping the original error as `cause` so the stack that matters survives. It is deliberately not filed as a write failure: running inside the same guard that turns a stream error into `WRITE_FAILED` meant a caller's bug came back as `Writing to "out" failed: `, advising them to check a destination that was working. ``` error: The onProgress callback threw: Cannot read properties of undefined (reading 'total') This is the caller's callback, not the recording or the destination. Whatever was written before it threw is incomplete and should not be used. ``` **What to do.** Fix the callback. The conversion still stops, and whatever it had written is incomplete — carrying on writing into a directory whose owner has just failed is not an improvement. ### UNSUPPORTED_REQUEST The command cannot be carried out as written, decided after reading the header. **Cause.** Every one of these is a `--stdout` refusal: `--stdout` with `--annotations-only`, which leaves no signal data to stream; `--stdout` on a recording whose channels use more than one sampling rate in the default wide layout, which would be more than one table; and `--stdout` on a recording with no signal channels at all. **What edf2csv does.** Refuses and exits **2**, not 1. It is the one `ConversionErrorCode` in `USAGE_ERROR_CODES`, because the fix is to change the flags rather than the file — the hints say exactly that, and filing it under 1 sent scripts looking at the disk. ``` error: --stdout needs exactly one table, but this recording produces 3, one for each sampling rate its channels use (256 Hz, 128 Hz, 1 Hz). Narrow it to one rate with --channels, write --layout long to get them all in one table, or convert to a directory instead. ``` **What to do.** Take one of the three routes the hint names. `--info --stdout` reports the same refusal ahead of time as a [`STDOUT_UNSUPPORTED`](#stdout_unsupported) warning rather than an error, since `--info` writes nothing. ## Usage errors These mean the command was invoked in a way that can't be carried out. They exit **2** rather than 1, so a script can tell "you asked for something impossible" apart from "this recording is broken". | Situation | Example message | | --- | --- | | Unknown flag | `There is no --chanels option. Did you mean --channels?`, then how to pass it as a filename instead, then a pointer to `--help` | | A flag given no value | `--channels needs a value and was given none.`, then `It takes one: --channels .` The placeholder is the one `--help` writes for that flag | | A switch given a value | `--gzip is a switch and takes no value, but was given "yes".`, then `Write it on its own: --gzip` | | No input file | Usage text is printed | | Two recordings that would convert into the same directory | `"n2/rec.edf" and "n1/rec.edf" would both be converted into "out/rec", so one would overwrite the other.` | | Several recordings with `--stdout` | `--stdout writes a single CSV, so it cannot take 3 recordings.` | | `--channels` given with no names | `--channels was given but lists no channel names.` | | `--decimals` missing or out of range | `--decimals must be a whole number between 0 and 20` | | A channel name that matches nothing | `No channel named "ECQ". Did you mean "ECG"?` | | `--channels` naming the annotation channel, coming close to it, or giving its position | `"EDF Annotations" is this recording's annotation channel, not a signal`; a near miss names the spelling the file carries, and `#1` names the channel that sits there | | `--jobs` or `--layout` given a value it cannot act on | `--layout must be "wide" or "long", got "tall"` | | A position that doesn't exist | `No channel at position #9.` | | An unparseable time value | `--start "banana" is not a time I understand.` | | `--duration` and `--end` together | `Use either --duration or --end, not both.` | | `--start` at or past the end of the recording | `--start "600s" is at or past the end of this 2s recording.` | | A window that ends before it starts | `The requested window ends at "1s", which is not after its start at "5s".` | | `--duration` of zero | `--duration "0s" is not a length of time, so the window ends where it starts, at "5s".` The flag is named, since it is the only value that can be wrong here | | One recording's output inside another's | `"study/rec/inner.edf" would be converted into "out/rec/inner", which is inside "out/rec"` | | `--stdout` given a folder | `--stdout writes a single CSV, and a folder is converted as a batch even when it holds one recording.` | | `--stdout` with nothing to stream | `--stdout has no signal data to write because --annotations-only was given.` | | `--stdout` on a mixed-rate recording | `--stdout needs exactly one table, but this recording produces 3` — see [`UNSUPPORTED_REQUEST`](#unsupported_request) | | `--stdout` with `--json`, `--out`, `--checksum` or `--force` | `--stdout and --json both write to stdout, so they cannot be combined.` | | A folder holding no recordings | `No EDF or BDF recordings found in "study".` A folder that could not be *read* is exit 1 instead, with `Nothing could be converted.` | | No input at all | `No input file given.` It printed flush left with all 68 lines of `--help` under it until 0.8.38; `edf2csv $FILE` with `FILE` unset arrives this way | A term that matches no channel is an error rather than a silent omission, and the message suggests the closest labels in the file. Quietly dropping a channel you explicitly asked for would hand you a CSV missing data you believe is in it. The last two entries are about the recording's length but are still classed as usage errors, because the fix is to change the command rather than the file. The reverse mistake was on this page until 0.5.65: "the recording changed size while it was being read" was listed here, and it exits **1**. It is the parser reporting that the file moved under it, which is the definition of a file error — the command was fine — and [`UNREADABLE`](#unreadable) above describes it, with the exit code it really uses. Nothing else in this table comes from the parser, and a test now holds that. ## Exit codes | Code | Meaning | | --- | --- | | `0` | The command succeeded. Warnings may still have been printed | | `1` | The recording couldn't be read, or the output couldn't be written — or `--strict` was given and the recording raised a warning, in which case the output was written in full | | `2` | The command was invoked incorrectly, or asked for something the recording can't provide | | `130` | Interrupted with Ctrl-C (SIGINT). Whatever had been written is incomplete | | `143` | Terminated by SIGTERM. Same as above | `1` is therefore not a synonym for "nothing was written". Under `--strict` it means the opposite: every file the run intended to write is there, and a warning is being reported as a failure because you asked for that. A pipeline that branches on the exit code alone cannot tell the two apart — read `warnings` from `--json`, or drop `--strict`, if the difference matters. Piping into a consumer that exits early, such as `head`, closes stdout and would normally raise a broken pipe error. That case is treated as success, so `edf2csv recording.edf --info | head -5` exits 0. Over a batch the codes combine: any recording that failed makes the run exit 1, and 2 only when nothing worse happened — a usage error is the narrow claim, so every recording has to have earned it. A `--jobs` worker killed from outside, by the out-of-memory killer or a scheduler's time limit, counts as a failure like any other; up to 0.5.88 it made the run exit 2, because a signalled child exits 130 or 143 and the mapping only knew about 1 and 2. The command was fine; something killed a worker. ## Checking warnings from a script `--json` puts the whole summary, warnings included, on stdout as JSON. Warnings aren't also printed as text in this mode, so stderr stays clean. ```bash edf2csv recording.edf --out ./converted --json > summary.json ``` The `warnings` array holds one entry per diagnostic: ```json { "tool": { "name": "edf2csv", "version": "..." }, "output_dir": "./converted", "files": [ { "name": "signals_256hz.csv", "rows": 768 }, { "name": "signals_128hz.csv", "rows": 384 }, { "name": "signals_1hz.csv", "rows": 3 }, { "name": "channels.csv", "rows": 3 } ], "annotations": 0, "duration_seconds": 3, "records": 3, "elapsed_ms": 12, "warnings": [ { "code": "MIXED_SAMPLING_RATES", "severity": "warning", "message": "Channels use 3 different sampling rates (256 Hz, 128 Hz, 1 Hz)." } ] } ``` To fail a pipeline on a specific code, test for it: ```bash edf2csv recording.edf --out ./converted --json > summary.json if grep -q '"RECORD_COUNT_MISMATCH"' summary.json; then echo "recording is incomplete" >&2 exit 1 fi ``` The same list, minus `STALE_OUTPUT`, is stored permanently in the `notes` array of `metadata.json` inside the output directory, so a conversion carries its own warnings with it. Six months later you can still see what the tool said about the recording without rerunning anything. --- # How correctness is verified > What edf2csv checks, how it is compared against pyEDFlib, why the conversion formula is arranged the way it is, and what is not claimed ## Eleven separate claims Correctness here covers eleven different things, verified eleven different ways. The list grew past the "three" this section used to promise as the batch, fuzz and estimate harnesses were added, and the heading did not keep up until 0.4.34 — nor after it: a ninth claim was added and the heading still said eight, which is what the test below now counts. 1. **The arithmetic is right.** The physical values edf2csv computes match the values a reference implementation computes, to the last bit. Checked against [pyEDFlib](https://github.com/holgern/pyedflib) by `npm run crossvalidate`, which dumps the doubles from 75 generated recordings and compares the 64 bits of each against pyEDFlib's: **16,943 values and 120 annotations, all in agreement**. 2. **The parser reads the format correctly, including the parts real files get wrong.** Checked against generated EDF and BDF files whose byte layout and expected contents are written out in code, so the expected answer is known independently of the code under test. 3. **A batch converts each recording exactly as converting it alone would.** Random folder trees are converted serially and in parallel, and both must produce the same directories with the same bytes — every file but `metadata.json`, which records when the conversion ran and so cannot be identical across two of them. Checked by `npm run fuzz:batch`. 4. **A damaged file is reported, never a crash.** Real recordings are corrupted byte by byte and converted; every one must exit 0, 1 or 2 with something to say, and never a stack trace. Checked by `npm run fuzz`: **2,700 runs over 300 corrupted recordings, all reported cleanly** at the default seed, and more on request (`npm run fuzz -- 42 2000`). 5. **`--info` predicts what a conversion writes.** The row count is exact and the byte count never reads low for any recording whose samples stay inside the digital range its header declares, across every fixture crossed with thirteen option sets — the precisions, both ways of naming a window, the channel selection, both layouts, `--gzip` and `--bom`, which is every option that changes what lands on disk. Checked by `npm run estimate`: **601 predictions over 50 recordings** — row counts on all of them, sizes on the runs that write CSV, since `--gzip` output is smaller than the CSV by design and says nothing about a bound taken from the header — sizes reading 16% high on average, which is the direction a size estimate has to err in — and never more than three times the truth, which is the other half of that contract. Reading absurdly high answers "do I have room" wrongly too, and every check that only asks whether a number is large enough is satisfied by any number at all. Three is a wall rather than a target: the worst any fixture produces is 1.73x, a three-byte-per-sample BDF at `--decimals 0` where every cell is as narrow as a cell gets and the bound taken from the header is as wide as it gets. That figure is measured rather than remembered — the sweep prints the largest ratio it saw and the run that produced it, and refuses one that beats it, so a recording that reads worse raises the number here instead of passing under the wall in silence. The one case that reads low is a file whose samples leave the digital range its own header declares. A cell is bounded by the channel's declared physical range, which is what the two calibration points map that digital range onto; a sample outside it maps outside the physical range too and is written at its full width. Nothing in EDF forbids that, and a header bounded at ±100 whose codes run to ±32000 converts several percent larger than the estimate — how much depends on how far outside the declared range the codes actually run, since that is what decides how many characters a cell takes. Clamping the data to make the figure true is not a trade worth making — the samples are what they are — so the exception is stated instead, here and beside the arithmetic that produces it. No fixture does it, and none can: `test/fixtures/edf-writer.mjs` clamps every sample into the declared digital range as it writes, so the sweep asserts the contract strictly rather than carrying an allowlist. That is worth saying plainly rather than leaving as luck — the strictness rests on the writer's clamp, and relaxing it would start producing recordings the estimate sweep refuses. The exception is reached by writing the sample bytes by hand, which one test does: codes spread over ±26000 to ±32000 under a header bounded at ±100 come out about 5% over. 6. **The digital codes can be recovered from the CSV, at the precision edf2csv derives.** The documentation says the derived decimals are fine enough to get the original integer back, and offers the arithmetic for doing it. Checked by `npm run roundtrip`: **20,160 cells over 1,260 calibrations**, EDF and BDF, upright and inverted, every one recovering the code the file holds. The qualifier is the claim: `--decimals` replaces that precision with one you chose, and a coarser one stops the codes being recoverable — `--decimals 0` on a 256 Hz EEG channel gets 645 of 768 samples wrong, which the FAQ says beside the recipe. The derived precision itself stops at 100 places, the most `toFixed` will print, so a channel stepping by less than 1e-98 is past it too; that one is not silent, since `VALUE_RESOLUTION` reports it. 7. **The two layouts hold the same samples.** `--layout long` is a different shape, not different data, which is what makes it an honest answer to a mixed-rate recording. Checked by `npm run layouts`: **50 recordings crossed with eight option sets** — the windows, the precision, and the channel selection, which decides how many rates are in the conversion and so what the long layout's one shared time column has to mean — converted both ways, compared per channel as an ordered sequence of value cells, every sequence identical. 8. **Asking for part of a recording returns that part unchanged.** `--channels` selects columns and `--start`/`--end` selects rows, and both are documented as selections rather than transformations. Checked by `npm run narrowing`: every fixture's full conversion is taken as the truth, then each channel is converted alone and three windows are converted from it, and the narrowed output must be the corresponding slice of the full one byte for byte — **106 single-channel selections and 253 windows over 50 recordings**. Window bounds are placed halfway between two sample times on purpose: a bound read back off the CSV is a rounded number, and a conversion filters the exact ones, so a bound sitting on a sample asks a question neither answer is wrong about. The long layout is crossed too — **54 more single-channel selections** — where the assertion is deliberately weaker: its one shared `time_s` column takes the precision the finest rate *in the conversion* needs, so narrowing can round the column to a different width while the instants stay the same. Same channel, same value, same order, and the time compared at the coarser of the two precisions, since both are roundings of one instant. The same sweep asks the other half of the question, which being a slice cannot answer: `--end t` and `--start t` together, the one arrangement where the half-open rule has to be read both ways at once, must hold the whole recording between them — **174 pairs of windows meeting at a bound, and 41 more cut on or beside an event and compared as annotations**. A bound that dropped the sample sitting exactly on it, or wrote it into both halves, is a slice of the full conversion either way and passes every check above; it is only caught by asking the two halves about each other. Compared as multisets, since an EDF+D recording stores its records in file order and cutting it by time puts them back in another. And that two windows meeting at a bound hold the whole recording between them — **174 pairs**, cut on a sample and halfway between two. Every check above asks whether a window is a *slice* of the full conversion, which a bound that drops the sample sitting exactly on it satisfies perfectly: each half is still a run of consecutive rows in the right order, and neither is asked about the other. Flipping the boundary rule to exclude that sample is reported by this and by nothing else in the sweep. The pair is compared as a multiset, because a recording whose data records are stored out of order writes its rows in file order, so cutting it by time and putting the halves back together reorders them — correctly. Order is what the slices establish; what the pair adds is that nothing falls between two windows or lands in both. And the same of `annotations.csv` — **41 more pairs**, cut on an event's onset and halfway between two. Every comparison above reads `signals*.csv`, so the events had never been narrowed by anything: they are filtered by the same window under the same half-open rule, and flipping that rule to drop the event sitting exactly on `--start` leaves the whole suite green and leaves this sweep reporting that 253 windows returned exactly the part they name. 9. **The executable behaves as documented.** Exit codes, what goes to stdout versus stderr, refusing to overwrite, failing on a mistyped channel name. Checked by running the built CLI as a subprocess. 10. **Every `error:`, `warning:` and `interrupted (`, on a terminal too, begins a line.** That is what makes a batch's stderr greppable, and it is the one claim the suite structurally cannot check: the progress meter exists only when stderr is a TTY, and a captured stderr is a pipe. Checked by `npm run terminal`, which allocates a pseudo terminal and runs conversions under it — **6 runs**, asserting the meter is taken down before anything is printed over it — including before the interrupt message, which is the one prefix that meets the meter by definition rather than by arrangement, since Ctrl-C is pressed while looking at it — that the numbers in it are percentages — whole, between 0 and 100, never going backwards inside a run — that nothing but text reaches the screen, and that the command the compressed-to-a-terminal refusal offers can be pasted into a shell and produces a gzip stream. It found the defect fixed in 0.7.9, where a failed conversion printed `converting… 96%error: Expected 317440 bytes …` and `grep '^error:'` came back empty. The suite holds itself to the same standard, which it did not until 0.8.35: one test converted a fixture to stdout thirty times to check that no listener was left behind on it, and stdout there is the runner's own report — so `npm test` put thirty CSVs and, under `--gzip`, thirty deflate streams into it, 675 control bytes and 45 ESCs into the terminal of whoever ran it. That conversion happens in a child process now, and `npm test` writes nothing but text. The failing conversion it needs is arranged by cutting the recording out from under the reader, triggered by the first bytes the meter puts on the screen rather than by a timer — on a recording large enough that those bytes arrive at 32% rather than at 96%, which is all the room a two-batch file left — and a machine that outruns it even so gets a note saying that run proved nothing, in the same way a machine with no pty module does, rather than a red build. 11. **The stream holds the bytes the directory holds.** `--stdout` is documented as writing the signal CSV "instead of a directory", and every recipe that pipes a conversion into `duckdb`, `gunzip` or a script depends on the two being the same bytes — but they are not the same code. `--out` opens a file stream per rate group and closes it; `--stdout` writes one stream it does not own, through an audit wrapper that counts bytes so a short write can be reported, since it is the one destination with no second file after it to trip over. Nothing compared them: the estimate sweep measures files on disk, layouts, narrowing and round-trip all read directories, the batch sweep is about batches, and the terminal sweep checks the one case `--stdout` refuses. Checked by `npm run stream`: **305 streams over 50 recordings**, crossed with the modes that change what reaches one, against the single signal file the same command writes to a directory. Compressed streams are decompressed first, since gzip need not choose the same block boundaries twice. The second and ninth are what `npm test` runs; the third through eighth and the eleventh are the batch, fuzz, estimate, round-trip, layout, narrowing and stream commands. The tenth needs a pseudo terminal, which Node cannot allocate, so it borrows python3's `pty` module and reports that it checked nothing when that is unavailable rather than failing a machine without it. The first needs pyEDFlib, so it is a separate command — the package itself has no dependencies and `npm test` keeps it that way: ```bash pip install pyedflib npm run crossvalidate ``` ``` Compared 16,943 sample values bit for bit, and 120 annotations, across 75 recordings. Every value agreed. ``` Without pyEDFlib installed it says so and exits 0 rather than pretending to have checked anything. A quarter of the recordings are BDF rather than EDF, where a sample is three bytes instead of two. The 24-bit path is where a reader is most likely to be quietly wrong: the sign has to be extended by hand, and a value that comes out unsigned is not obviously wrong to look at — it is a large positive number where a large negative one belongs. Half of them carry EDF+ or BDF+ events, so the annotation reader is compared too, including an event with no duration and one whose duration is zero. On that one point the two disagree by design: pyEDFlib reports a missing duration as `-1.0`, edf2csv leaves the cell empty, on the grounds that a duration nobody recorded is not a duration of minus one second. The recordings it generates are not the test fixtures. Those target the things real files get wrong, and pyEDFlib refuses several of them outright — a truncated file, a header whose digital range is a single point. What this needs is the opposite: ordinary well-formed recordings across a wide spread of calibrations, with digital spans from `-1..1` to `-32768..32767` and physical spans from `0.0001` to `99999`, giving gains from about 1e-9 to about 1e5. Both endpoints of the digital range appear in every recording, since `digitalMin` and `digitalMax` are the two points the header actually calibrates and where a mapping derived slightly differently disagrees most. The comparison does not go through the CSV at all. Both sides dump their doubles and the 64 bits are compared, so what is being compared is two computations of a value rather than one of them against its printed form. Reading a printed cell back cannot be exact whatever precision it was printed at — a cell is a rounded rendering, so parsing it gives the nearest double to those digits rather than the double that was computed. Until 0.4.32 this ran at `--decimals 20` and did exactly that, which is described below; this paragraph described it too, for twenty versions after it stopped being true. To confirm the check can fail, put a one-part-in-a-million error into the gain and rerun it: ``` x018.edf signals.csv "sig18" sample 0: pyEDFlib -249.99999999999997, edf2csv -250.000251 ``` Shifting every annotation onset by a millisecond does the same for the event half: ``` x010.edf annotation 0: onset 0.25 vs 0.251 ``` Either exits 1. ## Batches ```bash npm run fuzz:batch # 12 folder trees, the default seed npm run fuzz:batch -- 42 40 # a different seed, more trees ``` ``` 12 folder trees, 49 recordings, 49 conversions (seed 1). Serial and parallel agreed, and every batch matched converting alone. ``` Converting a folder is the hardest part of this tool to reason about: the tree is walked, links are followed, destinations are derived from file names, and the conversions may run in any order across several processes. Rather than guess which arrangement breaks, this builds arrangements — nesting, names with spaces and non-ASCII characters, mixed-case extensions, symlinks, files that are not recordings — and checks five things that must hold whatever shape comes out: 1. **Serial and parallel produce the same directories, holding the same bytes.** A difference between them is what a race looks like from outside. `metadata.json` is the one file compared by name rather than by content: it carries `converted_at`, so two runs of the same conversion differ there by design, and asserting otherwise would mean either freezing the clock or dropping the field. The bytes are a separate question from the names: `--jobs 1` converts in this process and anything more forks a child whose command line is rebuilt by hand, so the two are not the same code, and a flag lost in that rebuild leaves the directories right and the numbers in them wrong. Each tree is converted under a different option set for that reason — the sweep passed no flags at all until 0.7.35, which is the one condition under which such a loss cannot show. 2. **Each recording's output equals converting it alone.** A batch may reorder the work; it may not change a byte of it. 3. **The closing count matches the directories produced**, so "Converted 5 of 5" is a fact. 4. **A non-zero exit comes with a message**, never a silent half-conversion. 5. **Nothing is written outside the directory that was named.** A destination is the input's path relative to the folder the caller pointed at, joined onto `--out`, so whether it can begin with `..` is a question about the walk — a symlink leading out of the tree, a name that normalises oddly — and the answer decides whether `--out` is a destination or a suggestion. Every check above reads the output roots, so a conversion that landed beside them was somewhere none of them was looking: joining `..` into the destination leaves this sweep reporting that serial and parallel agreed over a run whose every file went elsewhere. The first of those is how the collision fixed in 0.4.14 was found: one run produced `/rec`, another `/rec/inner`, from the same command over the same files. Putting that bug back makes this fail in two independent rounds and exit 1. ## The two layouts ```bash npm run layouts ``` ``` 370 conversions compared over 50 recordings (690 channel sequences, 30 refused by both). Both layouts hold the same samples, in the same order, per channel. ``` The conversion and channel-sequence counts move with the fixture set and with which windows a given recording can honour, which is why the claim above is stated as the sweep's shape rather than as a total. The recording count between them is the fixture set itself, so it is the same number claim 7 states and the same one the estimate and batch sweeps report; a test holds the three to it. What must not move is the last line. `--layout long` writes one table of `time_s,channel,value` where the default writes a column per channel and a file per rate. Every page describing it says the same thing: a different shape, not different data. That is the claim, and until 0.5.16 nothing ran it — during which the long layout shipped four defects, three of them found by reading rather than by running. Each fixture is converted both ways, crossed with option sets that move the window, the precision and the channel selection, and compared per channel: the column read down its rows in the wide table against the rows for that channel in the long one, as an ordered sequence of value cells. The selection is there because it decides how many rates are in the conversion, and the long layout's one shared `time_s` column takes its precision from that set rather than from the file's — the thing 0.7.17 found nothing checking. It changes the wide layout too, where dropping a rate removes a file. Six option sets moved only the window and the precision, so the one option that changes the shape of both layouts at once was crossed with neither. Deliberately not joined on time. The two layouts write `time_s` at different precisions by design — the long one shares the finest any rate needs, since one column cannot mean three things — so a time-keyed comparison compares the formatting rather than the data, and at nine decimal places it collapses distinct sub-nanosecond samples into one key. The first version of this harness did exactly that and reported 42 disagreements that were all its own. It is confirmed capable of failing: making the long layout skip one sample per record is caught on the first recording, as a channel with 8 values in one layout and 6 in the other. ## Damaged files ```bash npm run fuzz # 300 recordings, the default seed npm run fuzz -- 99 800 # a different seed, more of them ``` ``` 2700 runs over 300 corrupted recordings (seed 1). Every one exited cleanly with something to say. ``` A header is thirty-odd fields parsed out of bytes, and the ways one can be wrong are not a list anybody can write down. A test asserts the cases its author thought of, which are the cases the code already handles. Mutating real recordings asks a different question — is there *any* arrangement of bytes that gets past the checks — and it has the advantage of not sharing the author's assumptions. Damage is weighted toward the first kilobyte, where the fixed header and the start of the signal headers live, because that is where one byte changes the meaning of everything after it. A corrupted sample is only a different number; a corrupted sample count is a promise about the file's shape that the file no longer keeps. Each file is run nine ways. Four of them survey and convert it — `--info`, `--info --json`, a conversion, and a conversion with `--gzip`, which puts a compressor between the writer and the file and has its own failure routes. The other five reach code the first four never do: `--layout long` has its own row writer, `--stdout` has no directory behind it for a failure to name, `--annotations-only` skips the signal writing altogether, a window is record arithmetic on a header the damage may have made nonsense of, and `--channels` rebuilds the plan from a selection, which is how a rate group lands in a differently-named file. Runs are deterministic, so a crash found on one machine reproduces on another. It was confirmed capable of failing before being trusted: made to return an exit code outside 0, 1 and 2, it names the recording, the arguments and the message, and exits 1. Below is how to reproduce the pyEDFlib comparison on your own recordings. ## The cross-check against pyEDFlib pyEDFlib is the Python binding around EDFlib, the C library written by the author of the EDF+ specification. EDFbrowser uses the same library. It's the closest thing this format has to a reference implementation. On the 75 generated recordings this ships with, 16,943 sample values came out of edf2csv and out of pyEDFlib bit-for-bit identical, along with 120 annotations. Run `npm run crossvalidate` to reproduce it. ### What bit-for-bit means Both tools produce IEEE 754 double-precision floats. Bit-for-bit identical means the 64 bits are the same 64 bits: not equal to within a tolerance, not `numpy.allclose`, not agreeing to twelve decimal places. Zero differing bits, across every sample compared. The distinction is practical. A tolerance-based check has to pick a tolerance, and any tolerance loose enough to pass hides every bug smaller than itself. If a future change to the reading path swaps two bytes, sign-extends a 24-bit sample incorrectly, or reorders the arithmetic, an exact comparison fails immediately. Until 0.4.32 this page described that method and `npm run crossvalidate` did not use it. The checker converted with `--decimals 20` and parsed the cells back, which cannot be exact whatever the tolerance — a cell is a rounded decimal rendering, so reading it gives the nearest double to the printed digits rather than the double that was computed. It then accepted anything within `abs(reference) * 1e-9` and skipped empty cells without counting them. The checker now runs the dumper below and compares the 64 bits, and it is confirmed capable of failing: flipping the lowest mantissa bit of every scaled value is caught on the first sample of every recording, including cases the decimal rendering cannot show — `-1.0` against `-1.0000000000000002`, which the old tolerance passed without comment. Exact agreement is only possible because edf2csv performs the calibration in the same order EDFlib does, which is the subject of the next section. ### Running the comparison yourself The comparison has to happen on doubles, not on CSV text, because CSV is rounded on the way out. Dump the doubles from edf2csv with the programmatic API, then read the same channel with pyEDFlib and compare bit patterns. Save this as `dump-doubles.mjs`: ```js // Dump one channel's physical values as raw float64, for comparison with pyEDFlib. import { writeFileSync } from 'node:fs'; import { EdfFile, makeScaler } from 'edf2csv'; const [input, channelIndex, output] = process.argv.slice(2); const file = await EdfFile.open(input); const signal = file.dataSignals[Number(channelIndex)]; const scale = makeScaler(signal); const values = []; for await (const batch of file.readRecords()) { for (let r = 0; r < batch.recordCount; r++) { for (let i = 0; i < signal.samplesPerRecord; i++) { values.push(scale(file.sampleAt(batch, r, signal, i))); } } } await file.close(); writeFileSync(output, Buffer.from(Float64Array.from(values).buffer)); console.log(`${values.length} samples from "${signal.label}" -> ${output}`); ``` Run it on channel 0: ```bash npm install edf2csv node dump-doubles.mjs recording.edf 0 channel0.f64 ``` Then compare in Python: ```python import numpy as np import pyedflib f = pyedflib.EdfReader("recording.edf") try: reference = f.readSignal(0) # float64, physical units finally: f.close() ours = np.fromfile("channel0.f64", dtype=np.float64) assert ours.shape == reference.shape, (ours.shape, reference.shape) same = ours.view(np.uint64) == reference.view(np.uint64) print(f"{same.sum()} of {same.size} values bit-for-bit identical") print("first difference:", None if same.all() else int(np.argmax(~same))) ``` Comparing the `uint64` view rather than the floats is deliberate: it's a comparison that approximate agreement can't satisfy. The checked-in version of this dumper is `test/crossvalidate/dump-doubles.mjs`, which does every channel at once and is what `npm run crossvalidate` runs, so the method printed here and the one that executes are the same code. Two caveats. This comparison isn't part of `npm test`, because it needs a Python environment. And pyEDFlib refuses EDF+D files outright, so a discontinuous recording can't be cross-checked this way at all. For those files the check is against the generated fixtures, where the expected sample values are known by construction. ## The conversion formula and its arrangement EDF stores samples as integers. Each channel's header gives two calibration points — digital minimum to physical minimum, and digital maximum to physical maximum — and the physical value is the straight line through them. The specification writes it like this: ``` gain = (physicalMax - physicalMin) / (digitalMax - digitalMin) physical = (digital - digitalMin) * gain + physicalMin ``` edf2csv evaluates the algebraically identical rearrangement EDFlib uses: ``` offset = physicalMax / gain - digitalMax physical = gain * (offset + digital) ``` The two forms are algebraically identical but not numerically identical. Floating-point addition and multiplication aren't associative, so the two forms take different paths to the same real number. ### What the two forms do differently Take a channel calibrated at plus or minus 800 uV stored over the digital range -2048 to 2047, the ordinary 12-bit case that appears in many public EEG datasets. The digital span is 4095, so the gain is 1600/4095 and the exact physical value for digital 0 is 800/4095. The specification's literal ordering computes `(0 - (-2048)) * gain` first. That intermediate is `800.1953601953602`, a number near 800. It then subtracts `physicalMin`, which is -800, leaving a result near 0.195. Subtracting two numbers of similar magnitude to get a small one is catastrophic cancellation: the absolute error carried by the large intermediate, invisible at a magnitude of 800, ends up in the low digits of a result whose magnitude is 0.195. EDFlib's ordering computes `offset` once at setup. Here it works out to exactly `0.5`, and `0.5 + digital` is a small number a double represents exactly. There's then a single multiplication — one inexact operation in the whole computation rather than three, and no cancellation. | | Value at digital 0 | | --- | --- | | Exact value, 800/4095 | `0.19536019536019536` | | `gain * (offset + digital)` | `0.19536019536019536` | | `(digital - digitalMin) * gain + physicalMin` | `0.19536019536019467` | One arrangement returns the correctly rounded value and the other doesn't. ### Across the whole digital range Digital 0 isn't a cherry-picked worst case. Save this as `rounding.mjs` and run it with `node rounding.mjs`: ```js // A +/-800 uV channel stored over the digital range -2048..2047. const physMin = -800, physMax = 800, digMin = -2048, digMax = 2047; const gain = (physMax - physMin) / (digMax - digMin); const offset = physMax / gain - digMax; const specLiteral = (d) => (d - digMin) * gain + physMin; const edf2csvForm = (d) => gain * (offset + d); // The exact value for digital d is (2d + 1) * 800 / 4095, computed here in // arbitrary precision and then rounded once to the nearest double. const exact = (d) => { const bits = 300n; const n = BigInt(2 * d + 1) * 800n; const sign = n < 0n ? -1 : 1; const scaled = ((n < 0n ? -n : n) << bits) / 4095n; return sign * (Number(scaled) / 2 ** Number(bits)); }; let specWrong = 0, oursWrong = 0; for (let d = digMin; d <= digMax; d++) { if (specLiteral(d) !== exact(d)) specWrong++; if (edf2csvForm(d) !== exact(d)) oursWrong++; } console.log('digital 0, exact ', exact(0)); console.log('digital 0, edf2csv ', edf2csvForm(0)); console.log('digital 0, spec-literal', specLiteral(0)); console.log('intermediate, edf2csv ', offset + 0); console.log('intermediate, spec-literal', (0 - digMin) * gain); console.log(`codes not correctly rounded: spec-literal ${specWrong}, edf2csv ${oursWrong}, of 4096`); ``` ``` digital 0, exact 0.19536019536019536 digital 0, edf2csv 0.19536019536019536 digital 0, spec-literal 0.19536019536019467 intermediate, edf2csv 0.5 intermediate, spec-literal 800.1953601953602 codes not correctly rounded: spec-literal 2077, edf2csv 20, of 4096 ``` Of the 4096 possible digital codes on this channel, the specification's literal ordering returns something other than the correctly rounded value for 2077 of them, and at worst it's 32 units in the last place away. The arrangement edf2csv uses is exact for 4076 codes and never more than one unit in the last place away. The remaining 20 come from the gain itself. `gain` is the result of a division and is already rounded to a double before any sample is converted, so the computed result is the correctly rounded product of an already-rounded gain, which permits a final error of one unit in the last place and no more. Removing it would mean carrying the gain in higher precision, which no reader of this format does, and which would break exact agreement with every other tool. ### Where this does and doesn't show up On an ordinary microvolt channel you won't see the difference in a CSV. edf2csv chooses each channel's decimal precision from its quantization step, so no two adjacent digital codes can round to the same text. For a plus or minus 800 uV channel the step is 0.39 uV and the precision works out to three decimals, which prints `0.195` either way. Forcing the maximum `--decimals` accepts, 20, is where the two forms part company: edf2csv prints `0.19536019536019536003` and the specification's literal ordering prints `0.19536019536019466614`, first differing at the fifteenth decimal. That is the difference this section is about, and three decimals is why an ordinary conversion never shows it. It matters for four reasons: - **It's what makes exact comparison possible.** Bit-identity with pyEDFlib is a property you either have or don't. Accepting a 32-unit error means the strongest available check degrades to a tolerance check, and a tolerance check can't tell a rounding difference from a genuine bug. - **The doubles are visible through the API.** `makeScaler` returns the value, not a formatted string, so anything built on the programmatic API gets the full double. - **Not every channel is in microvolts.** A channel calibrated in volts has a quantization step near 1e-7 and gets many more decimal places; a magnetometer in tesla more again, which is why the derived precision runs to 100 — the most `toFixed` will print. The further right the printed digits go, the closer the discrepancy gets to visible. - **It's free.** The better arrangement is one line, evaluated once per channel. ### The cases where the formula doesn't apply Real headers are sometimes self-contradictory. In each case the code does something defined rather than producing `NaN` or `Infinity` and letting it flow into the CSV. | Header condition | Behaviour | | --- | --- | | `digitalMin` equals `digitalMax` | The mapping is undefined, so the scaler yields `NaN` and those cells are written empty rather than filled with a stand-in number. A `DEGENERATE_DIGITAL_RANGE` warning is raised. | | Gain is zero | Every sample legitimately converts to the same value, so that value is written. | | Gain is not finite | The physical span overflowed a double, so there is no mapping at all: the cells are left empty and `UNUSABLE_PHYSICAL_RANGE` is raised. | | The derived offset overflows to non-finite | Only possible for an absurd calibration. The code falls back to the specification's literal ordering, which is less accurate but finite. | | Gain is negative — exactly one of the two bounds pairs reversed | The channel's polarity is inverted. Converted exactly as the header specifies, inversion included, with an `INVERTED_PHYSICAL_RANGE` warning. Correcting the header would mean guessing about the recording. Reversing both pairs leaves the gain positive, which is an ordinary channel and draws nothing. | The first and last of these have fixtures and tests of their own, listed below. ## Streaming doesn't change the numbers Conversion is streamed: data records are read in batches sized by a byte budget, so a 4 GB file and a 4 MB file use the same working set. A 40 MB EDF producing a 159 MB CSV converts in about 1.4 seconds with the Node heap capped at 48 MB. Buffered reading is a common source of silent corruption, because a sample can straddle a chunk boundary. The suite tests this directly by reading the same file twice, once with a one-byte read budget and once with a one-megabyte budget, and asserting the two sample sequences are deeply equal. A one-byte budget puts a boundary between essentially every pair of bytes in the file, so if record boundary handling depended on buffering at all, that test couldn't pass. ## The fixtures and what each one covers The fixtures are EDF and BDF files built by `test/fixtures/generate.mjs`, using a small purpose-built writer in `test/fixtures/edf-writer.mjs`. Each one pins down one thing that real recordings do and that a straightforward reader gets wrong, and all of them are listed below — the table used to hold fifteen of the fifty under a heading promising every one, which is the sort of claim this page exists not to make. Most fixtures use a generator where the digital value equals the sample's global index, so the expected output can be stated by hand rather than derived from the code being tested. | Fixture | What it contains | What it pins down | | --- | --- | --- | | `tiny.edf` | 2 channels at 10 Hz, 2 records, one in uV and one in mV | The baseline. Every value is checkable by hand. Its start date of `05.06.09` also pins the two-digit year rule: 2009, not 1909. | | `mixed-rates.edf` | EEG at 256 Hz, ECG at 128 Hz, temperature at 1 Hz, 3 records | Rate grouping. Three seconds gives 768, 384 and 3 rows in three files. The slow channel keeps its three genuine readings. | | `annotations.edf` | EDF+C with three events: one with a duration, one without, one starting mid-record | TAL decoding, and that a missing duration stays empty rather than becoming zero. | | `discontinuous.edf` | EDF+D whose records sit at 0 s, 1 s and 10 s | The nine-second gap survives as a jump in `time_s`, record start times are recovered from the annotation channel, and the timekeeping TAL isn't mistaken for an event. | | `annotations-front-loaded.edf` | 10 records, but every event crammed into record 0 with onsets at 0.5 s, 5.5 s and 8.5 s | Nothing in the specification obliges a writer to store an event in the record its onset falls in. A time window has to find the event by onset, not by which record holds its bytes. | | `annotations-only.edf` | EDF+C with an annotations channel and no signal channels at all | A file that converts to events and nothing else, raising `NO_SIGNAL_CHANNELS`. | | `truncated.edf` | Header declares 10 records, only 4 were written | The file is trusted over its own header. Four records are converted and `RECORD_COUNT_MISMATCH` is raised. | | `unknown-records.edf` | Declared record count of -1, 4 records present | The specification permits -1 for a recording still in progress. `RECORD_COUNT_UNKNOWN` is raised and the real count is used. | | `fractional-recdur.edf` | 25 samples per 0.1 s record | A rate of 250 Hz derived from a fractional record duration, rather than assuming one-second records. | | `quirky-labels.edf` | Two channels sharing the label `T8-P8`, a channel labelled `-`, and a channel with physical minimum above maximum | Duplicate labels get suffixed with the signal number, an odd but unique label is left alone, and an inverted range is honoured rather than corrected. Its plus or minus 800 uV calibration is the one used in the rounding test above. | | `rate-slug-collision.edf` | Two channels whose sampling rates both round to `0hz` in a filename, over an eleven-day record duration | Distinct rates get distinct files. Sharing a name meant two write streams on one path, interleaving both channels' rows under a header naming one of them. | | `reversed-bounds.edf` | Three channels: one with only its physical pair reversed, one with only its digital pair, one with both | Which of them is inverted is decided by the sign of the gain, not by the physical pair alone. The first two are warned about, each message naming the pair that is actually the wrong way round; the third has a positive gain and draws nothing. | | `degenerate-range.edf` | Three channels: one with digital minimum equal to digital maximum, one with physical minimum equal to physical maximum, and one ordinary | The two degenerate cases must not be treated alike. The undefined mapping writes empty cells and a warning, never `NaN` as text or a stand-in number; the flat-but-defined mapping still writes its constant value; the ordinary channel is untouched by either. | | `biosemi.bdf` | 24-bit BDF, including sample values no 16-bit field could hold | Three-byte samples, record sizing at three bytes per sample, and correct sign extension of negative 24-bit values. | | `biosemi-plus.bdf` | BDF+D, whose markers are spelled `BDF+D` and `BDF Annotations` | BioSemi's spelling of the EDF+ markers is recognised and normalised, and gaps and events are recovered from a discontinuous BDF file. | | `single-rate-empty-channel.edf` | One channel at 4 Hz beside a channel declaring zero samples per record | A channel with no samples has a nominal rate of 0 Hz and no file of its own. Counting it as a rate made this single-rate file warn that it used "2 different sampling rates (4 Hz, 0 Hz)". | | `annotations-at-edges.edf` | Three events in the last record: one inside the span, one exactly at `duration`, one past it | EDF+ does not oblige an onset to fall inside the data — an end-of-recording marker sits exactly at `duration`. A whole-file window of [0, duration) dropped those events with no option given and no way to ask for them back. | | `annotations-bad-timekeeping.edf` | EDF+D whose second record opens with a TAL lacking the mandatory signed onset, followed by a real event | The timekeeping TAL is the one in first position, not the first one that happens to parse. Taking the latter made the record start 1.5 s — an ordinary event's onset — shifting every sample in it by half a second. | | `fractional-start.edf` / `fractional-start-d.edf` | EDF+C and EDF+D twins whose first record starts 0.5 s after the header time, with an event at +0.75 | The origin comes from the first record's timekeeping TAL. Timing samples from zero instead put signals and events half a second apart, landing the event on sample 3 rather than sample 1. The pair differ only in the reserved field, so they must agree. | | `negative-origin.edf` | EDF+D whose timekeeping places the recording at -100 s | A negative onset is legal and means an event before the nominal start, so every `time_s` carries a minus sign. The byte estimate measured the column unsigned and read low — the one direction it promises never to go, and no other fixture began before zero. | | `biosemi-rate.edf` | A single channel at 1024 Hz | The rate a BioSemi ActiveTwo records at, and the first power of two needing more than nine decimals: 1/1024 is 0.0009765625. The search for an exact expansion stopped at nine, so `time_s * rate` came back 8191.999… instead of a whole number. | | `repeating-fast.edf` | Three samples in a 1e-15 s record — 3e15 Hz | 1/3e15 repeats forever, so no exact expansion exists and fifteen places still cannot separate consecutive samples. Every sample is written; what stops being true is that `time_s` identifies a row. `TIME_RESOLUTION` exists for this. | | `sub-nanosecond.edf` | Two records of 1e-9 s holding ten samples each | An interval of 1e-10 s, against a window boundary slack that was a flat nanosecond — larger than the interval — so `time < end - 1e-9` excluded the whole second record and ten of twenty rows vanished silently. | | `contiguous-fractional.edf` | An ordinary EDF+C with 0.1 s records sitting at 0.1, 0.2, 0.3 … | Exactly where continuity puts them, but 0.1 + 2 × 0.1 is 0.30000000000000004. A continuity check written as equality reported two of eight records as contradicting it — a failed `--strict` run on a file with nothing wrong. | | `continuous-liar.edf` | EDF+C whose records really do jump: 0.5 s, 1.5 s, 10.5 s | A file marked continuous whose own records disagree. Nothing looked past the first record, so the gap was silently closed. | | `continuous-liar-from-zero.edf` | The same contradiction with records at 0 s, 5 s, 10 s | The origin works out to exactly 0, which was treated as "no origin" and returned on before the contradiction check ran. Where record 0 sits decides nothing about records 1 and 2. | | `lost-timekeeping.edf` / `lost-timekeeping-d.edf` | EDF+C and EDF+D twins whose first timekeeping TAL writes its onset with a comma | Records 1 and 2 still say where they are, and continuity fixes the origin from either: 1.5 − 1×1 = 0.5. Reading only `recordStarts[0]` threw that away and timed the file 0.5 s early against annotation onsets that kept their true values. | | `two-annotation-channels.edf` | Two annotation channels, the second holding events whose onsets cannot be parsed | Only the first annotation channel carries timekeeping. Flagging the first TAL of every channel as timekeeping counted three dropped events as lost timekeeping — reported as "No event was lost", beside a claim the records did not say where they sit. | | `zero-first-annotation.edf` | Two annotation channels, the first declaring zero samples per record | EDF+ puts timekeeping in the first annotation channel, and the reader took `annotationSignals[0]` literally. A zero-byte slot meant the timekeeping in the channel after it went unread: "3 of 3 data records carry no readable timekeeping annotation" about three that are readable. | | `far-origin.edf` / `far-origin-collapsed.edf` | EDF+C recordings whose timekeeping sits at 1e16 s and 1e17 s | A double spaces its values 2 s apart at 1e16, so `t + 1` is `t`. At 1e16 eight of twelve rows vanished silently; at 1e17 every record lands on one instant and the window resolver blamed a flag nobody passed. | | `far-origin-negative.edf` | The same distance out at -1e16, with the sign written into the TAL by hand | The guard took the signed maximum seeded with 0, so an all-negative recording never got past the seed. The collapse happens anyway — double spacing grows with magnitude, not with value — and twelve rows became four in silence. | | `late-start.edf` | EDF+D whose first record sits at 30 s, with an event at 30.5 s | `--duration` is measured from where the conversion starts; the annotation filter anchored it at 0. Signals came from [30, 35) while events were filtered against (−∞, 5), so annotations.csv held only its header. | | `records-overlapping.edf` | EDF+D with records at 0 s, 0.5 s and 1 s, one second long | Strictly increasing starts, so a check for "starts before the one before it" sees nothing — yet record 0 runs to 0.75 while record 1 begins at 0.5, so the column steps backwards anyway. A device re-sending a buffer produces this. | | `records-backwards.edf` | EDF+D storing records timestamped 10 s, 5 s, 0 s in that order | Nothing obliges a writer to store records in time order. It is the one recording that breaks the long layout's claim that rows come out sorted by `time_s`; every sample is still written, in file order. | | `ascending-rates.edf` | Three channels declared slowest first, at 1, 2 and 4 samples per record | Rate groups are ordered fastest first because that is how the wide layout names files, and that leaked into long-layout row order: the file declared `slow, medium, fast` and the rows came out `fast, medium, slow`, against what channels.csv says. | | `fractional-tie.edf` | A 0.3 s record holding 12 and 4 samples — 40 Hz and 13.333… Hz | Sample 9 of the fast channel and sample 3 of the slow one are the same instant one ULP apart. A tie test written as equality does not see it, so those two rows fall out in numeric order rather than in channel order, once, mid-file. | | `many-rates.edf` | Forty channels, every one at a different rate | The header decides how many channels a file has, so any message enumerating them is as long as the file says. At 40 rates the mixed-rate warning ran past 300 characters on one line. | | `rate-decimal-collision.edf` | 4 and 5 samples in a 4,000,000 s record — 1e-6 Hz and 1.25e-6 Hz | Both are shown in exponent form and both round to "0.000001", so the warning read "2 different sampling rates (0.000001 Hz, 0.000001 Hz)". | | `rounding-bound.edf` | An unsigned channel bounded at 999.9999, written to two decimals, over five records | The top code renders as "1000.00" — seven characters where flooring the bound suggests six, so the estimate came out under the real file. Five records rather than ten, because a spare character per row cancelled the shortfall exactly and hid it. | | `exponent-time.edf` | Records one 1e21 s long | EDF's record-duration field is eight characters and exponent form fits, so `1e21` is a legal thing for a header to say — and three records reach the point where `String(n)` switches to exponent notation mid-column. | | `long-stream.edf` | 400 records at 256 Hz, converting to about 2 MB | Output larger than a pipe buffer. The small fixtures all fit, so every write lands and a reader hanging up mid-stream — the EPIPE case — was unreachable. | | `control-labels.edf` | An ESC in a label and a unit, a bell character, a tab, and one plain channel | `--info` escapes these because an ANSI escape in a header can drive the terminal. The CSV passes them through, which is right, and nothing said so — `cat signals.csv` on a channel labelled `ESC[2J` clears the screen. | | `label-suffix-collision.edf` | Two channels labelled `T8` and a third labelled `T8_ch0` | All three are legal, and the disambiguating suffix landed on a label another channel already had: `time_s,T8_ch0,T8_ch1,T8_ch0` — two columns with one name, under a warning promising the suffix kept them distinct. | | `latin1-labels.edf` | A `µV` unit and an accented label, written as Latin-1 bytes | The spec says printable ASCII and exporters write `µV` anyway, because that is what the amplifier measures in. Two characters become three bytes of UTF-8, which a spreadsheet reading the system code page renders as mojibake unless the file says otherwise. | | `magnetometer.edf` | ±1e-16 T over a 16-bit converter — a step of 3.05e-21 | Needs 23 decimals and got 20, so three digital codes shared each printed value and the arithmetic for recovering the code stopped working. | | `unprintable-step.bdf` | ±1e-99 m over a 24-bit converter | A step below 1e-98, past what `toFixed` will print at any precision. It exists to be warned about rather than recovered — `VALUE_RESOLUTION` is what is left when no number of decimals would separate consecutive codes. | | `comma-decimal.edf` | `tiny.edf` with its signal-count and record-duration fields patched to `2,0` and `1,0` | A comma decimal separator, which the spec forbids and writers emit. The signal-count field at offset 252 was read with its own `Number()`, which tolerated NUL padding but not the comma every other field here accepts, so the file died on a message contradicting itself. | The suite also opens files that aren't EDF at all, and a path that doesn't exist, and asserts that both fail with a typed error and a readable message rather than a raw errno or a stack trace. ## Why the fixtures are generated rather than committed `test/fixtures/generated/` is in `.gitignore`. The files are built fresh by `npm run fixtures`, which `npm test` runs for you. There are four reasons. **Every edge case is legible.** What makes `truncated.edf` truncated is a line reading `truncateRecords: 4` next to a header that declares 10. With a committed binary you'd have to reverse-engineer the file to learn what it was testing. **They can be changed.** Adjusting an edge case means editing a number and rerunning. With committed binaries, the test suite ends up shaped by whichever files someone happened to have rather than by which cases matter. **Real recordings carry patient identification in the header.** EDF's header has dedicated fields for patient identification and recording identification. A fixture taken from a real study puts whatever those fields contain into a public git history permanently. Generated fixtures have synthetic headers, and the one file that needs a realistically formatted EDF+ patient line uses the example from the specification document rather than a real one. **The repository stays small and text-only.** The whole fixture set regenerates in well under a second, and the generator is deterministic: the sample generators are a ramp and a sine, with no randomness and no timestamps, so regenerating produces byte-identical files. You can verify that: ```bash npm run fixtures shasum -a 256 test/fixtures/generated/* > before.txt npm run fixtures shasum -a 256 test/fixtures/generated/* > after.txt diff before.txt after.txt && echo "byte identical" ``` The same `.gitignore` also reserves `test/fixtures/downloaded/` for large real recordings pulled on demand for the cross-check work. Those are never committed either. ## Running the suite yourself You need Node 20 or newer, and nothing else. The package has no dependencies, and the only development dependencies are TypeScript and the Node type definitions. ```bash git clone https://github.com/tayal-sarthak/edf2csv.git cd edf2csv npm install npm test ``` `npm test` compiles the TypeScript, regenerates the fixtures, and runs the six test files with Node's built-in test runner. There's no test framework to install and no configuration file to read. It takes about twenty seconds on a laptop, almost all of it in three places: `cli.test.js` spawns the built binary as a subprocess for every case and interrupts a thirty-file batch to watch it stop, `large.test.js` builds and reads multi-gigabyte recordings, and `stdout-audit.test.js` creates and mounts a small disk image to fill it up — with `hdiutil`, so those nine run on macOS and are skipped on Linux, CI included. Two more in `cli.test.js` go with them, for filesystem behaviour rather than for a tool: one needs a filesystem that folds case, the other one that folds Unicode normalisation, and Linux does neither. Eleven of the numbers below are a laptop's; CI's own summary says which. Three of `large.test.js`'s six are conditional on the machine instead of the platform — they skip below 8 GiB of RAM, since one builds a 32 MB recording into a 283 MB CSV and two more hold a single record of over two gigabytes — so a small machine produces fourteen fewer than a large one and says so on each. Every one of these is a `t.skip`, reported as a skip and never as a pass. The rest — the parser, the conversion planning, the CSV contents, the documentation checks — runs in about a second between them: ``` ℹ tests 538 ℹ suites 59 ℹ pass 538 ℹ fail 0 ``` The 538 tests are split across six files by what they exercise: | File | Tests | What it covers | | --- | --- | --- | | `test/edf.test.js` | 71 | Header parsing, diagnostics, digital-to-physical conversion, chunked reading, BDF, EDF+ annotation decoding | | `test/convert.test.js` | 146 | Time specifications, option checking, column naming, channel selection, rate grouping, and the contents of the written CSV files | | `test/cli.test.js` | 194 | The built executable: exit codes, stdout versus stderr, overwrite refusal, unwritable destinations, invocation through a symlink as `npx` does. Two cases need a filesystem that folds case or Unicode normalisation and skip where there is none, which is everywhere CI runs | | `test/docs.test.js` | 112 | That this documentation and the source agree on their lists of codes, flags and exit codes | | `test/stdout-audit.test.js` | 9 | A destination that fills up, for `--stdout` and for `--out`, which needs a filesystem of a known small size and so is kept apart. It builds one with `hdiutil`, which only macOS has, and skips rather than pretends anywhere else — including in CI, which runs on Linux. These nine are counted here and exercised on a laptop; a skip is reported as a skip and never as a pass | | `test/large.test.js` | 6 | Recordings of a few gigabytes, built sparse, kept apart for the same reason | To run one file, build and generate first, then point the runner at it: ```bash npm run build && npm run fixtures node --test test/edf.test.js ``` To run a single group of tests, filter by name: ```bash node --test --test-name-pattern="conversion" test/edf.test.js ``` The CLI tests run the real built binary as a subprocess and inspect its exit code and streams, so they check the contract a script depends on rather than an internal function. That includes one easily broken case: `npx` invokes the tool through a symlink, and an entry-point check that compares the symlink path against the module's own resolved path would make the command exit 0 having done nothing. A test creates a symlink and asserts the tool actually runs. ## What is not verified **edf2csv does no signal processing.** No filtering, no notch removal, no detrending, no re-referencing, no artifact rejection, no downsampling, no unit conversion, no interpolation. Microvolts stay microvolts. If your analysis needs a 0.5 Hz high pass, edf2csv won't apply one, and no test here says anything about how such a filter should behave. **The correctness claim is about the conversion, not about the recording.** If a channel's header declares a calibration that doesn't match the amplifier that produced it, edf2csv converts it faithfully with the wrong calibration. It reports when the header is internally contradictory, and it prints the declared ranges in `--info` and `channels.csv` so you can check them, but it has no way to know what the hardware actually did. **No claim is made about clinical fitness.** This isn't a medical device, it has no regulatory clearance of any kind, and it isn't validated for diagnosis, patient management or any clinical decision. It's MIT licensed, which means it's provided as is and without warranty. Read the license before using it anywhere that matters. **The cross-check covers the recordings it covers.** EDF is an old and loosely followed format, and writers do surprising things. Bit identity was established on the recordings used for testing plus the generated fixtures. A file from a writer nobody in this project has seen may still be read in a way you disagree with. That's why the tool raises warnings, and why `--info` reads the header without converting anything. **Timing is taken from the file.** Sample times are derived from the record duration in the header and, for EDF+D recordings, from the timestamps in the annotation channel. There's no correction for amplifier clock drift, and no attempt to reconcile the header's start time with any external clock. Each of these sweeps refuses to report an invariant it held over nothing, and since 0.9.50 that is asked of every count it prints rather than of the first: `npm run narrowing` reports five, and a guard on two of them let it say "plus 0 single-channel selections in the long layout" and pass. **Nothing here verifies your pipeline.** A conversion that's bit-exact is still only the first step. `metadata.json` records the tool version, the source file, the time window converted and, with `--checksum`, a SHA-256 of the input, so a result can be traced back to the exact bytes it came from. --- # Recipes > Short, tested snippets for loading, scripting and querying the CSV that edf2csv writes Every snippet below is written against the files edf2csv actually produces. File names are examples; substitute your own. ## What a conversion leaves on disk ```bash edf2csv sleep-study.edf --out ./sleep_csv ls ./sleep_csv ``` ```text annotations.csv channels.csv metadata.json signals_100hz.csv signals_10hz.csv signals_1hz.csv ``` Four kinds of file can appear: - `signals.csv` holds the sample data. Its first column is `time_s`, seconds from the start of the recording, followed by one column per channel named after the channel's EDF label (`EEG Fpz-Cz`, `EOG horizontal`, `Temp rectal`). If the recording mixes sampling rates there's no `signals.csv`; instead you get `signals_100hz.csv`, `signals_1hz.csv` and so on, one file per rate, never resampled. - `annotations.csv` appears for EDF+ and BDF+ recordings. Columns: `onset_s`, `duration_s`, `description`, `record_index`. `duration_s` is empty for events that carry no duration, and also for events whose stated duration is not a number — the run warns when that happens, since the cell cannot tell the two apart. - `channels.csv` always appears. Columns: `column`, `signal_index`, `label`, `unit`, `sampling_rate_hz`, `samples_per_record`, `physical_min`, `physical_max`, `digital_min`, `digital_max`, `transducer`, `prefiltering`, `output_file`, `converted`. - `metadata.json` always appears, and records what was converted: the source path and size, the recording's start time and record layout, the exact time window converted, the rate groups, and every warning raised. ## Load signals.csv into pandas with time as the index ```python import pandas as pd signals = pd.read_csv("sleep_csv/signals_100hz.csv", index_col="time_s") signals.columns.tolist() # ['EEG Fpz-Cz', 'EEG Pz-Oz', 'EOG horizontal'] signals.loc[3600:3630] # the 30 seconds starting one hour in signals["EEG Fpz-Cz"].describe() ``` EDF labels routinely contain spaces and hyphens, so columns are addressed with brackets rather than attribute access: `signals["EEG Fpz-Cz"]`, not `signals.EEG`. `time_s` is an ordinary float index in seconds, which makes `.loc[start:stop]` a plain numeric slice. That slice asks for a label range, so it needs `time_s` to increase down the file. Two recordings break that, and a conversion of either warns about the shape that does it: data records stored out of chronological order, and data records that overlap in time. Both make the column decrease somewhere, and the slice then raises `KeyError: Cannot get right slice bound for non-monotonic index` — or, where the bound is one of the instants an overlap wrote twice, `Cannot get left slice bound for non-unique label`. Which of the two, and whether it raises at all, depends on where the bounds fall, so a slice that worked yesterday is not evidence that the file is in order. `df.sort_values("time_s")` is the fix for either, and the join further down needs the same. A repeated time on its own is not a problem here. A channel sampling faster than the time column can separate writes several rows at one instant, in order, and the slice returns all of them — which is the right answer, since all of them were recorded. On everything else, which is nearly every recording, the slice is the plain numeric one it looks like. If you converted a window with `--start` and `--duration`, `time_s` still counts from the beginning of the whole recording, not from the beginning of the excerpt. A conversion started at 286.5 s begins its first row at `286.500`, so the numbers keep meaning the same thing whichever slice you converted. The decimal count comes from the rate — three places at 100 Hz, eight at 256 Hz — so the same window written from a faster channel reads `286.50000000`. ## Give the rows a wall-clock timestamp ```python import json import pandas as pd meta = json.load(open("sleep_csv/metadata.json")) start = pd.Timestamp(meta["recording"]["start_datetime_local"]) signals = pd.read_csv("sleep_csv/signals_100hz.csv") signals.index = start + pd.to_timedelta(signals.pop("time_s"), unit="s") signals.index.name = "clock" signals.head(2) ``` ```text EEG Fpz-Cz EEG Pz-Oz EOG horizontal clock 2002-03-02 23:10:00.000 0.061 0.061 0.061 2002-03-02 23:10:00.010 1.648 1.404 0.916 ``` EDF stores no time zone, so `start_datetime_local` is written without one: it's the recorder's own wall clock, exactly as the header spelled it. Nothing needs to be stripped before use. If the header's date is unreadable the field is `null`, and the raw fields survive as `start_date_raw` and `start_time_raw`. ## Load signals.csv into R ```r signals <- read.csv("sleep_csv/signals_100hz.csv", check.names = FALSE) names(signals) #> [1] "time_s" "EEG Fpz-Cz" "EEG Pz-Oz" "EOG horizontal" signals[["EEG Fpz-Cz"]][1:5] ``` `check.names = FALSE` is the important part. Without it R rewrites `EEG Fpz-Cz` into `EEG.Fpz.Cz`, and your column names no longer match the labels in `channels.csv`. To index by time rather than row number, use zoo: ```r library(zoo) eeg <- zoo(as.matrix(signals[, -1, drop = FALSE]), order.by = signals$time_s) excerpt <- window(eeg, start = 3600, end = 3630) plot(excerpt) ``` `read.csv` is slow on files of a few hundred megabytes. For those, `data.table::fread` reads the same file in a fraction of the time and preserves the column names by default: ```r library(data.table) signals <- fread("sleep_csv/signals_100hz.csv") setkey(signals, time_s) signals[time_s %between% c(3600, 3630), .(time_s, `EEG Fpz-Cz`)] ``` ## Load signals.csv into MATLAB ```matlab signals = readtable("sleep_csv/signals_100hz.csv", "VariableNamingRule", "preserve"); signals.time_s = seconds(signals.time_s); tt = table2timetable(signals, "RowTimes", "time_s"); eeg = tt.("EEG Fpz-Cz"); excerpt = tt(timerange(seconds(3600), seconds(3630)), :); stackedplot(excerpt) ``` `"VariableNamingRule", "preserve"` keeps `EEG Fpz-Cz` intact; without it MATLAB renames the column to a valid identifier and it stops matching `channels.csv`. Converting `time_s` to a `duration` first is what lets `table2timetable` and `timerange` work in seconds. For a file too large to load at once, read it in blocks with a datastore. Note that `tabularTextDatastore` renames columns that aren't valid MATLAB identifiers, so check `ds.VariableNames` before referring to them: ```matlab ds = tabularTextDatastore("sleep_csv/signals_100hz.csv"); ds.ReadSize = 500000; disp(ds.VariableNames) peak = 0; while hasdata(ds) chunk = read(ds); peak = max(peak, max(abs(chunk{:, 2}))); end ``` ## Check the output size before converting a long recording ```bash edf2csv sleep-study.edf --info ``` ```text File ./sleep-study.edf Format EDF+ (continuous) Recorded 2002-03-02 23:10:00 Duration 8h 00m 0s (28,800 records of 1s) Size 18.7 MB Patient X X X X Recording Startdate 02-MAR-2002 X X X Channels 5 signals + 1 annotation channel # COLUMN LABEL UNIT RATE RANGE OUTPUT 0 EEG Fpz-Cz EEG Fpz-Cz uV 100 Hz -250 to 250 signals_100hz.csv 1 EEG Pz-Oz EEG Pz-Oz uV 100 Hz -250 to 250 signals_100hz.csv 2 EOG horizontal EOG horizontal uV 100 Hz -250 to 250 signals_100hz.csv 3 Resp oro-nasal Resp oro-nasal V 10 Hz -1 to 1 signals_10hz.csv 4 Temp rectal Temp rectal degC 1 Hz 34 to 40 signals_1hz.csv Sampling rates differ, so channels are written to 3 files, one per rate. No channel is resampled. Would write 3,196,800 rows, roughly 108 MB, and annotations.csv. ``` `--info` reads the header, and on an EDF+ recording a little of the annotation channel: at most sixteen records of a continuous file to find where it begins, stopping at the first that says, and the whole channel for a discontinuous one, whose record times are stored rather than arithmetic. It returns in milliseconds whatever the file's size either way, and writes nothing. [What it can and cannot tell you](/docs/warnings-and-errors#how-edf2csv-reports-problems) sets out which warnings follow from that. CSV runs several times the size of the EDF — about five times here, and higher for a recording with few channels, since every row carries a `time_s` cell however many channels share it — so the estimate is worth reading before you start. It reads high on purpose: this conversion writes 94 MB against the 108 MB predicted, because a cell is budgeted at the width its channel's declared physical range allows and most samples sit well inside it. The estimate line goes to stdout and the warnings go to stderr, which makes each of them easy to pick out on its own: ```bash edf2csv sleep-study.edf --info 2>/dev/null | grep '^Would write' edf2csv sleep-study.edf --info 2>&1 >/dev/null | grep '^warning:' ``` If the estimate is larger than you want, narrow the conversion rather than converting and then deleting. Any combination of these works: ```bash edf2csv sleep-study.edf --channels "EEG Fpz-Cz,EOG horizontal" --info edf2csv sleep-study.edf --start 1h --duration 20m --info edf2csv sleep-study.edf --decimals 2 --info ``` ## Survey a directory without converting anything `--info --json` describes a recording as JSON, so a whole folder can be summarised in one pass without writing a byte: ```bash for f in /data/recordings/*.edf; do edf2csv "$f" --info --json done | jq -s -r ' .[] | [ (.path | split("/") | last), .format, .duration_seconds, (.channels | length), (.channels | map(.sampling_rate_hz) | unique | join("/")), .estimate.rows, ([.warnings[].code] | join(";")) ] | @tsv' ``` ```text night-01.edf EDF+ (continuous) 28800 5 1/10/100 3196800 MIXED_SAMPLING_RATES;LARGE_OUTPUT night-02.edf EDF 2 2 10 20 ``` Nothing is read past the header for plain EDF, and at most sixteen records' annotation slots for a continuous EDF+, so this stays fast over a directory of multi-gigabyte recordings. Find the ones that need attention before converting: ```bash for f in /data/recordings/*.edf; do edf2csv "$f" --info --strict >/dev/null 2>&1 || echo "needs a look: $f" done ``` ## Pipe a conversion straight into another tool `--stdout` writes the signal CSV to stdout and creates no directory: ```bash edf2csv sleep-study.edf --stdout --channels "EEG Fpz-Cz" | duckdb -c "SELECT count(*), avg(\"EEG Fpz-Cz\") FROM read_csv('/dev/stdin')" ``` A stream holds one table, so this needs the recording to produce exactly one. A mixed-rate file is refused rather than merged, and the refusal names three ways out: narrow to a single rate with `--channels`, add `--layout long` to put every rate in one table of `time_s,channel,value`, or convert to a directory instead. The row count goes to stderr, so stdout carries nothing but CSV. ## Convert a whole folder of recordings ```bash mkdir -p converted for f in /data/recordings/*.edf; do name=$(basename "$f" .edf) if edf2csv "$f" --out "converted/$name" --quiet; then echo "ok $name" else echo "FAIL $name (exit $?)" >&2 fi done ``` The loop above is worth keeping when you want to act on each file as it finishes. When you only want the conversions, one command does it: ```bash edf2csv /data/recordings/*.edf --out converted --quiet ``` `--quiet` suppresses the per-file summary but still prints warnings, so a truncated or discontinuous file doesn't pass silently. Exit code 0 means every recording converted, 1 means at least one had a problem with the file or the output directory, and 2 means a problem with the command line. Add `--force` if you're re-running over a folder you've already converted; without it an existing output directory is an error rather than something to be overwritten by accident. To pick up BDF files in the same pass, and to survive spaces in file names, use `find`: ```bash find /data/recordings -type f \( -name '*.edf' -o -name '*.bdf' \) -print0 | while IFS= read -r -d '' f; do name=$(basename "$f") edf2csv "$f" --out "converted/${name%.*}" --force --quiet 2>>convert.log done ``` ## Script over the summary with --json and jq ```bash edf2csv recording.edf --out ./out --json ``` ```json { "tool": { "name": "edf2csv", "version": "..." }, "output_dir": "./out", "files": [ { "name": "signals_256hz.csv", "rows": 768 }, { "name": "signals_128hz.csv", "rows": 384 }, { "name": "signals_1hz.csv", "rows": 3 }, { "name": "channels.csv", "rows": 3 } ], "annotations": 0, "duration_seconds": 3, "records": 3, "elapsed_ms": 8, "warnings": [ { "code": "MIXED_SAMPLING_RATES", "severity": "warning", "message": "Channels use 3 different sampling rates (256 Hz, 128 Hz, 1 Hz)." } ] } ``` `--json` puts the whole summary on stdout and nothing else, warnings included, so nothing needs to be scraped out of the human-readable text. Count the data rows actually written: ```bash edf2csv recording.edf --out ./out --force --json | jq '[.files[] | select(.name | startswith("signals")) | .rows] | add' ``` Turn a folder into a one-line-per-recording table. `jq -s` collects the separate summary documents into an array: ```bash for f in /data/recordings/*.edf; do edf2csv "$f" --out "converted/$(basename "$f" .edf)" --force --quiet --json done | jq -s -r ' .[] | [ .output_dir, .duration_seconds, .annotations, ([.files[] | select(.name | startswith("signals")) | .rows] | add), ([.warnings[].code] | join(";")) ] | @tsv' ``` ```text converted/night-01 3 3 300 converted/night-02 3 0 1155 MIXED_SAMPLING_RATES ``` Make a pipeline stop on any warning with `--strict`, which exits 1 when the recording raised one. The output is still written, so you can look at what triggered it: ```bash edf2csv recording.edf --out ./out --force --strict || echo "conversion raised warnings, check them before using the output" >&2 ``` Reach for `--json` when you care about a specific warning rather than any of them: ```bash edf2csv recording.edf --out ./out --force --json | jq -e '[.warnings[].code] | index("DISCONTINUOUS") | not' >/dev/null || echo "this recording has gaps" >&2 ``` ## Extract only the annotations from a set of recordings ```bash mkdir -p events for f in /data/recordings/*.edf; do name=$(basename "$f" .edf) edf2csv "$f" --annotations-only --out "events/$name" --force --quiet done ``` `--annotations-only` skips the signal data entirely, so this runs in about the time it takes to read the annotation channel. Each output directory gets `annotations.csv`, `channels.csv` and `metadata.json`, and no signal files. A plain EDF has no annotation channel, so it produces no `annotations.csv` at all and warns that there was nothing to export: ```text warning: --annotations-only was requested but this recording has no annotation channel, so there are no events to export. Plain EDF files carry no annotations. Convert without --annotations-only to get the signals. ``` Because that warning goes to stderr, the loop above keeps going and the folder that comes out has one directory per recording either way. The `glob` below finds nothing for the files that had no events. Stack them into one table, keeping track of which recording each event came from: ```python import pathlib import pandas as pd frames = [] for path in sorted(pathlib.Path("events").glob("*/annotations.csv")): events = pd.read_csv(path) events.insert(0, "recording", path.parent.name) frames.append(events) all_events = pd.concat(frames, ignore_index=True) all_events.to_csv("all-events.csv", index=False) all_events.groupby("description").size().sort_values(ascending=False) ``` ```text description Lights off 2 Seizure onset 2 Sleep stage W 2 ``` ## Pull a 30 second window around a marked event ```bash edf2csv overnight-eeg.edf --annotations-only --out ./events --force --quiet onset=$(python3 -c " import csv with open('events/annotations.csv', newline='') as f: for row in csv.DictReader(f): if row['description'] == 'Seizure onset': print(row['onset_s']) break ") start=$(python3 -c "print(max(0.0, $onset - 15))") edf2csv overnight-eeg.edf --start "$start" --duration 30 --out ./seizure-window ``` ```text Wrote ./seizure-window signals.csv 7,680 rows annotations.csv 1 row channels.csv 1 row Done in 0.4s. ``` Two things make this work. `--start` accepts a plain number as seconds (as well as `30s`, `5m`, `1h30m` and `00:30:00`), so an onset read straight out of `annotations.csv` can be handed to it unchanged. And the window's `annotations.csv` is filtered to events whose onset falls inside the window, so the excerpt arrives with its own event list already attached. Reading the onset with Python's `csv` module rather than `cut -d,` matters because descriptions are free text and are quoted when they contain a comma. Finding the event in the resulting CSV is then just arithmetic on `time_s`, which is still measured from the start of the whole recording: ```python import pandas as pd signals = pd.read_csv("seizure-window/signals.csv", index_col="time_s") events = pd.read_csv("seizure-window/annotations.csv") onset = events.loc[events["description"] == "Seizure onset", "onset_s"].iloc[0] signals.loc[onset - 2 : onset + 2] ``` That is the same label slice as above, so the same two recordings break it in the same two ways. ## Align two rate groups with pandas merge_asof ```python import pandas as pd fast = pd.read_csv("sleep_csv/signals_100hz.csv") slow = pd.read_csv("sleep_csv/signals_1hz.csv") aligned = pd.merge_asof(fast, slow, on="time_s", direction="backward") aligned.head(3) ``` ```text time_s EEG Fpz-Cz EEG Pz-Oz EOG horizontal Temp rectal 0 0.00 0.061 0.061 0.061 37.00073 1 0.01 1.648 1.404 0.916 37.00073 2 0.02 3.236 2.747 1.770 37.00073 ``` Both frames must be sorted on the join key, which for an ordinary recording they already are. One kind of file breaks that: an EDF+D recording whose data records are stored out of chronological order writes its rows in file order, so `time_s` does not increase monotonically and `merge_asof` raises `ValueError: left keys must be sorted`. The conversion says so — "1 data record starts earlier than the record before it" — and `df.sort_values("time_s")` before the join is the fix. [The time_s column](/docs/output-files#the-time_s-column) sets out when that happens. `direction="backward"` carries the most recent slow reading forward, `"nearest"` picks the closer of the two neighbours, and `tolerance` leaves `NaN` where no reading is close enough: ```python aligned = pd.merge_asof(fast, slow, on="time_s", direction="nearest", tolerance=0.5) aligned["Temp rectal"].isna().sum() ``` This is the step edf2csv leaves to you. Once the temperature column has 2,880,000 entries, only 28,800 of which came off a sensor, nothing in the file distinguishes the measurements from the fill. Doing it here keeps the choice of `direction` and `tolerance` in your analysis code, and leaves the files on disk holding only recorded values. ## Read a very large signals.csv in chunks ```python import pandas as pd peak = 0.0 rows = 0 for chunk in pd.read_csv( "sleep_csv/signals_100hz.csv", chunksize=500_000, usecols=["time_s", "EEG Fpz-Cz"], ): peak = max(peak, chunk["EEG Fpz-Cz"].abs().max()) rows += len(chunk) print(rows, peak) # 2880000 250.0 ``` `chunksize` makes `read_csv` return an iterator of frames instead of one frame, so memory stays flat regardless of file size. `usecols` is the bigger win on a wide montage: naming the two columns you need means the other twenty are never parsed. For work that needs all the columns, `dtype` halves the memory a chunk occupies, at a cost in precision you should think about first: ```python reader = pd.read_csv( "sleep_csv/signals_100hz.csv", chunksize=500_000, dtype={"EEG Fpz-Cz": "float32", "EEG Pz-Oz": "float32", "EOG horizontal": "float32"}, ) ``` Keep `time_s` as float64. A recording eight hours long reaches times near 28,800 s, and float32 can't hold that with 8 decimal places. ## Query the CSV directly with DuckDB ```bash duckdb -c "SELECT count(*) AS rows, min(time_s), max(time_s) FROM 'sleep_csv/signals_100hz.csv'" ``` DuckDB reads the CSV where it lies and streams it, so the 89 MB `signals_100hz.csv` this recording produces can be aggregated without an 89 MB frame in memory and without an import step. Column names containing spaces are quoted with double quotes, exactly as SQL requires: ```bash duckdb -c " SELECT floor(time_s / 30) * 30 AS epoch_start, avg(\"EEG Fpz-Cz\") AS mean_uv, max(abs(\"EEG Fpz-Cz\")) AS peak_uv FROM 'sleep_csv/signals_100hz.csv' GROUP BY 1 ORDER BY 1 LIMIT 5" ``` Cutting an excerpt back out to CSV is one statement: ```bash duckdb -c " COPY ( SELECT time_s, \"EEG Fpz-Cz\" FROM 'sleep_csv/signals_100hz.csv' WHERE time_s BETWEEN 3600 AND 3630 ) TO 'excerpt.csv' (HEADER, DELIMITER ',')" ``` Rate groups can be joined the same way pandas does it, with `ASOF JOIN` (DuckDB 0.9 or newer): ```sql SELECT f.time_s, f."EEG Fpz-Cz", s."Temp rectal" FROM 'sleep_csv/signals_100hz.csv' AS f ASOF JOIN 'sleep_csv/signals_1hz.csv' AS s ON f.time_s >= s.time_s; ``` The same queries work from Python without the shell quoting: ```python import duckdb duckdb.sql(""" SELECT avg("EOG horizontal") FROM 'sleep_csv/signals_100hz.csv' WHERE time_s BETWEEN 3600 AND 3630 """).df() ``` ## Attach units and calibration from channels.csv ```python import pandas as pd channels = pd.read_csv("sleep_csv/channels.csv").set_index("column") channels[["label", "unit", "sampling_rate_hz", "output_file", "converted"]] ``` ```text label unit sampling_rate_hz output_file converted column EEG Fpz-Cz EEG Fpz-Cz uV 100 signals_100hz.csv yes EEG Pz-Oz EEG Pz-Oz uV 100 signals_100hz.csv yes EOG horizontal EOG horizontal uV 100 signals_100hz.csv yes Resp oro-nasal Resp oro-nasal V 10 signals_10hz.csv yes Temp rectal Temp rectal degC 1 signals_1hz.csv yes ``` `channels.csv` lists every signal channel in the file, including ones you excluded with `--channels`; the `converted` column says which made it into a CSV, and `output_file` says which one. The `column` values are exactly the column headers used in the signal files, so this table is the lookup for labelling a plot axis or checking a unit: ```python units = channels["unit"].to_dict() ax.set_ylabel(f"EEG Fpz-Cz ({units['EEG Fpz-Cz']})") ``` The `physical_min`, `physical_max`, `digital_min` and `digital_max` columns are the header's calibration as recorded. They're what edf2csv used to convert the samples, so they let anyone reproduce the arithmetic from the digital values. ## Record a checksum so a conversion can be reproduced ```bash edf2csv recording.edf --out ./out --checksum jq -r '.source | "\(.bytes) bytes \(.sha256)"' ./out/metadata.json ``` ```text 1548 bytes 2e07d98230275974... ``` `--checksum` costs one extra read of the input and writes a SHA-256 into `metadata.json`, where it sits alongside the tool version, the source path and modification time, the exact converted window, and every warning raised. Without the flag the field is `null`. That makes the output directory self-describing. Months later, `metadata.json` still records which file this came from, which version produced it, which seconds of the recording it covers, and what was flagged at the time. ```bash jq '{tool: .tool.version, window: [.conversion.start_seconds, .conversion.end_seconds], notes: [.notes[].code]}' ./out/metadata.json ``` --- # Programmatic API > Read EDF headers, stream raw samples and run conversions from JavaScript or TypeScript, with the real signatures ## What the package gives you edf2csv is built as a command-line tool, but the parser and the converter underneath are exported so you can use them directly. Two entry points cover almost everything: - `EdfFile.open(path)` opens a recording, gives you its header and diagnostics, and lets you stream raw samples without producing a CSV at all. - `convert(path, options)` runs the same conversion the CLI runs, into a directory you choose, and hands back what was written along with every warning raised. Everything else in the API is a supporting part of those two: the scaling function that turns digital codes into physical units, the annotation decoder, the planner that decides which channels go into which file. The package is ESM only and needs Node 20 or newer. There's no CommonJS build. `require("edf2csv")` nevertheless works on any Node that can require an ESM graph — verified on 22.16 and 24.4 — because nothing here has top-level `await`. It fails on the older Node 20 releases that predate that, which are inside the supported range, so `await import("edf2csv")` is the form that works everywhere. TypeScript declarations ship with the package, so `import type` works without installing anything else — including `@types/node`, which the declarations deliberately avoid needing. Raw bytes are typed as `Uint8Array` rather than `Buffer` for that reason; a `Buffer` is still what arrives at runtime, since `Buffer` extends `Uint8Array`. The package has no dependencies. ```bash npm install edf2csv ``` ```js import { EdfFile, convert, makeScaler } from 'edf2csv'; ``` If your project is CommonJS, a dynamic import works from an async function: ```js const { EdfFile } = await import('edf2csv'); ``` ## EdfFile.open: inspect a recording ```ts class EdfFile { static open(path: string): Promise; readonly path: string; readonly fileSize: number; /** Last-modified time when the file was opened, in ms; `metadata.json`'s `source.modified`. */ readonly modifiedAtOpenMs: number; readonly header: EdfHeader; /** Records actually present in the file, which may differ from the header's claim. */ readonly recordCount: number; readonly trailingBytes: number; readonly diagnostics: Diagnostic[]; get dataSignals(): EdfSignal[]; // signals, excluding annotation channels get annotationSignals(): EdfSignal[]; get timekeepingSignal(): EdfSignal | undefined; // the first with room to hold a TAL readOrigin(): Promise; scanOrigin(): Promise<{ origin: number | null; malformed: number; // unreadable TALs that cost events malformedTimekeeping: number; // unreadable TALs in first position, costing a record's time malformedTimekeepingWithText: number; // counted in both of the above, never only one recordStarts: (number | null)[]; // what each record it read said, null where it said nothing }>; get durationSeconds(): number; // recordCount * header.recordDuration readRecords(options?: ReadRecordsOptions): AsyncGenerator; sampleAt(batch: RecordBatch, recordOffset: number, signal: EdfSignal, sampleIndex: number): number; offsetOf(batch: RecordBatch, recordOffset: number, signal: EdfSignal): number; annotationBytes(batch: RecordBatch, recordOffset: number, signal: EdfSignal): Uint8Array; sha256(): Promise; // hex digest over fileSize bytes; see below changedSinceOpen(): Promise; // against fileSize and modifiedAtOpenMs readAnnotations(): Promise<{ annotations: Annotation[]; recordStarts: (number | null)[]; malformed: number; // unreadable TALs that cost events malformedTimekeeping: number; // unreadable TALs in first position, costing a record's time malformedTimekeepingWithText: number; // counted in both of the above, never only one unreadableDurations: number; // events kept whose stated duration is not a number negativeDurations: number; // events kept whose stated duration is below zero }>; close(): Promise; } ``` `open` reads the header only. It doesn't touch the data records, so it returns immediately whatever the file's size. It opens a file handle that stays open, so **you must call `close()`**, ideally in a `finally` block. Calling `readRecords` or `readAnnotations` after `close()` throws an `EdfError` with code `UNREADABLE`. Two properties need care. `recordCount` is derived from the actual file size, not from the header's declared count, so a truncated recording reports what's really there and raises a `RECORD_COUNT_MISMATCH` diagnostic. `trailingBytes` counts the bytes after the last complete record, which are ignored. ### Printing a channel table ```js import { EdfFile, describeFormat, formatRate, formatWallClock } from 'edf2csv'; const file = await EdfFile.open('/data/recordings/sleep-study.edf'); try { console.log(describeFormat(file.header)); console.log(`${file.recordCount} records of ${file.header.recordDuration}s`); console.log(`duration ${file.durationSeconds}s`); // formatWallClock, not toISOString: see EdfHeader below for why the Z would be a lie. console.log(`start ${formatWallClock(file.header.startDateTime) ?? 'unknown'}`); for (const signal of file.dataSignals) { console.log( `#${signal.index}`.padEnd(4) + signal.label.padEnd(14) + `${formatRate(signal.samplingRate)} Hz`.padEnd(10) + `${signal.physicalMin} to ${signal.physicalMax} ${signal.physicalDimension}`, ); } for (const note of file.diagnostics) { console.log(`${note.severity}: [${note.code}] ${note.message}`); } } finally { await file.close(); } ``` For a three second recording carrying EEG at 256 Hz, ECG at 128 Hz and a rectal thermistor at 1 Hz: ```text EDF 3 records of 1s duration 3s start 1985-01-01T00:00:00.000Z #0 EEG Fpz-Cz 256 Hz -250 to 250 uV #1 ECG 128 Hz -5 to 5 mV #2 Temp rectal 1 Hz 34 to 40 degC warning: [MIXED_SAMPLING_RATES] Channels use 3 different sampling rates (256 Hz, 128 Hz, 1 Hz). ``` `describeFormat(header)` returns `"EDF"`, `"BDF"`, or one of `"EDF+ (continuous)"`, `"EDF+ (discontinuous)"`, `"BDF+ (continuous)"`, `"BDF+ (discontinuous)"` — a BDF+ file reports its own spelling, even though `continuity` normalises to the `EDF+` form. `formatRate(hz)` renders a rate without floating point noise: `256`, `0.5`, `12.5`. `formatRates(rates)` renders several at once and guarantees that rates which differ read as differing. `formatRate` rounds to six decimals, which is what keeps 30 samples in a 0.1-second record on screen as `300` rather than `299.99999999999994` — but it also collapses `1e-6` and `1.25e-6` onto one string. When that happens every rate in the group switches to its shortest exact form. Use it wherever more than one rate is shown together; `--info` and the output filenames both do. `rateSlug` renders one rate with no set to separate it from, so it collapses that pair too and answers `0_000001hz` for both — while a recording carrying both is converted into `signals_0_000001hz.csv` and `signals_0_00000125hz.csv`. It is the spelling rule, not a prediction of a filename: to name the files a conversion writes, run the group's rates through `formatRates` first, which is what `buildPlan` does. Both go through one line, so a rendered rate is spelled the same way in either. ### EdfHeader Every field is read straight from the 256-byte fixed header plus the per-signal block. Nothing is normalised except where noted. ```ts interface EdfHeader { version: string; // '0' for EDF, 'BIOSEMI' for BDF patientId: string; recordingId: string; startDateRaw: string; // raw 'dd.mm.yy' as written in the file startTimeRaw: string; // raw 'hh.mm.ss' as written in the file startDateTime: Date | null; // null when the date/time fields are unusable headerBytes: number; // computed from signalCount, not read from the field declaredHeaderBytes: number; // what the field says, which need not match reserved: string; isEdfPlus: boolean; isBdf: boolean; // BioSemi BDF/BDF+, 3 bytes per sample instead of 2 continuity: 'EDF+C' | 'EDF+D' | null; declaredRecordCount: number; // as declared; -1 means the header does not say recordDuration: number; // seconds, may be fractional signalCount: number; signals: EdfSignal[]; // includes annotation channels bytesPerSample: number; // 2 for EDF, 3 for BDF recordBytes: number; // size of one data record } ``` `startDateTime` is a `Date` built with `Date.UTC`, which makes it a carrier for the file's wall-clock digits rather than a real instant — EDF records no timezone at all. Do not serialise it with `toISOString()`: the `Z` asserts UTC, and a reader converting to local time then shifts the recording by their own offset. Use [`formatWallClock`](#smaller-exports), which writes the digits without a zone. EDF stores a two-digit year, and the spec pins the century: 85 to 99 mean 1985 to 1999, 00 to 84 mean 2000 to 2084 — except on an EDF+ file whose recording identification field begins `Startdate dd-MMM-yyyy` and agrees with the date field, where the four-digit year there is used and a 1984 recording reads as 1984. Dates that can't be parsed, or that roll over (31.02, say), give `null` rather than a wrong instant, and `startDateRaw` and `startTimeRaw` still hold whatever the file wrote. `continuity` normalises the BDF+ spelling: a file reserving `BDF+D` reports `'EDF+D'`, because the two mean the same thing. `headerBytes` is the one field that is computed rather than read: 256 for the fixed header plus 256 per signal. Every data record offset is derived from it, so it has to be the size the layout actually uses — a writer that fills the length field in carelessly is common enough to have its own warning, `HEADER_BYTES_MISMATCH`, and believing the field over the arithmetic would put every sample at the wrong offset. `declaredHeaderBytes` carries what the field said, the same way `declaredRecordCount` does for the record count, so a caller auditing how a recording was written can see both. ### EdfSignal ```ts interface EdfSignal { index: number; // position in the file, 0-based, stable when labels collide label: string; transducer: string; physicalDimension: string; // the unit, e.g. 'uV' physicalMin: number; physicalMax: number; digitalMin: number; digitalMax: number; prefiltering: string; samplesPerRecord: number; reserved: string; isAnnotations: boolean; // true for 'EDF Annotations' / 'BDF Annotations' samplingRate: number; // samplesPerRecord / recordDuration, in Hz byteOffsetInRecord: number; // where this signal's samples start inside one record } ``` `header.signals` holds every signal including annotation channels. `file.dataSignals` and `file.annotationSignals` are the two halves, split on `isAnnotations`. `file.timekeepingSignal` is the one a record's start time is read from: EDF+ puts that TAL first in the first annotation channel, but a channel declared with zero samples per record has a zero-byte slot and can hold nothing, so it is the first channel with room rather than the first declared. Match on `index` rather than on `label` if you need to be certain which channel you've: labels are free text and aren't guaranteed unique. ### Diagnostic Diagnostics are the non-fatal observations edf2csv makes about a file. They are the same objects the CLI prints as `warning:` lines and writes into `metadata.json`. ```ts interface Diagnostic { code: DiagnosticCode; severity: 'warning'; message: string; /** What the user can do about it. Omitted when there is nothing useful to say. */ hint?: string; } ``` `DiagnosticCode` is a closed union, so a `switch` over it type-checks: ```text MIXED_SAMPLING_RATES DISCONTINUOUS RECORD_COUNT_UNKNOWN RECORD_COUNT_MISMATCH TRAILING_BYTES DEGENERATE_DIGITAL_RANGE DEGENERATE_PHYSICAL_RANGE UNUSABLE_PHYSICAL_RANGE INVERTED_PHYSICAL_RANGE DUPLICATE_LABEL EMPTY_LABEL NO_ANNOTATIONS ANNOTATION_DECODE_FAILED COMMA_DECIMAL LARGE_OUTPUT NO_SIGNAL_CHANNELS NO_SAMPLES STALE_OUTPUT INPUT_CHANGED EMPTY_WINDOW TIME_RESOLUTION VALUE_RESOLUTION HEADER_BYTES_MISMATCH NONPRINTABLE_LABEL FORMULA_LABEL START_TIME_UNREADABLE LEAP_SECOND_START START_DATE_MISMATCH MISSING_EDF_PLUS_MARKER STDOUT_UNSUPPORTED EMPTY_RATE_WINDOW ``` `file.diagnostics` carries only the ones the header parser can raise. Conversion adds more, which is why `convert()` returns its own combined list. A problem serious enough that no trustworthy output is possible throws instead. That's `EdfError`: ```ts class EdfError extends Error { readonly code: EdfErrorCode; readonly hint: string | undefined; } type EdfErrorCode = | 'FILE_TOO_SMALL' | 'BAD_HEADER_FIELD' | 'NO_DATA_RECORDS' | 'INVALID_SIGNAL_COUNT' | 'INVALID_RECORD_DURATION' | 'NO_SAMPLES' | 'UNREADABLE'; ``` `UNREADABLE` covers a missing file, a directory passed where a file was expected, a path that runs through a regular file (`rec.edf/inner`), something that is not a regular file at all such as a socket or a fifo, a permission failure, and a file that changed size while being read. It is also what a method throws on a file that has already been closed. Branch on `code`, never on the message text. ## Streaming samples `readRecords` walks the data records in batches sized by a byte budget rather than loading the file. Peak memory is flat: a 4 GB recording and a 4 MB one use the same working set. ```ts interface ReadRecordsOptions { startRecord?: number; // inclusive, defaults to 0 endRecord?: number; // exclusive, defaults to the file's record count chunkBytes?: number; // read budget per batch, defaults to DEFAULT_CHUNK_BYTES (8 MiB) } interface RecordBatch { firstRecordIndex: number; // index of this batch's first record within the file recordCount: number; data: Uint8Array; // recordCount * header.recordBytes bytes } ``` A record bound that is not a whole number, and a `chunkBytes` that is not a positive one, are refused as `OptionError`s naming the option — a fractional `startRecord` would decode samples from the middle of a record, and `chunkBytes: NaN` used to come back as a `RangeError` from inside Node's allocator. The refusal quotes the value the way every other one in this package does: numbers bare, everything else quoted so its type shows, so `startRecord: '1'` reads `got "1"` rather than `got 1` in a sentence about not being a number. Bounds outside the file are clamped rather than refused, so `startRecord: -1` starts at 0 and an `endRecord` past the last record stops at it. Samples aren't decoded for you. `sampleAt(batch, recordOffset, signal, sampleIndex)` returns the raw digital integer, handling the two byte layouts EDF and BDF use (BDF's 24-bit little-endian values are sign-extended correctly). `recordOffset` is the record's position **within the batch**, from 0 to `batch.recordCount - 1`, not its index in the file. Add `batch.firstRecordIndex` when you need the absolute index. Both bounds are checked, and were not until 0.8.62: a read past the batch returned a plausible `0` for every sample, and one past the channel's own samples returned the *next* channel's data — a 256-sample channel asked for sample 261 answered 243, a real number from the recording belonging to another column. The channel is checked too, since 0.8.72: it has to be one of this recording's own — an element of `header.signals`, or of the `annotationSignals`, `dataSignals` and `selectChannels` subsets filtered out of it. A channel from a *different* open file names a position in that file's records, and reading it here answered with whatever this file keeps at that position: a `.bdf` channel handed to a `.edf` file returned `0 74 147 219 290`, the first EDF channel's samples. `offsetOf` and `annotationBytes` take the same channel and are checked the same way, and since 0.8.73 they check `recordOffset` too: `offsetOf(batch, -5, signal)` handed back `-1300`, `offsetOf(batch, 1.5, signal)` a position half a record in — the pairing of one record's back half with the next one's front that `readRecords` refuses a fractional `startRecord` for — and `annotationBytes` an empty slice, which reads as a record carrying no annotations rather than as a record the batch does not hold. The batch's bytes are checked beside its record count since 0.8.97: all three read `batch.data`, and a batch-shaped object carrying something else answered rather than failing — `sampleAt` with a plain array of four numbers returned `513`, a digital code this recording could have held, and with a `Float64Array` returned `0`, the commonest sample in any recording, while `annotationBytes` handed back twenty numbers that are not the bytes of anything as the annotation channel's own. To get physical units, build a scaler once per signal and apply it per sample. ```ts type Scaler = (digital: number) => number; function makeScaler(signal: EdfSignal): Scaler; ``` ```js import { EdfFile, makeScaler } from 'edf2csv'; const file = await EdfFile.open('/data/recordings/sleep-study.edf'); try { const signal = file.dataSignals.find((s) => s.label === 'EEG Fpz-Cz'); if (!signal) throw new Error('channel not found'); const scale = makeScaler(signal); const { recordDuration } = file.header; let count = 0; let sum = 0; let peak = 0; for await (const batch of file.readRecords()) { for (let r = 0; r < batch.recordCount; r++) { const recordStart = (batch.firstRecordIndex + r) * recordDuration; for (let i = 0; i < signal.samplesPerRecord; i++) { const digital = file.sampleAt(batch, r, signal, i); const microvolts = scale(digital); const timeSeconds = recordStart + i / signal.samplingRate; if (count < 3) console.log(timeSeconds.toFixed(8), digital, microvolts); count++; sum += microvolts; if (Math.abs(microvolts) > Math.abs(peak)) peak = microvolts; } } } console.log(`${count} samples, mean ${(sum / count).toFixed(3)}, peak ${peak.toFixed(3)}`); } finally { await file.close(); } ``` ```text 0.00000000 0 0.06105006105006105 0.00390625 74 9.096459096459096 0.00781250 147 18.00976800976801 768 samples, mean 0.061, peak 122.161 ``` The `recordStart` arithmetic above assumes two things, and EDF+ guarantees neither. It assumes the records are contiguous, which an EDF+D file is free not to be: only the per-record timekeeping annotation says where each one sits. And it assumes the first record sits at zero, which a *continuous* file is also free not to. `fractional-start.edf` is EDF+C with records at 0.5, 1.5 and 2.5 seconds — perfectly contiguous, and half a second later than `index * recordDuration` says. The recipe above times its first sample at 0.000; `convert()` writes 0.500 for the same sample, and the annotation onsets in the same file keep their true values, so an analysis built on the recipe puts events half a second away from the samples they describe. So: read `recordStarts` from `readAnnotations()` and use that array, for any EDF+ file rather than only for a discontinuous one. That is what the conversion does, which is why its `time_s` and its `annotations.csv` agree. `EdfFile.readOrigin()` is the cheap version — it reads at most sixteen records rather than the whole annotation channel — and it is only enough for a **continuous** recording, where the records really are contiguous and one offset places all of them: ```js // EDF+C only. On an EDF+D file this is wrong for every record after a gap. const origin = (await file.readOrigin()) ?? 0; const recordStart = origin + (batch.firstRecordIndex + r) * recordDuration; ``` Check `header.continuity` before reaching for it. On `discontinuous.edf`, whose records sit at 0, 1 and 10 seconds, that arithmetic puts the third record at 2 — nine seconds from where the file says it is, and from where `convert()` writes it. The conversion takes this shortcut for `EDF+C` and reads every record's own start time for `EDF+D`, which is the distinction this recipe was missing. `makeScaler` evaluates `gain * (offset + digital)`, EDFlib's arrangement of the spec formula. The spec-literal ordering, `(digital - digitalMin) * gain + physicalMin`, loses low bits to cancellation on a channel spanning plus or minus 800 uV: digital 0 comes out as 0.19536019536019467 when the exact value is 0.19536019536019536. The arrangement used here keeps the intermediate small and returns the correctly rounded result, which is also bit-for-bit what pyEDFlib and EDFbrowser produce. When `digitalMax === digitalMin` there is no mapping at all, so the scaler returns `NaN` for every sample and the CSV writer leaves those cells empty. A gain of zero has two causes and they are not the same. A genuinely flat physical range — minimum equal to maximum — is a defined mapping, so `physicalMin` is returned and written normally. A range that is not flat whose gain *underflows* to zero, such as -1e-320 to 1e-320 over the full 16-bit range, has no mapping at all: 3e-325 is smaller than the smallest double, so `NaN` is returned and those cells are left empty, exactly as for an overflowed span. The header parser has raised `DEGENERATE_DIGITAL_RANGE`, `DEGENERATE_PHYSICAL_RANGE` or `UNUSABLE_PHYSICAL_RANGE` in each case. Check `Number.isNaN` if you consume `makeScaler` directly. The scaler itself takes one digital sample and, since 0.8.99, refuses anything that is not a number: `+` on a string concatenates, so `scale('42')` added nothing and pasted the text onto the offset — `0.5 + '42'` is `'0.542'` — and came back `0.066` uV where digital 42 is `5.189`. In range, in the right unit, to the right precision, and wrong by a factor of seventy-eight. `null` and `true` read as digital 0 and 1. The spec-literal arrangement above subtracts rather than adds, so it coerced and answered correctly, which made the two orderings disagree about the same argument. Two related helpers, used to pick CSV precision: ```ts function quantizationStep(signal: EdfSignal): number; function decimalsForSignal(signal: EdfSignal, max?: number): number; // max defaults to 100 ``` `quantizationStep` is the smallest physical change one digital unit can express. `decimalsForSignal` is two places past that step, capped at `max`, which is the precision at which no two adjacent digital codes round to the same text. A typical EEG channel lands on 3. The cap defaults to 100 because that is the most `toFixed` will print; it was 20 until 0.4.74, which cost a magnetometer channel most of its digital codes. ### The batch buffer is reused This is the one contract in the API that fails silently if you get it wrong. `readRecords` allocates a single buffer and refills it on every iteration. `batch.data` is a view into that same buffer, not a fresh copy. Once the loop turns over, anything you kept a reference to now shows the *next* batch's bytes. ```js // WRONG. Every entry is a view of one buffer that keeps being overwritten. const kept = []; for await (const batch of file.readRecords()) { kept.push(batch.data); } // kept[0] no longer holds what it held when it was pushed. ``` The views are distinct objects over shared memory, which is worth stating precisely because the obvious test for it gives the wrong answer. `kept[0] === kept[1]` is **false** — each iteration hands you a new `Uint8Array`. What they have in common is `kept[0].buffer === kept[1].buffer`, all at offset 0, so what you are holding is three windows onto the same bytes. Nor do they all end up equal. The final batch is usually short — the 18.7 MB recording this page reads gives two 8 MB batches and a 2.7 MB one — so after the loop `kept[0]` shows the last batch's bytes for as far as they go and the *previous* batch's bytes beyond that. It is neither the first batch nor the last but a seam between two, which is the kind of wrong that produces plausible-looking numbers rather than an error. ```js // CORRECT. Copy what you intend to keep. const kept = []; for await (const batch of file.readRecords()) { kept.push(new Uint8Array(batch.data)); } ``` Use `new Uint8Array(batch.data)` rather than `batch.data.slice()`. `batch.data` is typed as a `Uint8Array`, and on a real `Uint8Array` those are the same thing — but the object you actually receive at runtime is a Node `Buffer`, whose `slice()` is an alias for `subarray()` and returns another view of the same memory rather than a copy. `new Uint8Array(...)` copies whichever of the two you are handed. The same applies to anything derived from the buffer without copying, including `annotationBytes()`, which returns a `subarray` of it. Decoded values are safe: numbers returned by `sampleAt` are copies by nature, and strings produced from the bytes are too. The rule is only about buffers and buffer views. Note also that a small `chunkBytes` doesn't change the results, only how often the buffer is refilled. The minimum batch is one record, so `chunkBytes: 1` still reads a whole record at a time. A value that is not a positive number — `0`, a negative, `NaN`, `Infinity` — is an `EdfError` naming the option, rather than a `RangeError` from inside `Buffer.alloc`. ## Reading annotations ```ts interface Annotation { onset: number; // seconds from the start of the recording duration: number | null; // null when the TAL stated no duration that could be read text: string; recordIndex: number; // the data record this annotation was stored in durationUnreadable?: true; // the file stated a duration and it is not a number } ``` `readAnnotations()` returns every event in the file, the start time each record declares, and five counts of what could not be decoded — kept apart because each is a different loss. `malformed` counts unreadable TALs that cost events; `malformedTimekeeping` counts unreadable TALs that sat in first position, where a record's start time is stored, so what they cost is a position. Those two are **not** nested, and neither contains the other: a bare timekeeping TAL that fails is counted only in the second, so `malformed` can be 0 while `malformedTimekeeping` is 1 — which is what three of this repository's own fixtures do, and it makes `malformed - malformedTimekeeping` a negative number rather than a count of anything. What overlaps is `malformedTimekeepingWithText`: TALs in first position that carried events after the start time as well, which the format allows and writers do. Those lost both, so they are counted in *both* of the other two, and never in only one — `malformed + malformedTimekeeping - malformedTimekeepingWithText` is the number of TALs that failed; `unreadableDurations` counts events that were kept whole except for a duration the file stated and this could not read; and `negativeDurations` counts events whose duration read as a number below zero, which is not a length of time — the value is written out as the file gave it, so nothing about the row looks wrong. `unreadableDurations` is why `duration` being `null` is not by itself the same as the file giving no duration. An event written with a duration of `abc` comes back with `duration: null`, indistinguishable from one that never had a duration — the count is what tells you it happened, and the conversion raises `ANNOTATION_DECODE_FAILED` for it. ```js import { EdfFile } from 'edf2csv'; const file = await EdfFile.open('/data/recordings/sleep-study.edf'); try { if (file.annotationSignals.length === 0) { console.log('no annotation channel: this is plain EDF, not EDF+'); } else { const { annotations, recordStarts, malformed } = await file.readAnnotations(); console.log(`${annotations.length} annotations, ${malformed} unreadable`); for (const event of annotations) { console.log(event.onset, event.duration ?? '-', JSON.stringify(event.text), event.recordIndex); } console.log('record starts', [...recordStarts]); } } finally { await file.close(); } ``` ```text 3 annotations, 0 unreadable 0.5 1 "Sleep stage W" 0 1.25 - "Lights off" 1 2 0.5 "Seizure onset" 2 record starts [ 0, 1, 2 ] ``` Three things to know about this method. It reads only the annotation channel, seeking straight to it inside each record instead of pulling whole records through memory. On a multi-gigabyte recording that's a few kilobytes of I/O rather than all of it. It always scans the entire file, even when you only care about a window. Writers aren't required to store an annotation in the record its onset falls in, and some put every annotation in the first record, so reading only a window's records would drop events that belong in it. The returned annotations are sorted by `onset`, then by `recordIndex`. `recordStarts` has one entry per data record, `null` where the record carried no readable timekeeping annotation. The timekeeping entry itself is never returned as an annotation, because it has an onset and no text. For decoding annotation bytes yourself, `decodeRecordAnnotations(bytes, recordIndex)` handles one record's worth of the channel. Pair it with `file.annotationBytes(batch, recordOffset, signal)` if you're already streaming records and would rather not make a second pass. It returns a `DecodedRecordAnnotations`: ```ts interface DecodedRecordAnnotations { recordStart: number | null; // from the leading timekeeping TAL, null when unreadable annotations: Annotation[]; malformed: number; // unreadable TALs that cost events malformedTimekeeping: number; // unreadable TALs in first position, costing a record's time malformedTimekeepingWithText: number; // counted in both of the above, never only one unreadableDurations: number; // events kept whose duration is not a number negativeDurations: number; // events kept whose duration is below zero } ``` The five counts are what the warnings are raised from, and they are per record here: summing them across the records you decode gives what `readAnnotations` reports for the whole file. ## convert: run a full conversion ```ts function convert(inputPath: string, options?: ConvertOptions): Promise; ``` This is exactly what the CLI calls. It opens the file, reads the annotation channel, builds a plan, creates the output directory, streams every rate group in a single pass over the data records, and writes `channels.csv`, `annotations.csv` when there's an annotation channel, and `metadata.json`. ### ConvertOptions ```ts interface ConvertOptions { // channel and window selection channels?: readonly string[]; // labels, case-insensitive, or '#N' by position start?: number; // seconds on the recording's clock; may be negative duration?: number; // seconds; mutually exclusive with `end` end?: number; // seconds; mutually exclusive with `duration` annotationsOnly?: boolean; // skip the signal files entirely decimals?: number; // fixed precision instead of per-channel // shape and encoding layout?: 'wide' | 'long'; // 'wide' (default), or one table of time_s/channel/value // any other value is an OptionError, as on the CLI gzip?: boolean; // compress every CSV, giving each a .csv.gz name bom?: boolean; // start each CSV with a UTF-8 byte order mark // output outputDir?: string; // defaults to defaultOutputDir(inputPath) force?: boolean; // overwrite an existing output directory checksum?: boolean; // record a SHA-256 of the input in metadata.json toStdout?: boolean; // stream the single table to stdout, write no files onProgress?: (progress: ConversionProgress) => void; // Quoted back in time-range errors so they name the value the caller gave, not its // parsed form. Optional; the parsed seconds are used when absent. Text, and checked to // be since 0.8.91: an object reached the sentence as "[object Object]". startText?: string; durationText?: string; endText?: string; } interface ConversionProgress { recordsDone: number; recordsTotal: number; // endRecord - startRecord bytesWritten: number; } ``` `start`, `duration` and `end` are plain numbers of seconds here, not the `5m` or `00:30:00` strings the CLI accepts. `start` and `end` may be negative, because they name a position on the recording's own clock and that clock can begin before zero; `duration` is a length and may not. Use `parseTimeSpec` if you want to accept those forms from your own users. Passing both `duration` and `end` throws a `TimeRangeError`, as does a window that starts at or past the end of the recording. `onProgress` fires once per batch of records read, not once per record, so on a small file it may fire only once — and on a run that reads no data records for signals it never fires at all. That is four ordinary cases, not an edge: `annotationsOnly`, a recording with no signal channels, a window landing where the recording has no data, and a `channels` selection whose channels carry none. A caller driving a progress display should treat the promise returned by `convert` as the completion signal and `ConvertResult` as the account of what happened; `onProgress` reports work in flight, and those four runs have none to report. `bytesWritten` counts characters as the signal writers flush them, so it's zero until the first flush — and, for the same reason, the last value it reports is short of the finished file by whatever was still in the buffers when the final batch ended: up to one flush threshold per signal file, which is a megabyte each. A 400-record recording that writes 6,251,463 bytes reports 5,243,023. It is a progress signal, not a byte count; `ConvertResult.files` carries the rows actually written, and the files on disk carry the bytes. `defaultOutputDir(inputPath)` gives the directory `convert` would choose on its own: the input filename with its extension replaced by `_csv`, next to the input. `defaultOutputDir('/data/recordings/sleep-study.edf')` is `/data/recordings/sleep-study_csv`. ### ConvertResult ```ts interface ConvertResult { outputDir: string; // "-" under toStdout, where no directory is made files: WrittenFile[]; // { name: string; rows: number } readerHungUp: boolean; // a toStdout reader closed the pipe before the end annotationCount: number; // rows written to annotations.csv diagnostics: Diagnostic[]; // header, plan and stale-output diagnostics combined plan: ConversionPlan; file: EdfFile; // already closed; header and diagnostics still readable elapsedMs: number; } ``` `files` lists only the files that were written, in the order they were written: the signal CSVs first, then `annotations.csv` if the recording has an annotation channel, then `channels.csv`. `metadata.json` is always written but is deliberately not in the list. `toStdout` is the exception to both of those, and it is the one mode where neither sentence above holds. No directory is made, so `outputDir` is `-` — the conventional name for the stream, and not a path to join anything onto. No file is written either, so the single `files` entry names the table rather than a file: `signals.csv`, or `signals.csv.gz` under `gzip`. Its `rows` is the count that matters and the one the command line reports; the `name` is there so the entry has one, and there is nothing on disk to open by it. `annotationCount` is `0` for the same reason — a stream carries one table, and `annotations.csv` is not it — whatever the recording holds. `--info` says the same thing its own way: since 0.8.31 its `OUTPUT` column reads `(stdout)` and the JSON's `output_file` is `-`. `result.file` is closed before `convert` returns. Its `header`, `recordCount` and `diagnostics` are plain data and stay readable, but `readRecords` and `readAnnotations` on it throw `UNREADABLE`. Open the file yourself with `EdfFile.open` if you want to keep reading after converting. `changedSinceOpen()` is the exception: it keeps working on a closed file, because `convert` asks it on the way out and caches the answer. So `await result.file.changedSinceOpen()` agrees with whether the result carries an `INPUT_CHANGED` diagnostic. (It returned `false` on a closed file until 0.4.38, which had the result contradicting itself.) Calling it on a file you closed yourself without ever asking throws `UNREADABLE`, since a closed descriptor cannot answer it. `sha256()` is what `--checksum` uses: it hashes exactly the `fileSize` bytes that were there when the file was opened, through the descriptor already on them, and returns the digest as hex. Reading the path again afterwards would describe whatever answers to that name by then, which for a recording still being written is a different file. It needs the file open, so call it before `close()`. ```js import { EdfFile } from 'edf2csv'; const file = await EdfFile.open('/data/recordings/sleep-study.edf'); const digest = await file.sha256(); // hex, over file.fileSize bytes await file.close(); ``` `modifiedAtOpenMs` is the other half of that: the file's modification time when it was opened, in milliseconds, and the value `changedSinceOpen()` compares against. It is kept as a raw number rather than a `Date` on purpose — `new Date(ms).getTime()` truncates to whole milliseconds, and comparing that against a later `fstat` carrying the filesystem's sub-millisecond precision reported every undisturbed conversion as one whose input had changed underneath it. ### A conversion with options ```js import { convert } from 'edf2csv'; const result = await convert('/data/recordings/sleep-study.edf', { outputDir: '/data/exports/epoch-42', force: true, channels: ['EEG Fpz-Cz', 'Temp rectal'], start: 1, duration: 1, checksum: true, onProgress: (p) => console.log(`records ${p.recordsDone}/${p.recordsTotal}`), }); console.log(result.outputDir, `in ${result.elapsedMs}ms`); for (const written of result.files) console.log(` ${written.name} ${written.rows} rows`); console.log('window', result.plan.range); console.log('estimate', result.plan.estimate); for (const note of result.diagnostics) { console.log(`${note.severity}: [${note.code}] ${note.message}`); if (note.hint) console.log(` ${note.hint}`); } ``` ```text /data/exports/epoch-42 in 14ms signals_256hz.csv 256 rows signals_1hz.csv 1 rows channels.csv 3 rows window { startSeconds: 1, endSeconds: 2, startRecord: 1, endRecord: 2, isWholeRecording: false, recordingStartSeconds: 0, recordingEndSeconds: 3 } estimate { rows: 257, bytes: 5172, exceedsSpreadsheetLimit: false } warning: [MIXED_SAMPLING_RATES] Channels use 2 different sampling rates (256 Hz, 1 Hz). They are written to one file per rate so no channel is resampled. ``` Two channels were requested at two different rates, so two signal files came back. `channels.csv` still has a row for all three channels in the recording, with `converted` set to `no` for the one that was filtered out. The warning names two rates rather than the file's three, because it describes the conversion rather than the recording: the header parser raises its own, which counts every channel and knows nothing about `channels`. `convert` drops that copy in favour of this one — see `withoutFileRateWarning`. ### Errors from convert `convert` throws five distinct error types. All of them are exported, so `instanceof` works. | Type | When | | --- | --- | | `EdfError` | The recording can't be read or its header is unusable. Has `code` and `hint`. | | `ConversionError` | The output can't be written, the recording stopped being readable part way through, the request can't be carried out, or your own callback threw. `code` is `OUTPUT_EXISTS`, `OUTPUT_UNWRITABLE`, `INPUT_OUTPUT_COLLISION`, `INPUT_UNREADABLE`, `UNSUPPORTED_REQUEST`, `CALLBACK_FAILED` or `WRITE_FAILED`. | | `OptionError` | An option is not a value this can act on: `decimals` outside 0 to 20 or not a whole number, a `start` or `end` that is not a finite number of seconds, a `duration` that is not a non-negative one, a `layout` that is neither `"wide"` nor `"long"`, a `channels` value that is not a list of channel names, or is a list that names nothing — an empty array, or one holding only blanks, which is what an empty string split on commas produces — an `outputDir` that is not a path — `""`, or, until 0.8.50, anything that is not a string at all: `42` reached `path.join` and answered `TypeError: The "path" argument must be of type string`, and `null` was not refused anywhere, so the rows went to `_csv` beside the input and the call reported success — or any of `annotationsOnly`, `gzip`, `bom`, `force`, `checksum` and `toStdout` given something other than a boolean — each is read as `=== true` where it is read, so `gzip: 1` wrote plain CSVs under plain names and `annotationsOnly: 'true'` wrote every signal the caller asked to leave out — or an `onProgress` that is not a function, the one option here that is called rather than read: until 0.8.36 a value that is not one passed every check above, claimed the destination, wrote rows into it and then failed from inside the writing loop as a `ConversionError` reporting a callback that threw, over a call that supplied none. The bag they arrive in is checked since 0.9.3, in `convert` and `buildPlan` both: each declares it with a default of `{}`, which covers `undefined` and nothing else, so `convert('rec.edf', 'out')` — the second parameter is an option bag and a string looks like a destination — read its properties off the string, got `undefined` for every one, and converted to `rec_csv` beside the input reporting success, while `convert('rec.edf', null)` was a `TypeError` naming a property of this package's own parameter. The first argument is held to the same standard: an `inputPath` that is not a string — an option bag passed by mistake, an array of paths — is refused here rather than handed to `fs`, which answered `Cannot read "[object Object]"` as though the recording were the problem. `EdfFile.open` and `defaultOutputDir` make the same check on their own argument, so reading a header without converting, or asking where a conversion would write, gives the same answer to the same mistake. So do `readRecords`'s `startRecord`, `endRecord` and `chunkBytes`, which until 0.8.28 came back as `EdfError`s coded `BAD_HEADER_FIELD` and `UNREADABLE` — codes about the recording, over a value the caller passed. The bag those three arrive in is checked since 0.9.2: the default `= {}` covers `undefined` and nothing else, so `readRecords(42)` and `readRecords('x')` had their properties read off them, came back `undefined`, and read every record as though no options were given, while `readRecords(null)` was a `TypeError` naming a property of this package's own parameter. `buildPlan` makes it on the recording it is told about as well as on the options: until 0.8.64 a missing `recordCount` or `recordDuration` produced a plan with `estimate.rows: 0` and no error, and a negative one came back as `TimeRangeError: --start 0s is at or past the end of this -5s recording` — a flag the caller never passed. Since 0.9.0 it asks the channels for the two fields it reads and `assertSignals` does not — that checker asks for `index`, `label` and `isAnnotations`, which is what its other two callers read, while this one groups by `samplingRate` and counts rows from `samplesPerRecord`: a list carrying only the three came back `hz must be a sampling rate in hertz, got undefined`, naming a parameter three calls down at a caller who passed `signals`, and `samplesPerRecord: 2.5` came back as `estimate.rows: 394.5`, half a row. Zero samples and a rate of zero or `Infinity` are still taken, since a header can state all three. `resolveRange` makes the same check on the two of them since 0.8.75, on `recordStarts` since 0.8.89 — a string is iterable, so `recordStarts: 'x'` spread to its characters and came back as a range covering no records that called itself the whole recording, while a number came back as `TypeError: recordStarts is not iterable`, and on what the list holds since 0.9.1, which is where that failure actually lands: `recordStarts: ['a', 'b', 'c']` came back with `endRecord: 0` and `isWholeRecording: true` over the same three records, because every comparison against a string is false and the span falls back to the contiguous one. `NaN` took the identical route. and on its length since 0.9.15, which is what the field is documented to be — "true start time of each data record": `recordStarts: [0]` on a three-record recording came back with `endRecord: 1` and `isWholeRecording: true`, one record of the three called the whole recording. An empty list is the exception, since that is how "no record times are known" arrives. `null` is still taken for a record whose position is not known — and on the bag they arrive in: it is the function `buildPlan` calls, exported with its own signature block, and called directly it answered `resolveRange({ start: 1, recordDuration: 1 })` with a window over no records — while `resolveRange(42)` resolved too, since reading `.start` off a number is `undefined` rather than a throw. `describeFormat`, `formatRate`, `formatRates`, `rateSlug` and `formatWallClock` make it on theirs, and did not until 0.8.63: `describeFormat(file)` — the `EdfFile` rather than its `header`, one property away — answered `"EDF"` for a discontinuous BDF+ recording, and until 0.8.87 that check asked about two of the three fields it reads: an object carrying `isBdf` and `isEdfPlus` and no `continuity` answered `"EDF+ (continuous)"`, since the parenthetical treats anything but `EDF+D` as continuous, and `rateSlug(NaN)` answered `"NaNhz"`, a file name this tool cannot write — as did `rateSlug(-1)`, which answered `"-1hz"` until 0.8.78; zero and `Infinity` are still accepted, since a header can really state both. `formatWallClock` takes a `Date` or `null` and refused everything else — except the falsy ones, which a `if (!date) return null` above the check sent straight back as `null`: `formatWallClock(0)`, the epoch and the obvious thing to hold beside a `Date`, answered with this tool's word for a recording that has no start instant. Fixed in 0.8.86. `makeScaler` makes it on the signal it is handed: until 0.8.61 `makeScaler({})` returned a working function giving `NaN` for every sample, since `digitalMax === digitalMin` is true of an object with neither — the same answer a channel whose header contradicts itself gets, with no way to tell the two apart. `quantizationStep` and `decimalsForSignal` read the same four fields and share that check since 0.8.76: `quantizationStep({})` answered `0`, the step of a channel whose header contradicts itself, and `decimalsForSignal(42)` answered `3`, the precision an ordinary EEG channel gets. `decimalsForSignal`'s second argument — the ceiling, defaulting to 100 because `toFixed(101)` throws — is checked since 0.8.90: `Math.min` carried it straight out, so `decimalsForSignal(signal, -5)` answered `-5` and `decimalsForSignal(signal, 'x')` answered `NaN`, each a `RangeError` out of `toFixed` one call later. It is not bounded above — handing it a ceiling nothing can reach is how you ask what a channel would need without one — and it now applies to the three-place fallback as well, which ignored it. `selectChannels` and `buildColumnNames` make it on the channel list as well as on the terms — the argument in front came back as `TypeError: signals.filter is not a function` until 0.8.60, and `buildColumnNames('ECG')` returned `Map { null => 'undefined_chundefined' }` with no error at all, because a string is iterable. Since 0.8.88 it asks for the fields its callers read rather than only `index`: `selectChannels([{ index: 0 }], ['ECG'])` reached `TypeError: Cannot read properties of undefined (reading 'toLowerCase')` — the same failure one level down — and `buildColumnNames([{ index: 0 }])` returned `Map { 0 => null }`, where a channel with no label at all is named `signal_0`, so `null` is not a column name this tool writes. `decodeRecordAnnotations` makes it on the bytes it is handed, which used to come back as `TypeError: bytes.subarray is not a function` — the name of one of its own locals — and asks for a view of *bytes* since 0.8.85: a typed array of wider elements has both the index accessor and the `subarray` the decoder uses, so `decodeRecordAnnotations(new Float64Array(2), 0)` did not fail, it read sixteen bytes as two doubles and reported a record with nothing malformed in it. It makes it on `recordIndex` as well since 0.8.77 — the argument it does not read but writes, onto every `Annotation` it returns: `decodeRecordAnnotations(bytes, 'x')` produced events whose `recordIndex` was the string, and omitting it entirely, which is easy beside `annotationBytes(batch, recordOffset, signal)`, produced events with the field missing. Since 0.9.14 it has to be a *safe* whole number: `Number.isInteger` is true of `1e300` and of 2^53 + 2, and past 2^53 a double cannot tell one whole number from the next, so neither names a record even in principle — and the value went onto every event and into `record_index`, where a join reads it, as `1e+300`. And `parseHeader` makes it on both of its arguments: every count it reports about the data is derived from the byte count, so one that is not a number produced a header claiming `NaN` records and a warning that said so, with no error — and since 0.9.4 it has to be a whole number of them, which a file always has and `fs.stat` always reports: `parseHeader(bytes, 848.5)` came back with "0.5 bytes after the last complete data record were ignored" and `900.25` with "12.25 bytes", under `TRAILING_BYTES`, the code a script matches to decide a recording was truncated — and until 0.8.74 the bytes themselves were unchecked, so a number or a plain object came back as `TypeError: bytes.subarray is not a function` and a *string* of header text, which has a `length`, came back as an `EdfError` coded `FILE_TOO_SMALL`: the code a script matches to quarantine a truncated recording, raised over a recording nobody had read. That check asked `ArrayBuffer.isView`, which is true of every typed array and of `DataView`; since 0.8.84 it asks for a view of *bytes*, one byte an element. A `DataView` — what a caller reading the header by hand with `getUint8` holds — reached the same `bytes.subarray is not a function`, and a `Float64Array` of 800 bytes reported itself as 100. `sampleAt`, `offsetOf` and `annotationBytes` ask it of `batch.data` since 0.8.97, for the same reason and with the same test: the record count was the whole of what they checked, and all three go on to read the bytes. The `Scaler` that `makeScaler` returns raises one too since 0.8.99, for a sample that is not a number: it is added to the offset, and `+` on a string concatenates. | | `ChannelSelectionError` | A `channels` term matched nothing, or `#N` named a position the file doesn't have. | | `TimeRangeError` | The requested window is empty, inverted, past the end, or over-specified. | `CALLBACK_FAILED` means your `onProgress` threw. It carries the original error as `cause`, so the stack that matters survives, and the conversion stops — carrying on writing into a directory whose owner has just failed is not an improvement. Until 0.4.38 this came back as `WRITE_FAILED` reading `Writing to "out" failed: `, advising you to check a destination that was working perfectly. `OptionError` is raised before the output directory is created, so a rejected option leaves nothing on disk. The command line has always rejected these values; until 0.4.33 the library did not, and `decimals: NaN` quietly wrote whole numbers into a column you had asked for decimals in. ```js import { convert, EdfError, ConversionError, OptionError, ChannelSelectionError, TimeRangeError } from 'edf2csv'; try { await convert('/data/recordings/sleep-study.edf', { outputDir: '/data/exports/run-1' }); } catch (error) { if (error instanceof ConversionError && error.code === 'OUTPUT_EXISTS') { console.error('already converted; pass force: true to replace it'); } else if (error instanceof EdfError) { console.error(`${error.code}: ${error.message}`); if (error.hint) console.error(error.hint); } else if (error instanceof ChannelSelectionError || error instanceof TimeRangeError) { console.error(error.message); } else { throw error; } } ``` `ConversionError.code` is one of `OUTPUT_EXISTS`, `OUTPUT_UNWRITABLE`, `INPUT_OUTPUT_COLLISION` (an output file would resolve to the recording being read), `INPUT_UNREADABLE` (the reader failed after writing had begun), `UNSUPPORTED_REQUEST` (the flags cannot be carried out together), `CALLBACK_FAILED` (your `onProgress` threw) or `WRITE_FAILED`. `ConversionErrorCode` is the type of that field, so a handler can be written down rather than only written: `function explain(code: ConversionErrorCode)`, a `Record` of messages, or a `switch` the compiler checks for exhaustiveness. It was reachable but unnameable until 0.7.6, when `EdfErrorCode` and `DiagnosticCode` had both been exported since they existed. `ChannelSelectionError` messages carry a suggestion when the term is close to a real label, and `EdfError` and `ConversionError` both carry a `hint` describing what to do. Both are worth surfacing to a user rather than swallowing. `WRITE_FAILED` means files were partially written. They are left on disk, incomplete, and shouldn't be used. ## Planning without converting `buildPlan` answers "what would a conversion produce" with no I/O beyond the header you already read. It's what powers the `--info` estimate. ```ts function buildPlan(input: PlanInput, options?: PlanOptions): ConversionPlan; interface PlanInput { signals: readonly EdfSignal[]; recordDuration: number; recordCount: number; hasAnnotationChannel: boolean; recordStarts?: Float64Array | null; // true record start times, for EDF+D files } interface ConversionPlan { groups: RateGroup[]; layout: 'wide' | 'long'; // a column per channel, or time_s/channel/value gzip: boolean; // whether the CSVs will be written compressed range: ResolvedRange; columnNames: Map; // signal index to CSV column name writeSignals: boolean; diagnostics: Diagnostic[]; estimate: { rows: number; bytes: number; exceedsSpreadsheetLimit: boolean }; } interface RateGroup { rate: number; // Hz, shared by every channel in the group samplesPerRecord: number; fileName: string; // 'signals.csv', or 'signals_256hz.csv' when rates differ timeDecimals: number; // decimals used for the time_s column channels: PlannedChannel[]; rows: number; // rows this group's table gets under the window asked for } interface PlannedChannel { signal: EdfSignal; column: string; decimals: number; } ``` `PlanOptions` is everything `ConvertOptions` needs before a byte is written — the selection (`channels`, `start`, `duration`, `end`, `annotationsOnly`, `decimals`) plus the shape and encoding (`layout`, `gzip`, `bom`), since those decide the file names, the row count and the estimate. ```js import { EdfFile, buildPlan, SPREADSHEET_ROW_LIMIT } from 'edf2csv'; const file = await EdfFile.open('/data/recordings/sleep-study.edf'); try { const plan = buildPlan( { signals: file.header.signals, recordDuration: file.header.recordDuration, recordCount: file.recordCount, hasAnnotationChannel: file.annotationSignals.length > 0, }, { start: 0, duration: 10 }, ); for (const group of plan.groups) { console.log(`${group.fileName} ${group.rate} Hz ${group.channels.length} channels`); } console.log(plan.estimate); if (plan.estimate.exceedsSpreadsheetLimit) { console.log(`over ${SPREADSHEET_ROW_LIMIT} rows: not openable in Excel or Numbers`); } } finally { await file.close(); } ``` ```text signals_256hz.csv 256 Hz 1 channels signals_128hz.csv 128 Hz 1 channels signals_1hz.csv 1 Hz 1 channels { rows: 1155, bytes: 22749, exceedsSpreadsheetLimit: false } ``` `estimate.rows` is the total data rows across every signal file. `estimate.bytes` is an approximation of their combined size as CSV text, good enough to warn on and not meant to be exact — and under `gzip` not their size on disk at all, since it counts what the compressor is given rather than what it writes. `exceedsSpreadsheetLimit` is true when any single file would pass 1,048,576 rows including the header. One caveat when planning a window on a discontinuous file: pass `recordStarts`. Without it the planner assumes records sit end to end, and a recording with a 95 second gap in the middle would have its window clipped to the amount of data rather than the span of time it covers. `convert` derives this array itself from the timekeeping annotations. Doing it by hand means matching that derivation, and a record whose timekeeping TAL is unreadable is where the two can part company. `convert` places it at `origin + index * recordDuration`, where `origin` comes from the first record that does state one — not at `index * recordDuration`, which silently assumes the recording begins at zero: ```js import { EdfFile, buildPlan } from 'edf2csv'; const file = await EdfFile.open('/data/recordings/sleep-study.edf'); try { const { recordStarts } = await file.readAnnotations(); const origin = (await file.readOrigin()) ?? 0; const starts = Float64Array.from(recordStarts, (declared, index) => declared ?? origin + index * file.header.recordDuration, ); const plan = buildPlan( { signals: file.header.signals, recordDuration: file.header.recordDuration, recordCount: file.recordCount, hasAnnotationChannel: file.annotationSignals.length > 0, recordStarts: starts, }, { start: 0.5, duration: 1 }, ); console.log(plan.estimate, plan.range.recordingStartSeconds); } finally { await file.close(); } ``` On `lost-timekeeping-d.edf`, whose first record's TAL is unreadable while the rest say 1.5 and 2.5, filling from zero puts record 0 at 0 rather than 0.5. Planning `{ start: 0.5, duration: 1 }` against that estimates 2 rows; the conversion writes 4. `ResolvedRange` describes the window that was chosen: ```ts interface ResolvedRange { startSeconds: number; // inclusive endSeconds: number; // exclusive, clamped to the end of the recording startRecord: number; // first data record touching the window endRecord: number; // one past the last data record touching the window isWholeRecording: boolean; recordingStartSeconds: number; // earliest record start, including EDF+D timing recordingEndSeconds: number; // end of the latest record, including EDF+D timing } ``` ## Smaller exports Column naming and channel selection, if you want the CLI's matching rules without the conversion: ```ts function buildColumnNames(signals: readonly EdfSignal[]): Map; function selectChannels( signals: readonly EdfSignal[], terms: readonly string[], ): { signals: EdfSignal[]; ambiguous: { term: string; matched: EdfSignal[] }[] }; ``` Column names come from the whole file, not from the current selection, so a channel always gets the same column regardless of what else was requested. An empty label becomes `signal_`, and a label shared by two channels gets a `_ch` suffix on both. `selectChannels` matches case-insensitively on the exact label, accepts `#N` for a position, throws `ChannelSelectionError` on a term that matches nothing, and reports terms that matched several channels in `ambiguous` rather than silently returning extras. Time parsing, if you want to accept the CLI's time forms: ```ts function parseTimeSpec(input: string, optionName?: string, allowNegative?: boolean): number; // seconds function resolveRange(options: { start?: number; duration?: number; end?: number; recordDuration: number; recordCount: number; recordStarts?: Float64Array | null; }): ResolvedRange; ``` `optionName` is only used in the error message, so pass whatever your own interface calls the option. `allowNegative` defaults to false; pass true for a value that names a position rather than a length, since a recording timed from its first record's timekeeping annotation can begin before zero. The CLI passes it for `--start` and `--end` and withholds it for `--duration`. It is read as `=== true` since 0.9.13, the way every flag in this package is read: a truthiness test takes a value that is not a boolean as the opposite of what it says, so `parseTimeSpec('-5s', '--duration', 'false')` returned `-5` — a negative length of time, which is the one thing the argument exists to decide. `'no'`, `{}` and `[]` went the same way; that is the shape the value arrives in from a config file or a query string, which is the door this function is documented to be reached through. ```js import { parseTimeSpec } from 'edf2csv'; parseTimeSpec('1h30m', '--start'); // 5400 parseTimeSpec('00:30:00', '--start'); // 1800 parseTimeSpec('250ms', '--start'); // 0.25 parseTimeSpec('90', '--start'); // 90, a bare number is seconds parseTimeSpec('-1h30m', '--start', true); // -5400, the sign applies to the whole value ``` `optionName` is what every refusal opens with — the CLI passes the flag, so its messages read `--start "1,5" is not a time I understand`. It is optional: leave it out and the refusals say `The value`, which is what they said `undefined` for until 0.8.19. A value that is not text is refused rather than coerced — `parseTimeSpec(30, '--start')` throws `TimeRangeError: --start must be given as text, not 30`. That is the shape a JSON config or a form field arrives in, and the reason it is not read as 30 is the one `channels: 'ECG'` gives: an accepting `Number(input)` also accepts `NaN`. Everything this function refuses is a `TimeRangeError` naming the option and the value. Header parsing, for bytes you already have in memory: ```ts function parseHeader(buf: Uint8Array, fileSize: number): EdfHeaderInfo; interface EdfHeaderInfo { header: EdfHeader; recordCount: number; // implied by the real file size trailingBytes: number; diagnostics: Diagnostic[]; } ``` `buf` must hold at least `256 + signalCount * 256` bytes. `fileSize` is the size of the whole file, which is what lets the parser derive the real record count and compare it against the header's claim. Constants and small utilities: ```ts const DEFAULT_CHUNK_BYTES: number; // 8 * 1024 * 1024 const SPREADSHEET_ROW_LIMIT: number; // 1_048_576 const TOOL_VERSION: string; // the version written into metadata.json const ANNOTATIONS_LABEL: string; // 'EDF Annotations' const BDF_ANNOTATIONS_LABEL: string; // 'BDF Annotations' function formatRate(hz: number): string; // 256, 0.5, 12.5 function formatRates(rates: readonly number[]): string[]; // distinct rates render distinctly function describeFormat(header: EdfHeader): string; // 'EDF+ (discontinuous)' function rateSlug(rate: number): string; // '256hz', '12_5hz' — one rate, alone function defaultOutputDir(inputPath: string): string; function formatWallClock(date: Date | null): string | null; // '2002-03-02T23:10:00' ``` `formatWallClock` is the one to reach for when writing `startDateTime` out, and the reason is worth stating. EDF records a date and a time and no timezone at all. `startDateTime` carries those digits as a UTC `Date` so they round-trip unshifted, which makes it a container for the wall clock rather than an instant — and serialising it with `toISOString()` appends a `Z`, asserting UTC. A reader converting that to local time then moves the recording by their own offset: 13:43:04 in the file becomes 08:43:04 in New York. `formatWallClock` drops the `Z`, because the file genuinely does not say which zone it meant. It is what produces `start_datetime_local` in `metadata.json` and the `Recorded` line in `--info`. ## Types exported for annotation Every one of these is available through `import type { ... } from 'edf2csv'`: `EdfHeader`, `EdfHeaderInfo`, `EdfSignal`, `RecordBatch`, `ReadRecordsOptions`, `Annotation`, `DecodedRecordAnnotations`, `Scaler`, `Diagnostic`, `DiagnosticCode`, `EdfErrorCode`, `ConversionErrorCode`, `ConvertOptions`, `ConvertResult`, `ConversionProgress`, `WrittenFile`, `ConversionPlan`, `PlanOptions`, `PlanInput`, `RateGroup`, `PlannedChannel`, `OutputEstimate`, `ChannelSelection`, `ResolvedRange`. The classes `EdfFile`, `EdfError`, `ConversionError`, `ChannelSelectionError` and `TimeRangeError` are values, so they import normally and work with `instanceof`. --- # The EDF format > How EDF, EDF+ and BDF store a recording on disk: the header fields, the record layout, the calibration, and the quirks real files have ## Why this page exists You don't need to know any of this to run `edf2csv`. You need it when something looks wrong: a channel that reads a thousand times too large, a recording dated 2085, two columns with the same name, a warning you want to understand rather than dismiss. Every diagnostic the tool prints comes from a specific field in a specific place, and knowing which field it was usually tells you what happened. EDF (European Data Format) was published in 1992 and is deliberately simple. A file is ASCII text for the metadata followed by raw binary integers for the samples. There's no compression, no index, and no length prefix on anything. Every position in the file can be computed with arithmetic, which is why a converter can stream a 40 MB recording without loading it. EDF+ (2003) added annotations, a way to mark a recording as discontinuous, and conventions for the identification fields. It didn't change the layout. BDF is BioSemi's 24-bit variant and changes exactly two things. All three are handled by the same parser. ## The 256 byte fixed header The first 256 bytes of every EDF, EDF+ and BDF file have this layout. All fields are ASCII, left-justified and padded with spaces to their full width. | Offset | Bytes | Field | What it means | | --- | --- | --- | --- | | 0 | 8 | version | `0` for EDF and EDF+. Byte 255 followed by `BIOSEMI` for BDF | | 8 | 80 | patient identification | Who was recorded. EDF+ standardises this into subfields | | 88 | 80 | recording identification | Which recording this is. EDF+ starts it with `Startdate` | | 168 | 8 | start date | `dd.mm.yy`, two-digit year | | 176 | 8 | start time | `hh.mm.ss` | | 184 | 8 | header bytes | Size of the whole header, which is `256 * (1 + ns)` | | 192 | 44 | reserved | Where `EDF+C` and `EDF+D` live | | 236 | 8 | number of data records | Or `-1` when the writer didn't know | | 244 | 8 | duration of a data record | Seconds, may be fractional | | 252 | 4 | number of signals (ns) | Including any annotations channel | That's the entire fixed header. Everything after byte 256 is per-signal header, and everything after that's data. ### Version For EDF and EDF+ this field is the single character `0` padded with seven spaces. It carries no information: there has only ever been one version. BDF uses it as a magic number instead. Byte 0 is `0xFF` (255, not a printable character) and bytes 1 to 7 spell `BIOSEMI`. That's the only reliable way to tell a BDF file from an EDF file, and it matters, because the two differ in how many bytes a sample takes. A parser that skips this check will read a BDF file as EDF and produce complete nonsense rather than an error. ### Patient and recording identification Two free-text fields of 80 bytes each. In plain EDF they're whatever the recording software felt like writing. EDF+ specifies a structure for both: the patient field becomes a hospital code, sex, birth date and name separated by spaces, and the recording field starts with the literal word `Startdate` followed by the start date in `dd-MMM-yyyy` form. These fields routinely contain direct identifiers. If you're sharing converted data, look at what your files actually have in them. `--info` prints both fields, and they're copied verbatim into `metadata.json` as `patient_id` and `recording_id`. ```bash edf2csv telemetry-psg.edf --info ``` ``` File telemetry-psg.edf Format EDF+ (continuous) Recorded 2002-03-02 22:15:00 Duration 3s (3 records of 1s) Size 1.5 KB Patient MCH-0234567 F 02-MAY-1951 Haagse_Harry Recording Startdate 02-MAR-2002 PSG-1234/2002 NN Telemetry03 ``` Those two lines are the EDF+ specification's own example header, which is why they read as a real patient rather than as placeholders. `sleep-study.edf` — the recording the rest of this site converts — has `X X X X` in both fields, so it cannot show you what this section is about. ### Start date and time, and the two-digit year The date field holds `dd.mm.yy`. Two digits for the year, which needed a rule the moment the format outlived the 1990s, and EDF has one: **85 to 99 mean 1985 to 1999, and 00 to 84 mean 2000 to 2084**. The format can't express a date outside 1985 to 2084 at all: on the date field alone, a file written in 2085 reads as 1985 and one written in 1984 reads as 2084. EDF+ writes the year again in full. Its recording identification field begins `Startdate dd-MMM-yyyy`, which the specification requires to agree with the date field, and four digits say what two cannot — so where that field is present and agrees about the day, the month and the last two digits of the year, `edf2csv` takes the century from it. A 1984 sleep study digitised into EDF+ reads as 1984 rather than 2084. A `Startdate` that contradicts the date field settles nothing, and the rule above is used instead. So `01.01.85` is 1 January 1985, and `02.03.02` is 2 March 2002. Note also that the date is day-first: `05.06.09` is 5 June 2009, not 6 May. ``` 05.06.09 -> 2009-06-05 02.03.02 -> 2002-03-02 01.01.85 -> 1985-01-01 ``` There's no time zone. The instant is whatever local clock the recording hardware had, so `edf2csv` reports it without one — as a bare wall clock in `--info` and as `start_datetime_local` in `metadata.json`. Labelling it UTC would shift it by the reader's own offset. The raw text of both fields survives as `start_date_raw` and `start_time_raw` so nothing is lost to interpretation. A few practical details. The spec writes the separator as a dot, but real files use `-` and `/` in dates and `:` and `-` in times, and all of those are accepted. A seconds value of 60 (a leap second) is accepted and read as 59. An impossible date such as `31.02.02` is rejected outright rather than silently rolled forward into March, and then `--info` prints the raw fields marked `(unparseable)` — in quotation marks, since either field may be empty or nothing but padding, and `Recorded 22.15.00 (unparseable)` gives no way to see which of the two is the blank one. ### The reserved field, where EDF+C and EDF+D live 44 bytes at offset 192. In plain EDF they're blank. EDF+ puts one of two markers here, and this single field is what separates a plain EDF file from an EDF+ file: - `EDF+C` means **continuous**. Data records follow each other without gaps, so record `n` starts at `n * recordDuration` seconds. - `EDF+D` means **discontinuous**. Records aren't contiguous in time. Where each record actually sits must be read from the annotations channel. BDF+ writes `BDF+C` and `BDF+D` for the same two meanings. `edf2csv` treats the pairs as equivalent. BioSemi also writes `24BIT` in this field on plain BDF files, which isn't a continuity marker and is ignored. A file with `EDF+D` gets a warning, because a converter that ignores this field will hand you a time axis that's quietly wrong. ``` warning: This recording is marked discontinuous (EDF+D): its data records need not be contiguous in time. Each row carries its true recording time, so gaps stay visible instead of being closed. ``` ### Record count, record duration, and signal count These three numbers determine the shape of everything that follows. **Number of data records** at offset 236 is how many records the writer intended to store. It may be `-1`, which the spec permits for a recording still in progress. It's also often wrong, because a recording that was interrupted leaves this field at the value the writer set when it started. `edf2csv` never trusts it. The record count it uses is derived from the actual file size: ``` dataBytes = fileSize - 256 * (1 + ns) recordBytes = sum(samplesPerRecord) * bytesPerSample recordCount = floor(dataBytes / recordBytes) ``` The declared value is still reported (as `data_records_declared` in `metadata.json`) and any disagreement produces a warning. **Duration of a data record** at offset 244 is in seconds and may be fractional. One second is by far the most common, but 0.1 s and 10 s both occur. This is the only field that turns sample counts into a sampling rate. **Number of signals** at offset 252 is four bytes, so at most 9999 channels. It counts the annotations channel if there's one, which is why a file reported as "1 signal + 1 annotation channel" has `ns` of 2. ## The per-signal header is field-major This is the part people get wrong. After the fixed 256 bytes comes `ns * 256` more bytes of per-signal header, and it's **not** stored as one 256-byte block per signal. It's stored field-major: all `ns` labels, then all `ns` transducer strings, then all `ns` physical dimensions, and so on to the end. ``` offset 256 ns * 16 bytes label +ns*16 ns * 80 bytes transducer type +ns*96 ns * 8 bytes physical dimension (the unit) +ns*104 ns * 8 bytes physical minimum +ns*112 ns * 8 bytes physical maximum +ns*120 ns * 8 bytes digital minimum +ns*128 ns * 8 bytes digital maximum +ns*136 ns * 80 bytes prefiltering +ns*216 ns * 8 bytes number of samples in each data record +ns*224 ns * 32 bytes reserved ``` So the label of signal `i` is at `256 + i * 16`, and its physical minimum is at `256 + ns * 104 + i * 8`. There's no run of bytes anywhere in the file that contains one signal's complete header. This matters because signal-major is the intuitive guess, and with one signal the two layouts are byte-for-byte identical. A hand-written parser tested on a single-channel file passes, then reads a two-channel file and gets a label where it expected a transducer string. Here are the first 64 bytes after the fixed header in a two-channel file: ``` "ch1 ch2 " |<-- 16 bytes -->|<-- 16 bytes -->|<--- transducer of ch1 ... ---> ``` Two 16-byte labels back to back, and then the transducer field starts. Signal-major parsing would have looked for the transducer of `ch1` at byte 16, and found `ch2`. ### What the per-signal fields mean - **label**, 16 bytes. Free text. EDF+ recommends ` ` such as `EEG Fpz-Cz`, and reserves the exact label `EDF Annotations` (`BDF Annotations` in BDF+) for the annotations channel. Nothing enforces uniqueness. - **transducer type**, 80 bytes. What the electrode was, e.g. `AgAgCl electrode`. Usually blank. - **physical dimension**, 8 bytes. The unit. `uV`, `mV`, `degC`, `bpm`. Eight bytes isn't enough room for anything careful, so this is where you find `uV` meaning microvolt in one file and `µV` in the next. - **physical minimum / maximum**, 8 bytes each. The two ends of the calibration, explained below. - **digital minimum / maximum**, 8 bytes each. The two ends of the ADC range, as integers. - **prefiltering**, 80 bytes. What analogue filtering was applied, e.g. `HP:0.1Hz LP:75Hz N:50Hz`. Free text, so it's documentation, not something a program can act on. - **number of samples in each data record**, 8 bytes. Divided by the record duration, this is the channel's sampling rate. All of these are carried through into `channels.csv`, one row per channel, so you never have to open the binary to see them. ## Data records and 2-byte little-endian samples Data starts immediately after the header, at byte `256 * (1 + ns)`, and runs to the end of the file as a plain sequence of data records with nothing between them. Within one record, each signal contributes its `samplesPerRecord` samples, in header order, back to back. A record is therefore always the same size: ``` recordBytes = sum over all signals of (samplesPerRecord * bytesPerSample) ``` Take a file with two channels at 10 samples per 1-second record. Each record is `(10 + 10) * 2 = 40` bytes, and the file looks like this: ``` byte 0 fixed header, 256 bytes byte 256 signal header, 2 * 256 = 512 bytes byte 768 record 0: ch1 sample 0..9 (20 bytes) ch2 sample 0..9 (20 bytes) byte 808 record 1: ch1 sample 10..19 (20 bytes) ch2 sample 10..19 (20 bytes) ``` Samples are **2-byte signed little-endian two's complement integers**, so the range is -32768 to 32767. Little-endian means the low byte comes first: the first ten samples of `ch1` above, counting 0 to 9, are stored as ``` 0000 0100 0200 0300 0400 0500 0600 0700 0800 0900 ``` which is `0, 1, 2, 3, ...` and not `0, 256, 512`. Because every offset is computable, reading one channel out of forty doesn't require reading the other thirty-nine. The sample at record `r`, signal `s`, index `k` sits at ``` 256 * (1 + ns) + r * recordBytes + byteOffsetOf(s) + k * bytesPerSample ``` This is also why sampling rate is a property of the record structure rather than a stored number. A channel with 256 samples in a 1-second record is 256 Hz; a channel with 25 samples in a 0.1-second record is 250 Hz. A rate that can't be written as an integer sample count over the file's single record duration can't be expressed in EDF at all. ## Digital to physical calibration The integers in the data records are raw ADC codes. They mean nothing until they're mapped onto physical units, and the four calibration fields in the signal header are that map. The mapping is a straight line through two points: `digitalMin` maps to `physicalMin`, and `digitalMax` maps to `physicalMax`. That's the whole model. There's no offset field, no per-record gain, nothing else. ``` gain = (physicalMax - physicalMin) / (digitalMax - digitalMin) offset = physicalMax / gain - digitalMax physical = gain * (offset + digital) ``` The second and third lines are EDFlib's arrangement of the same line, and `edf2csv` uses it because it's more accurate than the spec's literal `(digital - digitalMin) * gain + physicalMin`. Written that way, a channel spanning plus or minus 800 uV computes a value near 800 and then subtracts 800, and the cancellation throws away low-order bits: digital 0 yields `0.19536019536019467` when the exact answer is `0.19536019536019536`. Keeping the intermediate small returns the correctly rounded result, and makes the output bit-for-bit identical to pyEDFlib and EDFbrowser, which share the same arithmetic. Worked through on a real channel with `physicalMin -800`, `physicalMax 800`, `digitalMin -2048`, `digitalMax 2047`: ``` gain = 1600 / 4095 = 0.3907203907203907 offset = 800 / gain - 2047 = 2047.5 - 2047 = 0.5 digital 0 -> 0.3907203907203907 * 0.5 = 0.19536019536019536 digital 1 -> 0.3907203907203907 * 1.5 = 0.5860805860805861 ``` ### What physical minimum and maximum actually mean They are the ends of the calibration, **not** the extremes of the recorded data. `physicalMin -800` doesn't promise that the file contains a sample at -800 uV, and it doesn't promise that no sample goes below it either. It states only which physical value corresponds to `digitalMin`. Two things follow. First, `|physicalMax - physicalMin| / |digitalMax - digitalMin|` is the smallest physical step the channel can express, and `edf2csv` uses exactly that to choose how many decimal places to write, so two adjacent ADC codes never collapse to the same text. Both differences are magnitudes, because either pair may be written the wrong way round and a step is a size: an inverted channel needs the same precision as the upright one it inverts. Second, a digital value outside the declared digital range converts to a physical value outside the declared physical range. `edf2csv` applies the line and doesn't clamp, because clamping would fabricate data that isn't in the file. The pair can also be degenerate or inverted, and both happen in the wild: - `digitalMin == digitalMax` makes the line undefined, so there's nothing to compute. `edf2csv` leaves those cells empty and warns (`DEGENERATE_DIGITAL_RANGE`), rather than filling the column with a stand-in number that would read as ordinary data. - `physicalMin == physicalMax` makes every sample the same value. Warned as `DEGENERATE_PHYSICAL_RANGE`. - A negative gain inverts the polarity of the channel. That is what `physicalMin > physicalMax` usually means — but only usually: reversing `digitalMin` and `digitalMax` instead does the same thing, and reversing both pairs cancels out and leaves an ordinary channel. It is the sign of the fraction that decides. A negative gain is a legal line, it's just probably a mistake by the recording software. `edf2csv` converts exactly what the header says, inversion included, and warns (`INVERTED_PHYSICAL_RANGE`) so you can decide whether to trust it. ## BDF, the 24-bit variant BioSemi's BDF is EDF with a wider ADC. Everything on this page applies, with two changes. 1. The version field is byte 255 followed by `BIOSEMI` instead of `0`. 2. Samples are **3 bytes**, not 2, still signed little-endian two's complement. The digital range widens from -32768..32767 to -8388608..8388607. Nothing else moves. The fixed header is still 256 bytes, the signal header is still field-major and still `ns * 256` bytes, and the record layout is identical. Only `bytesPerSample` changes, which changes every byte offset into the data. Decoding a 24-bit sample needs sign extension, which most languages won't do for you. The trick is to load the three bytes into the top of a 32-bit word and shift back down: ``` value = ((b0 << 8) | (b1 << 16) | (b2 << 24)) >> 8 ``` For example the bytes `c0 bd f0` decode to -1000000, and on a channel calibrated -262144..262144 uV over the full 24-bit range that converts to -31249.9862 uV. BDF+ exists too and works exactly like EDF+, except that its markers are spelled `BDF+C` and `BDF+D` and its annotations channel is labelled `BDF Annotations`. ## EDF+ annotations and the TAL structure EDF+ needed somewhere to put text events without changing the file layout, so it put them in a signal. A channel labelled `EDF Annotations` occupies the same slot in every data record as any other channel, has a `samplesPerRecord` like any other channel, and reserves `samplesPerRecord * bytesPerSample` bytes per record. Those bytes aren't samples. They are UTF-8 text — or latin1, when the writer's idea of text was not UTF-8, which [the annotations page](/docs/edf-plus-annotations#the-annotations-channel) goes into. The text is a run of **Time-stamped Annotation Lists**, each terminated by a NUL byte, with the remainder of the channel NUL-padded to fill the slot. One TAL looks like this: ``` +[<0x15>]<0x14>[<0x14>...]<0x14><0x00> ``` Three control bytes do all the work: | Byte | Name | Role | | --- | --- | --- | | `0x15` | duration separator | Separates the onset from an optional duration | | `0x14` | text separator | Separates the timing from the text, and text from text | | `0x00` | TAL terminator | Ends one TAL, and pads the rest of the channel | The spec pads the remainder of the slot with `0x00`. Writers pad with spaces instead often enough that edf2csv treats a chunk of nothing but whitespace as padding rather than as an entry it failed to read — up to 0.5.93 a space-padded file holding one readable event was told two entries were lost, one per record. A chunk of anything else that does not parse is still counted and reported. Onsets and durations are seconds relative to the start of the recording, written as decimal text. The onset **must** carry an explicit sign, `+` or `-`. That isn't decoration: it's how a reader tells a TAL from padding, and `edf2csv` rejects any chunk that doesn't start with one. A negative onset is legal and means an event before the recording's nominal start. The first TAL in every data record is special. It carries that record's own start time and no text, and that's how an `EDF+D` file states where each record actually sits in time. In an `EDF+C` file it's redundant but still required. ### A byte-level example Here are the 60 bytes of the annotations channel in the first data record of a real EDF+ file: ``` 2b 30 14 14 00 2b 30 2e 35 15 31 14 53 6c 65 65 70 20 73 74 61 67 65 20 57 14 00 00 00 00 ... (NUL padding to the end of the slot) ``` Reading it left to right: ``` 2b 30 "+0" onset 0 seconds 14 0x14 text separator 14 0x14 second separator, so the text is empty 00 0x00 end of TAL -> a timekeeping TAL: this record starts at 0 s 2b 30 2e 35 "+0.5" onset 0.5 seconds 15 0x15 duration separator follows 31 "1" duration 1 second 14 0x14 text separator 53 6c 65 ... 20 57 "Sleep stage W" the annotation text 14 0x14 text separator, ends the text list 00 0x00 end of TAL 00 00 00 ... padding, not data ``` Which decodes to one record start of 0 s and one annotation: onset 0.5, duration 1, text `Sleep stage W`. A TAL with no `0x15` has no duration, and the duration is genuinely absent rather than zero. In a different record of the same file the bytes `2b 31 2e 32 35 14 4c 69 67 68 74 73 20 6f 66 66 14 00` read as `+1.25` then `Lights off` with no duration at all. A TAL may also carry several texts after one onset, by repeating `<0x14>`. All of them share that onset and duration. `edf2csv` emits one row per text. Two behaviours are worth knowing. A malformed TAL is skipped rather than thrown, and the number skipped is reported, because one bad annotation shouldn't cost you an entire conversion. And the annotations channel is always read across the **whole** file, even when you asked for a time window, because nothing in the spec obliges a writer to store an event in the record its onset falls in and some tools put every annotation in the first record. ## Quirks worth knowing about Real files break the spec in a small number of recurring ways. None of these are hypothetical; each one has a fixture in the test suite because it was found in a public dataset first. **Duplicate channel labels.** Labels are free text with no uniqueness rule, and public EEG datasets ship recordings with two channels both labelled `T8-P8`. They are different electrodes; the label is just wrong. `edf2csv` keeps both, disambiguates the columns with a `_ch` suffix pointing at the channel's position in the file, and tells you: ``` warning: 2 signals share the label "T8-P8" (positions #0, #1). Their names are suffixed with the signal number so they stay distinguishable: a column name each in the wide layout, and a distinct value in the channel column under --layout long. ``` The two columns then appear as `T8-P8_ch0` and `T8-P8_ch1`. When you want one specifically, `--channels` accepts `#0` and `#1` to address a channel by position rather than by name. **A channel labelled with a single hyphen.** Some recordings contain a channel whose entire label is `-`, usually a spare or disconnected input. It's a legal label, so it's preserved verbatim and the column is called `-`. A completely empty label is different: it gets an `EMPTY_LABEL` warning and becomes `signal_`, since a nameless column is worse than an ugly one. **A record count of -1.** The spec allows it for a recording still being written. The count is derived from the file size instead, and you're told: ``` warning: The header does not say how many data records the file has (-1), which the spec allows for recordings still in progress. Using the 4 records the file actually contains. ``` **Truncated files.** A recording cut short leaves the declared record count at its original value while the file holds fewer records. Because the count in use always comes from the file size, this converts cleanly and you get a `RECORD_COUNT_MISMATCH` warning naming both numbers. If the file ends part-way through a record, the incomplete tail is dropped and reported as `TRAILING_BYTES` rather than being read as a short record full of garbage. **Comma decimal separators.** Software built in a locale that writes `0,5` sometimes writes header numbers that way, which the spec doesn't allow. A field with a comma and no dot is read as a decimal point, and the file raises `COMMA_DECIMAL` so you can sanity-check the affected values in `channels.csv`. **NUL padding instead of space padding.** EDF says pad with spaces. Some writers pad with NUL bytes, which ordinary whitespace trimming doesn't remove, and a parser that only trims whitespace ends up unable to read the signal count of a perfectly good file. Both are trimmed here. **Anything else in a numeric field.** A sign, digits, an optional fractional part and an optional exponent are what these fields hold — the last of those because eight characters is not enough for a magnetometer's range any other way, so `1e-16` is a physical bound real headers write. Nothing else is read as a number, and that is narrower than most languages' own conversion: JavaScript's reads `0x64` as 100, `0b1100100` as 100 and `0o144` as 100, which up to 0.7.43 meant a physical maximum of `0x64` printed as `-100 to 100`, went into `channels.csv` as `physical_max,100`, and set the gain every sample on that channel was scaled by. Those bytes are a byte-shifted or damaged header, and they now raise `BAD_HEADER_FIELD` naming the field and quoting what was found. **A header-bytes field that disagrees with the signal count.** Offset 184 should equal `256 * (1 + ns)`. When it doesn't, the computed value wins (it's the one the layout actually implies) and `HEADER_BYTES_MISMATCH` is raised. --- # Questions and troubleshooting > Answers to common questions, from several signals files to patient data in metadata.json ## Why did I get several signals files instead of one? Because the channels in your recording weren't all sampled at the same rate. When every channel shares one rate you get a single `signals.csv`. When they don't, you get one file per rate, named after that rate: ``` sleep-study_csv/ signals_100hz.csv EEG Fpz-Cz, EEG Pz-Oz, EOG horizontal signals_10hz.csv Resp oro-nasal signals_1hz.csv Temp rectal ``` A single wide table can't hold two rates without inventing rows. Putting a 1 Hz temperature channel next to a 100 Hz EEG channel in one table means filling 99 out of every 100 temperature cells with values that were never measured, so edf2csv splits the table instead. Nothing is resampled, interpolated or padded. `channels.csv` has an `output_file` column telling you where each channel went, and `--info` shows the same mapping before you convert anything. If you would rather have one file, [`--layout long`](/docs/cli-reference#--layout) gives you one — by changing the shape rather than the data. Each row is a single sample, carrying its own time, so no channel has to fill in cells for times it was never sampled at: ```bash edf2csv sleep-study.edf --out ./converted --layout long ``` ``` time_s,channel,value 0.000,EEG Fpz-Cz,0.061 0.000,EEG Pz-Oz,0.061 0.000,EOG horizontal,0.061 0.000,Resp oro-nasal,0.000244 0.000,Temp rectal,37.00073 0.010,EEG Fpz-Cz,1.648 ``` All five channels at the first instant, then the 100 Hz ones again a hundredth of a second later while the 10 Hz and 1 Hz channels wait their turn. Still nothing resampled, interpolated or padded. `long.pivot(index='time_s', columns='channel', values='value')` in pandas gets you back to the wide form for whichever rates you want it for — except on a recording that samples faster than the time column can separate, or one whose data records overlap, where two rows share a time and a channel and pandas raises `ValueError: Index contains duplicate entries`. Both are shapes a conversion warns about. ## Why is my CSV so much larger than the EDF file? Because EDF stores each sample as 2 raw bytes (3 for BDF) and CSV stores it as human-readable text. A sample stored as two bytes becomes something like `-114.258`, which is eight characters plus a comma. How much larger depends on the channel count as much as on the decimals, because every row carries one `time_s` cell however many channels share it. A 23-channel 256 Hz montage comes out about 4 times the EDF; a single-channel recording of the same length is nearer 10, since the time column has nothing to share with. Channels needing more decimal places push it up further. The extra size isn't padding. The decimal places are chosen per channel from its calibration so that no two distinct digital codes round to the same text, and no further digits are written. Trimming them would cost resolution. If the size is a problem, convert a smaller part of the recording rather than reducing precision: ```bash edf2csv sleep-study.edf --channels "EEG Fpz-Cz,EOG horizontal" --start 1h --duration 20m ``` Run `--info` first to see the row count and approximate byte size before writing anything. Compressing the result afterwards works well, since CSV of this kind is very repetitive: `gzip signals.csv` typically recovers most of the size. ## Can I open the output in Excel? Sometimes. Excel and Numbers stop at 1,048,576 rows including the header. One hour of a single 256 Hz channel is 921,600 rows, so it just fits. Two hours doesn't. When any output file will exceed the limit, edf2csv warns you before writing: ```text warning: At least one output file will have more than 1,048,576 rows, which is more than Excel or Numbers can open. Use --start and --duration to convert a section, or read the file with pandas or R. ``` `channels.csv` and `annotations.csv` are small and open in a spreadsheet without trouble. For the signal files you have two options. Convert a window small enough to open: ```bash edf2csv sleep-study.edf --start 22m --duration 30s --out ./excerpt ``` Or read the full file with something that has no row limit. All the output uses plain RFC 4180 CSV with a single header row, so no dialect arguments are needed: ```python import pandas as pd signals = pd.read_csv("sleep-study_csv/signals_100hz.csv") ``` ```r signals <- readr::read_csv("sleep-study_csv/signals_100hz.csv") ``` A spreadsheet may also reformat what it displays. A time column of `0.00390625` can be shown as `0.004`, and a label such as `1-2` can be read as a date. The file on disk is unaffected, but don't rely on a spreadsheet's rendering when you're checking values. ## Why does it refuse to write into a directory that already exists? To stop a second conversion from mixing itself into the results of a first one. If the output directory exists, the conversion stops before writing anything: ```text error: "sleep-study_csv" already exists. Pass --force to write into it, leaving whatever else it holds, or --out to choose a different directory. ``` That's exit code 1, and nothing on disk has changed. Pick one: ```bash edf2csv sleep-study.edf --force # overwrite the previous output edf2csv sleep-study.edf --out ./run-2 # write somewhere else ``` The check exists because the output is a set of files that only make sense together. `metadata.json` describes the run that produced the CSVs beside it, and half-replacing that set would leave you with a metadata file describing one conversion and signal files from another. ## Why is there a leftover signals_256hz.csv next to my new signals.csv? `--force` overwrites the files a run produces, but it doesn't empty the directory first. Convert a mixed-rate recording into a directory, then convert a single-rate one into the same directory, and the rate-named files from the first run are still there, looking current. edf2csv detects this and tells you: ```text warning: signals_128hz.csv, signals_1hz.csv, signals_256hz.csv are left over from an earlier conversion into this directory and were not rewritten. Delete them, or convert into a fresh directory, so the two runs do not get mixed up. ``` Nothing is deleted for you. Either delete the stale files or convert into a fresh directory. `metadata.json` always lists the files the current run actually wrote, under `conversion.files`, so that's the authoritative list if you're unsure which is which. ## I asked for a channel and it says there is no channel with that name `--channels` matches the channel's label exactly, ignoring case only. It doesn't do substring or prefix matching, since a partial match would silently pull in channels you didn't ask for. A term that matches nothing is an error rather than a quiet omission: ```text error: No channel named "EKG". Did you mean "ECG"? Run with --info and no --channels to list the channels in this file. ``` The usual causes are a label with different spacing or punctuation than you expected (`EEG Fpz-Cz` rather than `EEG-Fpz-Cz`), or trailing spaces in the file's own header. Run `--info` and copy the label out of the `LABEL` column exactly as printed. Labels with spaces need quoting in the shell: ```bash edf2csv sleep-study.edf --info edf2csv sleep-study.edf --channels "EEG Fpz-Cz,EOG horizontal" ``` If the label is awkward, or two channels share it, address the channel by its position in the file instead. The `#` column in `--info` is that position: ```bash edf2csv sleep-study.edf --channels "#0,#3" ``` A real label always takes priority over the `#N` form, so a channel actually labelled `#3` stays reachable by name. ## Why did --channels give me two columns when I asked for one? Because two channels in the file share that label. EDF doesn't require labels to be unique, and recordings with two channels both labelled `T8-P8` are common enough to be normal rather than corrupt. Both are selected, and you're told why: ```text warning: "T8-P8" matches 2 channels (positions #0, #1); all of them were selected. Use --channels "#0" to pick just one. ``` In the output the columns are suffixed with the signal position so they stay distinct: `T8-P8_ch0` and `T8-P8_ch1`. The suffix is derived from the whole file, not from your selection, so a given channel always produces the same column name no matter which channels you asked for. ## The times in my file jump. Is that a bug? Almost certainly not. Check `metadata.json` for `"format": "EDF+ (discontinuous)"`. A discontinuous recording (EDF+D or BDF+D) has real gaps in it: the amplifier was paused, or a review tool exported only the interesting segments. Each data record then carries its own true start time, and edf2csv writes that time, so a gap appears in the `time_s` column exactly where the recording had one: ``` time_s,EEG Fpz-Cz 1.700,-12.451 1.800,-11.230 1.900,-10.107 10.000,3.418 10.100,4.639 ``` That file has no data between 1.9 s and 10.0 s because none was recorded. You're warned at conversion time: ```text warning: This recording is marked discontinuous (EDF+D): its data records need not be contiguous in time. Each row carries its true recording time, so gaps stay visible instead of being closed. ``` Other tools handle this differently. `mne.io.read_raw_edf` closes EDF+D gaps silently, which shifts every sample after the gap to a time it wasn't recorded at. pyEDFlib refuses EDF+D files outright. Keeping the gap visible means `time_s` is always the real recording time, and that the gap is yours to handle. Two other causes of odd times, both reported as warnings when they occur: records whose timekeeping annotation is missing get a fallback timestamp computed as if they were contiguous, and a file whose records are stored out of order produces a `time_s` column that doesn't increase monotonically. Both are named explicitly in the warnings and in `metadata.json` under `notes`. ## There is no annotations.csv in my output directory `annotations.csv` is written only when the recording has an EDF+ or BDF+ annotation channel. Plain EDF has nowhere to store events, so no file is written at all rather than an empty one suggesting that events were looked for and not found. Run `--info`. The channel count line names annotation channels separately, so a file with none says only how many signals it has: ```text Channels 2 signals ``` against a recording that has one: ```text Channels 5 signals + 1 annotation channel ``` When the channel exists but holds no events beyond the per-record timekeeping entries, the file is written with its header row and no data rows, and `metadata.json` records `"annotations_written": 0`. ## Does edf2csv send my data anywhere? No. It runs entirely on your machine. The code contains no network calls of any kind: no upload, no download, no update check, no crash reporting, no telemetry, no usage counter. It reads the file you point it at and writes files into the output directory. It also installs no dependencies at all, so there's no third-party package running in the same process that could do any of the above. The only thing that touches the network is `npm` or `npx` when you install the tool, which happens once and is the package manager's doing rather than the tool's. This matters because clinical and research recordings frequently can't leave the machine or the network they're on. ## Is patient information preserved in the output? Yes, and you should treat `metadata.json` accordingly. EDF headers carry two free-text identification fields, and edf2csv copies both into `metadata.json` verbatim, under `recording.patient_id` and `recording.recording_id`. In an EDF+ file the patient field is structured as a patient code, sex, birth date and name, and the recording field holds the start date plus a hospital administration code, the technician and the equipment. In practice these fields contain whatever the recording software put there, which is sometimes a study code and sometimes a person's actual name and date of birth. `metadata.json` contains: | Field | What it holds | | --- | --- | | `recording.patient_id` | The 80-character patient identification field, exactly as written in the header | | `recording.recording_id` | The 80-character recording identification field, exactly as written | | `recording.start_datetime_local` | Recording start as a zone-less wall clock, when the header's date and time parse | | `recording.start_date_raw` | The raw `dd.mm.yy` date field from the header | | `recording.start_time_raw` | The raw `hh.mm.ss` time field from the header | | `source.path` | The absolute path of the input file on the machine that ran the conversion | | `source.bytes`, `source.modified` | Size and modification time of the input | | `source.sha256` | Checksum of the input, only when `--checksum` was passed, otherwise `null` | Two of those are easy to overlook. A recording date and time is itself identifying when combined with a clinic and a date of admission, so `start_datetime_local` isn't neutral. And `source.path` is the resolved absolute path, which often embeds a subject folder name. The fields are copied rather than stripped so that the conversion stays reproducible, and because what counts as identifying depends on your context. So: - Don't attach `metadata.json` to an issue report, a public repository or a shared drive without reading it first. - If you need to publish a conversion, edit or remove the `recording.patient_id`, `recording.recording_id`, `source.path` and start-time fields before you do. - Anonymise the EDF header before conversion if you want the whole pipeline clean, since the metadata is only a faithful copy of what the header already says. Two other files are worth checking. `--info` prints `Patient` and `Recording` lines to stdout, so terminal transcripts and CI logs pick them up. And `annotations.csv` holds annotation text exactly as recorded, which is free text a technician typed and can contain names or clinical notes. `signals.csv` and `channels.csv` contain no patient identification. `channels.csv` does include the transducer and prefiltering strings, which can identify a site's equipment but not a person. ## Can I get the raw digital values instead of physical units? The CSV output is always physical units. There's no flag for raw digital codes, and `--decimals 0` rounds physical values to whole numbers rather than giving you the underlying integers. You have two routes. The first is to recover the digital code from the physical value, which is exact because the mapping is linear and the calibration constants are in `channels.csv`: ```python import pandas as pd channels = pd.read_csv("sleep-study_csv/channels.csv").set_index("column") row = channels.loc["EEG Fpz-Cz"] gain = (row.physical_max - row.physical_min) / (row.digital_max - row.digital_min) offset = row.physical_max / gain - row.digital_max # EEG Fpz-Cz is a 100 Hz channel, so it is in the 100 Hz table — see the layout above. signals = pd.read_csv("sleep-study_csv/signals_100hz.csv") digital = (signals["EEG Fpz-Cz"] / gain - offset).round().astype("int64") ``` The rounding recovers the original integer exactly, because the written decimals are always fine enough to keep adjacent digital codes distinct. `npm run roundtrip` checks that across the calibration space — 20,160 cells over 1,260 combinations of digital and physical bounds, EDF and BDF, down to a magnetometer's ±1e-16 and including the bounds written the wrong way round — and every one comes back as the code the file holds. Two things this depends on. Take the gain from `channels.csv` rather than from what you believe the recording's range to be: EDF's physical bound fields are 8 characters, so a header asked for `-0.000001` stores `-0`, and the calibration in the CSV is the one the numbers were made with. And leave `--decimals` alone. The promise is about the precision edf2csv derives per channel; force a coarser one and the codes stop being recoverable, silently — `--decimals 0` on a 256 Hz EEG channel gets 645 of 768 samples wrong. The second route is the programmatic API, which hands you the integers directly and never builds a CSV at all: ```javascript import { EdfFile } from "edf2csv"; const file = await EdfFile.open("sleep-study.edf"); const signal = file.dataSignals[0]; const digital = []; for await (const batch of file.readRecords()) { for (let record = 0; record < batch.recordCount; record++) { for (let sample = 0; sample < signal.samplesPerRecord; sample++) { digital.push(file.sampleAt(batch, record, signal, sample)); } } } await file.close(); console.log(signal.label, digital.slice(0, 8)); ``` `sampleAt` returns the raw two's complement integer, sign-extended from 24 bits for BDF. The batch buffer is reused between iterations, so copy anything you need to keep past the current loop turn. ## Why does one channel have three decimals and another five? Because the number of decimals is derived from each channel's own calibration, not fixed globally. The smallest physical step a channel can express is `|physical_max - physical_min| / |digital_max - digital_min|`, and edf2csv writes two places past that step. An EEG channel spanning plus or minus 250 uV across a 12-bit converter has a step of about 0.12 uV, so three decimals are enough for every distinct sample to have distinct text. A temperature channel spanning 34 to 40 degC over the same converter has a step near 0.0015, so it gets five. The result is that no resolution is lost and no meaningless digits are written. The per-channel choice is recorded in `metadata.json` under `conversion.rate_groups[].decimals`. If you need a fixed width across channels, for a downstream tool that insists on it, override it: ```bash edf2csv sleep-study.edf --decimals 6 ``` `--decimals` accepts a whole number from 0 to 20 and applies to every channel. Setting it below what a channel needs discards resolution, which is why it isn't the default. ## Does it support BDF and BioSemi files? Yes. BDF and BDF+ are read natively. BioSemi's format is EDF with 24-bit samples instead of 16-bit and its own version marker, and edf2csv handles both: samples are decoded as 24-bit little-endian two's complement, and `BDF Annotations` is recognised alongside `EDF Annotations` as the events channel. `BDF+C` and `BDF+D` are treated exactly as `EDF+C` and `EDF+D`. The format is reported in `--info` and in `metadata.json`: ```text Format BDF+ (discontinuous) ``` Nothing else about the workflow changes: the same flags, the same output files, the same rules about sampling rates and gaps. ## Does it do filtering, detrending or artifact removal? No. edf2csv applies exactly one transformation: the digital-to-physical scaling that the file's own header specifies. No filtering, no notch, no detrending, no re-referencing, no artifact rejection, no resampling, no unit conversion, no scaling to a common range. Preprocessing belongs in your analysis, where the choices are visible and reviewable. A converter that filtered on the way out would produce a CSV that disagrees with the EDF for reasons recorded nowhere. Two consequences. Prefiltering that the recording hardware already applied is described in the `prefiltering` column of `channels.csv`, so you can see what was done before the file existed. And a channel whose header declares its physical minimum above its physical maximum is converted with that inversion intact, since correcting it would mean overriding what the file says: ```text warning: Signal 3 ("inverted") declares physical minimum 100 above physical maximum -100, which inverts its polarity. The values are converted exactly as the header specifies, inversion included. ``` ## What happens with a truncated recording, or one that is still being written? A truncated file converts. edf2csv derives the number of data records from the actual file size rather than trusting the header, converts every complete record that's present, and warns about the discrepancy: ```text warning: The header declares 10 data records but the file contains 4. Only the 4 records that are present can be converted. The recording looks truncated. It may have been cut short or copied incompletely. ``` If bytes are left over after the last complete record, they're ignored and reported separately as a `TRAILING_BYTES` warning. Both warnings are also written into `metadata.json` under `notes`. If there isn't even one complete data record, the conversion fails with exit code 1 rather than producing a file with a header row and nothing in it. A recording still in progress often declares `-1` data records, which the spec permits. That's handled the same way: ```text warning: The header does not say how many data records the file has (-1), which the spec allows for recordings still in progress. Using the 4 records the file actually contains. ``` Converting a file that's actively being appended to works, with one catch: the file size is read once when the file is opened, so records written after that point aren't included. You get a clean conversion of the recording as it stood at that instant. If the file instead becomes shorter while it's being read, which happens when a writer rewrites it in place, the conversion fails rather than handing you a silently short result: ```text error: Expected 524,288 bytes of data at record 1024 but only 131,072 bytes were available; the file appears to have changed size while it was being read. Make sure the recording is not still being written to, then try again. ``` ## The values do not match what another tool gave me Check the low-order digits before assuming a bug. edf2csv computes each physical value as `gain * (offset + digital)`, which is the arrangement EDFlib uses, and the results are bit-for-bit identical to pyEDFlib on the recordings used for testing. The specification writes the same mapping as `(digital - digitalMin) * gain + physicalMin`. That form is algebraically equivalent but numerically worse: on a channel spanning plus or minus 800 uV it computes a value near 800 and then subtracts 800, and the cancellation discards low-order bits. Digital code 0 comes out as `0.19536019536019467` when the correctly rounded value is `0.19536019536019536`. A tool using the literal ordering will differ from edf2csv in the last few digits, and the edf2csv value is the correctly rounded one. Larger disagreements usually have a structural cause rather than an arithmetic one. If another tool gave you more rows than edf2csv did, it probably upsampled the slow channels to a common rate. If it gave you a continuous time axis for a file edf2csv split with a gap, it closed an EDF+D discontinuity. Compare against `channels.csv` and `metadata.json`, which state the rate and row count of every file that was written. ## How do I convert a whole directory of recordings? Point it at the folder: ```bash edf2csv /data/recordings --out /data/converted --jobs auto ``` Every `.edf` and `.bdf` inside is converted, at any depth — the extension is matched without regard to case, so `.EDF` and `.Bdf` count — and each gets its own directory under `/data/converted` keeping the position it had. Naming the files individually works too: ```bash edf2csv /data/recordings/*.edf --out /data/converted ``` Each recording gets its own directory inside `/data/converted`, named after the file. Leave `--out` off and each converts beside itself into `_csv` instead. A file that cannot be read is reported and the rest still convert; the run exits non-zero and the closing line says how many succeeded. Before this was possible the answer here was a shell loop, which still works and is still the right tool when you want to do something between files: ```bash for f in /data/recordings/*.edf; do edf2csv "$f" --out "/data/converted/$(basename "${f%.edf}")" done ``` Add `--force` if you expect to rerun the loop over the same destinations, and `--quiet` to keep the per-file summaries out of the log while still seeing warnings and errors. To stop the whole loop on the first failure, check the exit code: ```bash set -e for f in /data/recordings/*.edf; do edf2csv "$f" --out "/data/converted/$(basename "${f%.edf}")" --quiet done ``` ## How much memory does a large file need? Very little, and it doesn't scale with the length of the recording. Conversion is streamed: records are read in batches of about 8 MB, converted, and written out, so a 4 GB recording uses the same working set as a 4 MB one. A 40 MB EDF producing a 159 MB CSV converts in about 1.4 seconds with the Node heap capped at 48 MB. All the output files are written in the same single pass over the data, so a recording that produces three rate-split files is still read exactly once. Two things do scale with the recording, and neither is its length. The EDF+ annotation list is collected in memory before it's written, because annotations have to be sorted by onset and a writer is free to store an event in a record other than the one its onset falls in. A recording with hundreds of thousands of events uses memory in proportion to the event count. And one data record is read whole, because a record is the unit the format is addressed in and there is nothing smaller to divide it into. Records are almost always well under the 8 MB batch — a second of 60 channels at 200 Hz is 24 KB — but the header permits far larger, and a recording whose records are gigabytes needs room for one of them. Reading such a record works; it is the one case where the working set follows the file. The rows a record produces do not: until 0.4.54 they were held until the record ended, so a recording of one enormous record ran out of heap where the same samples split across many records converted fine. The row buffer is now emptied whenever it fills, wherever in the record that happens. Channel count used to be a third, and no longer is. Each channel gets a cache of its formatted values — the same handful of strings serve millions of rows — sized to the digital range it declares, up to 512 KB for one declaring the whole 16-bit range, which is what an ordinary EEG amplifier declares. A per-channel ceiling bounds nothing about a file's channel count, so a 229 KB recording of 256 such channels reserved 134 MB of pointers before writing a row, and died out of heap under any cap below the 192 MB that reservation forced it to need. Since 0.5.52 the caches share one 16 MB budget for the conversion, handed out fastest rate first; channels past it format each value directly, which is slower and produces exactly the same text. ## How do I check whether a conversion had problems from a script? Use the exit code for pass or fail, and `--json` for the detail. The exit codes are 0 for success, 1 for a problem with the file or the output directory, and 2 for a problem with how the command was invoked. Two more matter to a script: `--strict` also exits 1, for a conversion that wrote its output and merely raised a warning, so a pipeline using it cannot read 1 as "nothing was written"; and an interrupted run exits 130 for Ctrl-C or 143 for SIGTERM. The second is the one a script is likelier to meet, since `timeout`, systemd, a CI runner and a container stop all send SIGTERM — a pipeline that only tests for 130 reads a killed run as an ordinary failure. ```bash edf2csv sleep-study.edf --out ./converted --json > result.json ``` `--json` writes a summary to stdout and nothing else, so it can be piped or parsed directly: ```json { "tool": { "name": "edf2csv", "version": "..." }, "output_dir": "./converted", "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 } ], "annotations": 7, "duration_seconds": 28800, "records": 28800, "elapsed_ms": 1141, "warnings": [ { "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." } ] } ``` Every warning the run raised appears in the `warnings` array with a stable `code`, so a script can react to a specific condition rather than matching on message text: ```bash edf2csv sleep-study.edf --json \ | node -e 'process.stdin.toArray().then(c => { const codes = JSON.parse(c.join("")).warnings.map(w => w.code); if (codes.includes("RECORD_COUNT_MISMATCH")) process.exit(1); })' ``` Under `--json` the warnings go into the JSON on stdout instead of being printed to stderr, so you won't see them twice. Without `--json`, warnings and the summary go to stderr and the only things on stdout are `--info`'s description and, under `--stdout`, the signal CSV itself — so a conversion can run inside a pipeline without mixing messages into the data. ## How do I cite edf2csv, or pin a version? Pin the version wherever the tool is invoked, so a rerun a year from now produces the same bytes: ```bash npx edf2csv@0.2.0 sleep-study.edf npm install -g edf2csv@0.2.0 ``` Every conversion already records which version produced it. `metadata.json` opens with: ```json { "tool": { "name": "edf2csv", "version": "..." } } ``` Add `--checksum` and the SHA-256 of the input file is recorded alongside it, under `source.sha256`, so the input is pinned as well as the tool: ```bash edf2csv sleep-study.edf --checksum ``` For a methods section, name the tool, the version and the repository, and state the one non-obvious thing the conversion did: > EDF recordings were converted to CSV with edf2csv 0.2.0 > (https://github.com/tayal-sarthak/edf2csv). Channels recorded at different sampling rates were > written to separate files and were not resampled. edf2csv is MIT licensed, so it can be redistributed, vendored into a pipeline or included in supplementary material without restriction. `edf2csv --version` prints the version of whatever copy you're running.