API — primitives

Every function here takes bytes or plain values and returns plain values. No promises, no source, no hidden state. Each one is testable from a Uint8Array literal with no mocks.

The primitives are the layer the reading functions are built out of. Reach for them directly when you already hold bytes (from a WebSocket, a zip entry, a database blob), or to decode the same buffer twice without re-reading it.

Each function’s result shape is summarised with it. Types lists every public shape in full.

import {
  parseHeader,
  decodeDigital,
  toPhysical,
  clampToDigitalRange,
  decodeAnnotations,
  resolveTimeWindow,
  trimToWindow,
  findSignals,
  getSignal,
  isAnnotationLabel,
  decodeHeaderLatin1,
  formatStartTimeNaive,
  formatDiagnostics,
} from 'edfcore';

parseHeader

function parseHeader(
  headerBytes: Uint8Array,
  sourceByteLength: number,
  options?: ParseOptions,
): EdfHeader

Parses an EDF, EDF+, BDF or BDF+ header.

headerBytes must hold at least 256 * (ns + 1) bytes. Anything beyond that is ignored, so a caller that over-read is free to pass its whole buffer.

sourceByteLength is a required positional argument, and it is the size of the whole file, not the length of headerBytes. Two parsing steps need it, and neither can be skipped:

  • Recovering recordCount = -1. That value is the EDF+ sanctioned way of saying the writer never closed the file, and it’s common in real corpora. The only way to learn the real count is floor((sourceByteLength - headerByteLength) / recordByteLength). Without the file size there is no answer.
  • Detecting truncation. A header declaring 2880 records over a file that only holds 2879 whole ones is a truncated download. The difference between those two numbers is the only evidence of it. edfcore reports TRUNCATED_FILE and sets recordCount to what is actually present.

Pass bytes.byteLength for an in-memory file, or source.byteLength for a ByteSource. Passing the length of a header-only buffer isn’t an error edfcore can detect. It reports recordCount: 0 with recordCountSource: 'sourceByteLength', which is the correct answer to the question you asked.

Throws a plain RangeError when sourceByteLength is not a non-negative safe integer, which is a caller bug rather than a file defect. Throws EdfFormatError for a defect that makes further parsing meaningless: fewer than 256 bytes, a header shorter than the signal count implies, or a zero-byte data record. An EDF+ file with no annotations signal throws too.

import { parseHeader } from 'edfcore';

const bytes = new Uint8Array(await file.arrayBuffer());
const header = parseHeader(bytes, bytes.byteLength);

console.log(header.variant);            // 'EDF+C'
console.log(header.recordCount);        // 10
console.log(header.recordCountSource);  // 'headerField'

Validation order is pinned and tested. Under strict: true the first would-be diagnostic throws, so the order decides which error a broken file reports. Moving a check is a behavioural change even when every individual check is unaltered.

EdfHeader

Field Type Meaning
variant EdfVariant 'EDF' | 'EDF+C' | 'EDF+D' | 'BDF' | 'BDF+C' | 'BDF+D'.
continuity 'continuous' | 'discontinuous' From the reserved marker. Plain EDF and BDF are continuous.
bytesPerSample 2 | 3 2 for EDF, 3 for BDF.
headerByteLength number Always the computed 256 * (ns + 1), never the declared value.
declaredHeaderByteLength number What the file claims at offset 184, kept so a mismatch stays visible.
recordByteLength number bytesPerSample * sum(samplesPerRecord). Every data offset steps by this.
dataByteLength number recordCount * recordByteLength.
recordDurationSeconds number May legitimately be 0. Never divide by it.
recordDurationTicks bigint The same value in exact 100 ns ticks, taken from the digits on disk rather than from the parsed float.
recordCount number Resolved and non-negative. What is actually readable.
declaredRecordCount number Verbatim. -1 means the writer never closed the file.
recordCountSource 'headerField' | 'sourceByteLength' Which of the two produced recordCount.
startTime EdfStartTime Date and clock, with both candidate dates kept.
patient EdfPatientId Parsed local patient identification.
recording EdfRecordingId Parsed local recording identification.
signals readonly EdfSignal[] Every signal, data and annotations alike, in file order.
dataSignalIndices readonly number[] Indices of the sample-carrying signals.
annotationSignalIndices readonly number[] Indices of the annotations signals. Element 0 is the one carrying timekeeping.
reserved string The full 44 reserved bytes, verbatim.
raw EdfRawHeaderFields Every fixed-header field as written, before trimming or interpretation.
rawBytes Uint8Array A copy of the whole header, for hexdumps and bug reports.
diagnostics readonly EdfDiagnostic[] Everything the parse found. Empty under strict, because the first one threw.

