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
Three separate claims
Correctness here covers three different things, verified three different ways.
- The arithmetic is right. The physical values edf2csv computes match the values a reference implementation computes, to the last bit. Checked against pyEDFlib.
- 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.
- 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.
The second and third are what npm test runs. The first is an external comparison against another tool, described below, along with how to reproduce it 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 recordings used for testing, 129,536 sample values came out of edf2csv and out of pyEDFlib bit-for-bit identical.
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.
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:
// 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:
npm install edf2csv
node dump-doubles.mjs recording.edf 0 channel0.f64
Then compare in 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.
Two caveats. This comparison isn't part of npm test, because it needs a Python environment and real recordings, and real recordings aren't in the repository. 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:
// 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. Even forcing the maximum with --decimals 15 prints 0.195360195360195 from both forms.
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.
makeScalerreturns 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, which is why the precision cap is 15. 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 or not finite | Every sample converts to the same value, so the physical minimum is written. |
| 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. |
physicalMin above physicalMax |
Converted exactly as the header specifies, polarity inversion included, with an INVERTED_PHYSICAL_RANGE warning. Correcting the header would mean guessing about the recording. |
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.
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. |
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. |
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:
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.
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 three test files with Node's built-in test runner. There's no test framework to install and no configuration file to read. It finishes in about a second on a laptop:
ℹ tests 88
ℹ suites 19
ℹ pass 88
ℹ fail 0
The 88 tests are split across three files by what they exercise:
| File | Tests | What it covers |
|---|---|---|
test/edf.test.js |
33 | Header parsing, diagnostics, digital-to-physical conversion, chunked reading, BDF, EDF+ annotation decoding |
test/convert.test.js |
33 | Time specifications, column naming, channel selection, rate grouping, and the contents of the written CSV files |
test/cli.test.js |
22 | The built executable: exit codes, stdout versus stderr, overwrite refusal, unwritable destinations, invocation through a symlink as npx does |
To run one file, build and generate first, then point the runner at it:
npm run build && npm run fixtures
node --test test/edf.test.js
To run a single group of tests, filter by name:
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.
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.