raw holds version, patientId, recordingId, startDate, startTime, headerByteLength, reserved, recordCount, recordDuration and signalCount, all as string. Anything that can be checked against the file is exposed twice: as parsed value, and as the bytes it came from.

EdfSignal

Field Type Meaning
index number Position in header.signals.
kind 'data' | 'annotations' Decided by the reserved label, not by anything you pass in.
label string Trimmed. raw.label keeps the padding.
transducerType string Trimmed.
prefiltering string Trimmed.
physicalDimension string Exactly as written.
unit string Normalised for comparison only. The several encodings of micro all become u.
physicalMinimum number As declared.
physicalMaximum number May be less than the minimum. That is a negative amplifier gain, it is legal, and edfcore never “fixes” it.
digitalMinimum number As declared.
digitalMaximum number As declared.
samplesPerRecord number Authoritative. Sample indexing uses this, never a rate.
sampleRateHz number | undefined Derived. undefined exactly when recordDurationSeconds === 0. Never index by this.
sampleCount number samplesPerRecord * header.recordCount.
scale EdfScale | undefined { bitValue, offset }, or undefined when scaling is impossible or unsafe.
recordByteOffset number Byte offset of this signal’s block within one data record.
recordByteLength number samplesPerRecord * header.bytesPerSample.
raw EdfRawSignalFields All ten per-signal fields as written.

scale is undefined for a degenerate or inverted digital range, a degenerate physical range, or a log-transformed channel. toPhysical then throws; decodeDigital keeps working.

EdfStartTime

EDF records local time at the patient with no timezone, so edfcore never produces a Date. A Date applies the reader’s zone, and is worst exactly at DST boundaries.

Field Type Meaning
headerDate EdfCalendarDate | undefined From the dd.mm.yy field, through the 1985–2084 rule.
recordingIdDate EdfCalendarDate | undefined From the EDF+ recording-identification Startdate subfield (the only unambiguous four-digit year, and the only way past 2084).
resolvedDate EdfCalendarDate | undefined recordingIdDate when present, otherwise headerDate.
dateSource 'headerField' | 'recordingIdField' | 'none' Which one won.
clock EdfClockTime { hour, minute, second }. Reports midnight when the field was unparseable, and says so in a diagnostic.
secondsSinceMidnight number Derived from clock.

EdfCalendarDate is { year, month, day } with month in 1–12, not a JavaScript month index. A disagreement between the two dates is always reported as DATE_FIELDS_DISAGREE, and both stay on the result.

decodeDigital

function decodeDigital(
  header: EdfHeader,
  recordBytes: Uint8Array,
  records: RecordRange,
  signalIndex: number,
  out?: Int32Array,
  options?: MaterializeOptions,
): Int32Array

De-interleaves one signal out of a record range and sign-extends its samples. This is the sole owner of the 2- and 3-byte two’s complement expressions. EDF is little-endian 16-bit; BDF is little-endian 24-bit sign-extended from bit 23. There’s no big-endian variant of either.

recordBytes must be exactly records.count * header.recordByteLength bytes and must begin at record records.start. Pass the buffer readRecordBytes returned, unsliced. The range check catches asking for records the file doesn’t have. The length check catches a buffer that doesn’t start where records.start says it does. Nothing in the bytes identifies which record they came from, so that case is unrecoverable rather than merely wrong.

Returns an Int32Array of records.count * signal.samplesPerRecord samples, still in digital units.

out is the reuse path. When supplied and long enough it is written in place and no allocation happens. A longer array is narrowed with subarray, which shares its memory, so result.length still equals the true sample count. An out that is too short throws a plain RangeError.

Throws EdfChannelNotFoundError for a signal the header does not have, EdfRangeError for a bad range or a mis-sized buffer, and EdfBudgetError when the allocation would exceed maxMaterializeBytes. The budget check runs before the allocation, never during.

import { readRecordBytes, decodeDigital } from 'edfcore';

const records = { start: 0, count: 2 };
const bytes = await readRecordBytes(source, header, records);

// Fresh allocation.
const digital = decodeDigital(header, bytes, records, 0);

// Or reuse across a loop — zero allocations after the first.
const scratch = new Int32Array(64);
const again = decodeDigital(header, bytes, records, 0, scratch);
console.log(again.buffer === scratch.buffer); // true

Warning Never pass an annotations signal’s index here. Its bytes are TAL text; decoded as samples they produce numbers that look exactly like a signal. decodeDigital does not check for it (the check lives in readRecords), so select from header.dataSignalIndices.

MaterializeOptions

The trailing options argument on every primitive that can allocate.

Field Type Default Meaning
maxMaterializeBytes number 268435456 (256 MiB) Refused before the allocation, never during it.

Passing nothing keeps the default. ReadOptions also carries this field, so the same value flows through a read and the decode that follows it.

toPhysical

function toPhysical(
  signal: EdfSignal,
  digital: ArrayLike<number>,
  out?: Float64Array,
  options?: MaterializeOptions,
): Float64Array

Converts digital counts into the signal’s own physical units, in float64 throughout. digital is any ArrayLike<number>: an Int32Array from decodeDigital, or a plain array.

The expression is physical = bitValue * (offset + digital), and it is pinned. It is numerically worse than the obvious physicalMinimum + (digital - digitalMinimum) * gain. It is EDFlib’s exact form, kept verbatim so that edfcore’s float64 output can be compared bit for bit against pyEDFlib and EDFlib. That parity is the design target. It isn’t yet demonstrated by a golden-value harness, so treat it as intent rather than a measured guarantee. The two forms disagree by up to about 9.3e-10 LSB (ten orders of magnitude below the quantisation floor). On asymmetric ranges they differ on nearly half the samples by one ULP. If your pipeline cross-validates against a Python reader, that shared expression is why the numbers are expected to agree.

Float64 is a contract, never Float32. Float32 carries 24 significand bits, so a 24-bit BDF sample scaled into it loses about a quarter of a quantisation step.

Throws EdfScalingError when signal.scale is undefined. The error carries code, signalIndex and label, and its message names the raw header fields it read. decodeDigital keeps working on that signal.

EdfScalingError.code Cause
DEGENERATE_DIGITAL_RANGE digitalMinimum === digitalMaximum, which makes the gain a division by zero.
DEGENERATE_PHYSICAL_RANGE physicalMinimum === physicalMaximum, so every sample maps to one value.
INVERTED_DIGITAL_RANGE digitalMinimum > digitalMaximum, and edfcore will not guess which the writer meant.
LOG_TRANSFORMED_CHANNEL Physical dimension Filtered: the values are log-compressed and the linear formula would be wrong by orders of magnitude.
SCALE_UNAVAILABLE No scale, and none of the above explains it.

out behaves exactly as in decodeDigital: reused when long enough, narrowed with subarray when longer, a plain RangeError when shorter.

import { getSignal, toPhysical } from 'edfcore';

const signal = getSignal(header, 'Fp1');
const microvolts = toPhysical(signal, digital);

// Same length, same order, now in signal.physicalDimension units.
console.log(microvolts.length === digital.length); // true
console.log(signal.physicalDimension);             // 'uV'

clampToDigitalRange

function clampToDigitalRange(
  signal: EdfSignal,
  digital: Int32Array,
  out?: Int32Array,
  options?: MaterializeOptions,
): Int32Array

Clamps samples to the declared digital range. Post-hoc only. Nothing on the read path calls this.

It exists for one job: cross-validating against a consumer that clamps. EDFlib clamps samples to the declared digital range when it loads them. If your numbers differ from a Python reader’s only at the extremes, this is the difference.

Clamping is to [min(digMin, digMax), max(digMin, digMax)] rather than to [digMin, digMax]. That matters for an inverted declaration, where the naive bounds are empty and collapse every sample onto a single value.

Throws a plain RangeError when either bound is not a finite number. Every comparison against NaN is false, so a clamp against one returns the input unchanged.

const clamped = clampToDigitalRange(signal, digital);

decodeAnnotations

function decodeAnnotations(
  header: EdfHeader,
  recordBytes: Uint8Array,
  records: RecordRange,
  options?: DecodeAnnotationsOptions,
): EdfAnnotationsResult

Decodes the TALs in the annotation regions of a record range. Same buffer contract as decodeDigital: exactly records.count * header.recordByteLength bytes, beginning at records.start, or EdfRangeError.

The returned shape is documented under readAnnotations, which is this function plus one read.

Three rules the rest of the library depends on:

  1. The first TAL of the first annotations signal of the file is that record’s timekeeping TAL. “First” is a position, not “the first one that parsed”. “First annotations signal” means header.annotationSignalIndices[0], whatever this call was asked for. An additional annotations signal carries no timekeeping, so its first TAL is a real event and stays.
  2. recordOnsetTicks has one entry for every record in the decoded range, always.
  3. Onsets are exposed under both conventions as separately named fields, never as an option.

Passing signalIndices that omits the timekeeping signal is legal and means no timekeeping is read. Every recordOnsetTicks entry then falls back to the nominal grid. Passing a data-signal index throws a plain RangeError.

Diagnostic volume is bounded. TIMEKEEPING_TAL_MISSING is reported per record, because it names a record whose onset was derived and that information exists nowhere else. NEGATIVE_ANNOTATION_ONSET and TIMEKEEPING_TAL_NONCONFORMANT are reported once per call. Grammar defects are deduplicated per region and carry an occurrence count.

import { readRecordBytes, decodeAnnotations } from 'edfcore';

const records = { start: 0, count: header.recordCount };
const bytes = await readRecordBytes(source, header, records);
const { annotations, recordOnsetTicks } = decodeAnnotations(header, bytes, records);

// Text verbatim, onset exact in 100 ns ticks.
console.log(annotations[0].text, annotations[0].onsetTicks);
// One entry per record, always — record 1 of a 1 s file starts at 10000000n.
console.log(recordOnsetTicks.length === records.count);

Record-onset spacing and monotonicity are not checked here. This function produces recordOnsetTicks, and the timeline layer owns what a valid timeline is.

resolveTimeWindow

function resolveTimeWindow(
  timeline: EdfTimeline,
  index: EdfRecordIndex,
  startSeconds: number,
  durationSeconds: number,
): readonly RecordRange[]

Answers “which records does this window cost?” before a byte is read, so the price of a window is auditable. This is the function readWindow calls first.

Returns one RecordRange per contiguous run the window overlaps, in time order. Empty when the window falls entirely inside a gap, entirely outside the recording, or has a non-positive duration. The interval is half-open [start, start + duration), so a zero-length window contains no time and therefore no samples.

Ranges are record-aligned and are therefore usually wider than the window. A record is the smallest unit the file can be read by; trimToWindow is how you narrow the samples afterwards.

With index.segments present (coverage === 'complete') the answer is exact. With a probed index it is exact only while the file is contiguous, which is precisely what spanSeconds === coveredSeconds states. Otherwise this function throws a plain RangeError rather than guessing at onsets nobody has read.

Every comparison inside is integer or rational arithmetic on ticks, records and samplesPerRecord. round(t * sampleRateHz) appears nowhere. sampleRateHz is derived and often not representable (256/3 Hz is a real record duration of 3 s with 256 samples). Rounding through it walks the answer off by a sample near every large t.

// One second from t = 2.5, on a file with 1 s records: it straddles records 2 and 3.
const ranges = resolveTimeWindow(recording.timeline, recording.index, 2.5, 1);
// [ { start: 2, count: 2 } ]

const bytes = ranges.reduce(
  (total, r) => total + r.count * recording.header.recordByteLength,
  0,
);
console.log(`this window costs ${bytes} bytes in ${ranges.length} read(s)`);

trimToWindow

function trimToWindow(
  header: EdfHeader,
  chunkSignal: EdfChunkSignal,
  startSeconds: number,
  durationSeconds: number,
): EdfChunkSignal

Narrows one record-aligned EdfChunkSignal to exactly the samples inside [startSeconds, startSeconds + durationSeconds).

Sample j of the chunk starts at chunkSignal.startSeconds + j * recordDuration / samplesPerRecord, so the samples inside the window are those with j * recordDuration >= relativeStart * samplesPerRecord and j * recordDuration < relativeEnd * samplesPerRecord. Both comparisons are integer bigint products of on-disk quantities (no division, no sample rate, no float bound). The boundary sample is therefore the same one on every platform.

digital in the result is a subarray view of the input’s, so trimming allocates nothing and the two share memory. sampleCount is taken from the view, so the count and the data cannot disagree. outOfDigitalRangeCount is re-counted only when it can have changed and only when there is something to find.

The chunk must be one contiguous run of records (what readWindow returns), because that’s what makes the sample grid uniform across it. A window that only partly overlaps the chunk is clamped to the samples that exist. One that misses it entirely yields a zero-length result rather than an error.

Throws EdfChannelNotFoundError when chunkSignal.signalIndex is not in header.signals, which means you passed a different header than the chunk was read with.

import { readWindow, trimToWindow } from 'edfcore';

const chunks = await readWindow(recording, {
  startSeconds: 1.4,
  durationSeconds: 0.5,
  signalIndices: [0, 1],
});

for (const chunk of chunks) {
  for (const signal of chunk.signals) {
    const exact = trimToWindow(header, signal, 1.4, 0.5);
    console.log(signal.signalIndex, exact.sampleCount, exact.startSeconds);
  }
}
// On a file with 1 s records, signal 0 at 256 samples/record and signal 1 at 3:
// 0 128 1.40234375           <- 256 Hz: the first sample at or after 1.4 s
// 1 1   1.6666666666666667   <- 3 Hz: a coarser grid, so the boundary lands later

That per-signal difference is why startSeconds lives on EdfChunkSignal rather than only on the chunk. Two signals of different rates have different first samples inside the same window.

findSignals

function findSignals(header: EdfHeader, label: string): readonly EdfSignal[]

Every signal with this label, in signal order. Empty when none matches. Matching is exact on the trimmed label and is case-sensitive; the argument is trimmed too, so ' Fp1 ' matches 'Fp1'.

Nothing else is normalised. EDF labels are electrode names, 'Fp1' and 'FP1' are written by different systems, and edfcore has no montage vocabulary to decide they are the same thing.

const matches = findSignals(header, 'T8-P8');
console.log(matches.map((s) => s.index)); // [ 0, 1 ]  — CHB-MIT really does this

getSignal

function getSignal(header: EdfHeader, selector: number | string): EdfSignal

One signal, by index or by label. A numeric selector indexes header.signals directly.

Throws EdfChannelNotFoundError when nothing matches, carrying selector and availableLabels so the message can list what the file actually has. Throws EdfAmbiguousChannelError when a label matches more than one signal, carrying label and matchingIndices.

That second case is the reason this function exists. signals.find(s => s.label === label) returns the first of a duplicate pair and reports nothing about the second.

import { getSignal, EdfAmbiguousChannelError, type EdfHeader, type EdfSignal } from 'edfcore';

function resolve(header: EdfHeader, label: string): EdfSignal {
  try {
    return getSignal(header, label);
  } catch (error) {
    if (error instanceof EdfAmbiguousChannelError) {
      // Duplicates are real. Decide deliberately which one you meant.
      console.warn(`${label} matches indices ${error.matchingIndices.join(', ')}`);
      return getSignal(header, error.matchingIndices[0]);
    }
    throw error;
  }
}

instanceof is fine inside a single realm; if the header could have crossed a worker or iframe boundary, branch on isEdfError(error) && error.edfErrorKind === 'channel' instead.

isAnnotationLabel

function isAnnotationLabel(label: string): boolean

True for the two reserved annotation labels, 'EDF Annotations' and 'BDF Annotations', matched on the trimmed text and case-sensitively.

Both are accepted for either family: the label identifies the channel’s role, and a BDF+ file written by an EDF+ library carries 'EDF Annotations'.

You rarely need this, since signal.kind already says 'annotations'. It’s the same predicate the parser uses, so it’s here for code that classifies a label before it has a header.

isAnnotationLabel('EDF Annotations ');  // true
isAnnotationLabel('Fp1');               // false

decodeHeaderLatin1

function decodeHeaderLatin1(bytes: Uint8Array): string

Decodes header bytes as ISO-8859-1: byte b becomes code point U+00b, always. No trimming, no interpretation.

TextDecoder is not used on this path. Verified on Node v24.4.0, TextDecoder('latin1'), 'iso-8859-1', 'ascii' and 'windows-1252' all report encoding === 'windows-1252' yet decode 0x80 as U+0080. The WHATWG Encoding Standard mandates U+20AC for those labels, so the same header bytes produce different strings in Node and in a spec-compliant browser. String.fromCharCode is the identity map ISO-8859-1 actually is, and it’s the same everywhere.

decodeHeaderLatin1(bytes.subarray(0, 8));  // '0       '

formatStartTimeNaive

function formatStartTimeNaive(startTime: EdfStartTime): string | undefined

Renders the recording start as '1951-08-02T09:00:00.000'. There is no zone designator, because EDF has no zone. Returns undefined when startTime.resolvedDate is undefined, i.e. the file carries no resolvable date.

The milliseconds are always .000. The header stores whole seconds. The sub-second start of an EDF+ recording lives in record 0’s timekeeping TAL (timeline.startOffsetSeconds).

Do not feed the result to new Date() without deciding what zone you mean.

formatStartTimeNaive(header.startTime);  // '1951-08-02T09:00:00.000', or undefined

formatDiagnostics

function formatDiagnostics(
  diagnostics: readonly EdfDiagnostic[],
  options?: FormatDiagnosticsOptions,
): string

Renders a diagnostic array as a multi-line report, one block per diagnostic. Returns '' for an empty list, so the result concatenates into a larger report without a stray blank line.

Layout only. By the message contract a diagnostic’s own message already names the field, the raw bytes as written, the rule and the next step. This adds structure: severity marker, code, location, bytes as hex. Output is deterministic: no locale-sensitive formatting, no unordered iteration, and no ANSI escapes unless you ask for them.

FormatDiagnosticsOptions Type Default Meaning
color boolean false ANSI colour by severity: red for error, yellow for warning, cyan for info, dim for detail lines.
maxItems number all Show at most this many, then a dim ... and N more. A non-finite value is ignored; 0 shows only the summary line.

Raw byte runs are elided after 24 bytes with a +N more count: a report is a summary, not a hex dump.

import { formatDiagnostics } from 'edfcore';

console.log(formatDiagnostics(header.diagnostics, { color: true, maxItems: 10 }));
warning [DATE_CLIPPED_TO_1985_2084] startdate field (8 bytes at offset 168) is "01.01.20": its
  two-digit year was resolved to 2020 by the EDF+ rule that 85..99 mean 1985..1999 and 00..84
  mean 2000..2084, so the field cannot express a year outside that span. ...
  at byte offset 168 (8 bytes), startDate
  raw: "01.01.20"
  expected: 1985..2084
  actual: 2020
  spec: EDF+ additional specification 2 (startdate and starttime)

Constants

Constant Value Meaning
EDF_HEADER_BLOCK_BYTES 256 The fixed header is one block, and each signal adds exactly one more. Total header size is always 256 * (signalCount + 1).
EDF_ANNOTATIONS_LABEL 'EDF Annotations' The reserved label, trimmed and case-sensitive. On disk the field is 'EDF Annotations ' (15 characters plus a pad).
BDF_ANNOTATIONS_LABEL 'BDF Annotations' The BDF spelling. Either is accepted for either family.
EDF_DIGITAL_MIN -32768 16-bit two’s complement (the EDF sample range).
EDF_DIGITAL_MAX 32767
BDF_DIGITAL_MIN -8388608 24-bit two’s complement, sign-extended from bit 23 (the BDF sample range).
BDF_DIGITAL_MAX 8388607
EDF_RECOMMENDED_MAX_RECORD_BYTES 61440 An EDF specification recommendation, not a limit. Exceeding it is a warning, never an error. Reads are record-aligned, so it is also the smallest amount of data any read of that file can return.
TICKS_PER_SECOND 10000000n A bigint. Time is compared in exact 100 ns ticks, never in floats. Float equality on event times is how ERP alignment breaks.
VERSION '0.1.0' The published package version, kept in sync with package.json by a test.
import { TICKS_PER_SECOND, EDF_DIGITAL_MAX, VERSION } from 'edfcore';

const halfSecond = TICKS_PER_SECOND / 2n;   // 5000000n
const saturated = digital.filter((v) => v === EDF_DIGITAL_MAX).length;
console.log(`edfcore ${VERSION}`);

VERSION is '0.1.0' at the time of writing, and that number is meant literally. edfcore is checked against real public corpora (see the installation page). It has not been compared element by element against pyEDFlib or MNE, so treat what it tells you about a file as a well-argued claim rather than a settled one.