Errors and diagnostic codes

One rule decides which of these you get. edfcore throws when proceeding would mean making up a value, and records a diagnostic on the result when it can proceed without one. There is no third category, and there is no console call anywhere in the package.

edfErrorKind

Every error edfcore throws carries a string edfErrorKind, and isEdfError is the one spelling of the check:

import { isEdfError, readWindow } from 'edfcore';
import type { EdfBudgetError, EdfRangeError } from 'edfcore';

try {
  return await readWindow(recording, selection);
} catch (error) {
  if (!isEdfError(error)) throw error;

  switch (error.edfErrorKind) {
    case 'budget':  return askForLess((error as EdfBudgetError).budgetBytes);
    case 'range':   return clampToFile((error as EdfRangeError).available);
    case 'source':  return retry();
    default:        throw error;
  }
}

instanceof compares constructor identity, and constructor identity is per-realm. An error thrown inside a worker or an iframe and passed out fails instanceof EdfFormatError in the receiving context even though it is one. So does an error from a second copy of edfcore that some dependency pulled into the tree. A string property survives all three.

Note The cast in each branch is load-bearing. edfErrorKind discriminates correctly at runtime. The seven classes are seven class types rather than a union type, so TypeScript narrows the property and not the object. isEdfError gives you an EdfError, and EdfError declares edfErrorKind and nothing else. Reaching for error.budgetBytes without the cast is a compile error. The cast is safe because the kind you just matched on is what fixes the class.

function isEdfError(value: unknown): value is EdfError;

It tests for an object with a string edfErrorKind and nothing else. EdfError itself is exported. It’s the abstract base every class below extends, and it sets name to the concrete constructor’s name, so error.name is 'EdfFormatError' rather than 'Error'.

type EdfErrorKind = 'format' | 'scaling' | 'range' | 'source' | 'budget' | 'channel';

Six kinds, seven classes: the two channel errors share 'channel' because a caller recovering from “I could not resolve that label” recovers the same way for both.

The error classes

class edfErrorKind thrown when
EdfFormatError 'format' the file is wrong, or strict caught a diagnostic
EdfScalingError 'scaling' physical units are undefined for one signal
EdfRangeError 'range' you asked for records that do not exist
EdfSourceError 'source' a ByteSource broke its contract, or could not be reached
EdfBudgetError 'budget' an allocation was refused before it happened
EdfAmbiguousChannelError 'channel' a label matched more than one signal
EdfChannelNotFoundError 'channel' a label or index matched none

Each carries the context for its own failure, so you never have to parse the message.

EdfFormatError

field type meaning
code EdfDiagnosticCode the diagnostic code that fired
diagnostic EdfDiagnostic | undefined the whole diagnostic it would otherwise have recorded
field string | undefined the header field at fault
byteOffset number | undefined where in the file
signalIndex number | undefined which signal
recordIndex number | undefined which record

The last four default to the matching fields of diagnostic, so nothing is lost by throwing rather than collecting:

import { isEdfError, openEdf } from 'edfcore';
import type { EdfFormatError } from 'edfcore';

try {
  await openEdf(source, { strict: true });
} catch (error) {
  if (isEdfError(error) && error.edfErrorKind === 'format') {
    const formatError = error as EdfFormatError;
    console.log(formatError.code);                           // 'DATE_CLIPPED_TO_1985_2084'
    console.log(formatError.field, formatError.byteOffset);  // 'startDate' 168
    console.log(formatError.diagnostic?.specReference);      // 'EDF+ additional specification 2 ...'
  }
}

The public initialiser type is exported too, as EdfFormatErrorInit: { code, diagnostic?, field?, byteOffset?, signalIndex?, recordIndex?, cause? }. You need it only if you construct one yourself.

EdfScalingError

field type meaning
code EdfDiagnosticCode which of the four scaling conditions applies
signalIndex number the signal with no defined gain
label string that signal’s trimmed label

Thrown by toPhysical for a signal whose scale is undefined. The code is re-derived from the signal by applying the same four tests the header applied, in the same order. It therefore names the same cause the header recorded. A signal that matches none of them yet still has no scale reports SCALE_UNAVAILABLE rather than the nearest-looking code.

EdfRangeError

field type meaning
requested RecordRange what you asked for
available RecordRange what the file has, always starting at 0

Your bug, not the file’s. readRecordBytes, readRecords and index.onsetTicks all throw it.

EdfSourceError

field type meaning
offset number the byte offset of the read that failed
requestedLength number how many bytes were asked for
receivedLength number | undefined how many arrived, when that is knowable

The ByteSource contract is one sentence: a read resolves with exactly length bytes or rejects, never padding and never truncating. It’s checked on every call, including calls into a source you wrote. receivedLength is number | undefined because a misbehaving source may resolve with something that is not a byte array at all.

httpSource uses this class for its own failures too. It throws when no fetch implementation is available, when a server reports no usable size, on a non-success status, and when a server ignores the Range header. Those carry an offset and a requestedLength describing the read that provoked them.

EdfBudgetError

field type meaning
requiredBytes number the allocation that was refused
budgetBytes number the ceiling in force
optionName 'maxMaterializeBytes' the option to raise, as a literal

The check happens before the allocation is attempted rather than during it. Float64 physical output is four times the on-disk size for EDF, so one large call can take down a browser tab. The default ceiling is 256 MiB. Decode fewer records per call, reuse an out array, or raise the option.

EdfAmbiguousChannelError and EdfChannelNotFoundError

EdfAmbiguousChannelError type meaning
label string the trimmed label you asked for
matchingIndices readonly number[] every signal carrying it
EdfChannelNotFoundError type meaning
selector string | number the label or index you passed
availableLabels readonly string[] every label in the file, in signal order

Real files ship duplicate labels: CHB-MIT carries T8-P8 twice. Returning the first without a word is how the wrong channel ends up in a paper. getSignal throws and hands you the indices; findSignals returns them all when that is what you want.

Note Not everything edfcore throws is an EdfError. Passing an annotations signal to readRecords throws a plain RangeError, and so does a bad argument shape passed to a primitive. So does asking a probed index to map a time window on a file that has a gap. isEdfError returns false for all three. An EdfError says something about the file; a RangeError says something about the call. Aborting through options.signal throws an Error whose name is 'AbortError', the property the platform’s own consumers branch on.

Dispositions

Every code has exactly one severity and one disposition, both fixed in one file. The disposition is what the code does; the severity is what it is.

disposition severity behaviour
fatal 'error' throws EdfFormatError, whether or not strict is set
deferred 'error' recorded; signal.scale becomes undefined and toPhysical throws
warning 'warning' recorded; the file is non-conformant and what you got back is true
info 'info' recorded; the file is correct and the situation surprises people

Severity is derived from the code rather than passed in at the reporting site, so one code cannot acquire two severities in two places. Under strict: true the first would-be diagnostic of any disposition throws instead, which is why every diagnostics array is empty by construction in strict mode.

Always fatal

Eight codes. Each names something the file does not supply, so these throw whether or not strict is set.

code what it means what to do
NOT_AN_EDF_FILE no recognisable EDF or BDF version block at offset 0 check the file is not gzipped, a vendor container, or a truncated download; inspectEdf reports this without throwing
SOURCE_TOO_SMALL fewer than 256 bytes, or fewer than 256 * (ns + 1) the header is incomplete; check the whole file arrived, and that the source was built over the whole file rather than a prefix
SIGNAL_COUNT_INVALID ns is blank, non-numeric, or outside 1..9999 every later offset is a multiple of ns, so nothing can be read; hexdump bytes 252-255
NUMERIC_FIELD_INVALID a field the file geometry depends on failed its grammar end to end the error names the field and its offset; the file needs repairing at the writer
COMMA_DECIMAL_SEPARATOR a numeric field uses , as its decimal separator '0,5' and '1,024' are indistinguishable, and guessing turns 1024 into 1.024; rewrite the field with .
RECORD_SIZE_ZERO every signal declares 0 samples per record records have no size to step by, so no record can be located
EDFPLUS_WITHOUT_ANNOTATION_SIGNAL an EDF+ marker with no annotations signal no per-record timing exists, so any time reported would be invented; either the marker or the signal is wrong
TIMELINE_NOT_MONOTONIC record onsets went backwards every time-based answer would be wrong; read by record index with readRecords, or repair the timekeeping TALs

TIMELINE_NOT_MONOTONIC is the one that fires late, and only where a violating pair is actually observed. openEdf sees the two probes; locate sees the pairs its binary search touches; readRecords sees the records in the chunk; buildRecordIndex and validateRecording see every pair in the file. A file whose only violation sits between the two probes opens fine and throws when you read across it.

Deferred-fatal

Four codes. The header parses, the file is readable, and one signal has no defined conversion to physical units. Their severity is error, and they do not throw at parse time.

code what it means what to do
DEGENERATE_DIGITAL_RANGE digitalMinimum === digitalMaximum, so the gain is a division by zero use decodeDigital on that signal; the other signals are unaffected
DEGENERATE_PHYSICAL_RANGE physicalMinimum === physicalMaximum, so every sample maps to one value as above
INVERTED_DIGITAL_RANGE digitalMinimum > digitalMaximum, which has no sanctioned meaning as above; edfcore will not guess which one the writer meant
LOG_TRANSFORMED_CHANNEL the physical dimension is exactly Filtered, so values are log-compressed the linear formula would be wrong by orders of magnitude; apply the log transform yourself over decodeDigital output

The failure lands on the exact call that cannot be answered. signal.scale is EdfScale | undefined, so the compiler makes you check before reading the gain, though toPhysical itself accepts any EdfSignal and throws at runtime. See physical values for the whole story.

Warnings

Thirty-one codes. The file stays readable and what edfcore returns about it is true.

Header structure

code what it means what to do
HEADER_SIZE_MISMATCH byte 184 disagrees with 256 * (ns + 1) nothing; the computed size wins and declaredHeaderByteLength keeps the claim
RECORD_COUNT_RECOVERED the record count was -1, negative or unreadable, and was recovered from the source length check that the source’s byteLength is the true file size; recordCountSource is 'sourceByteLength'
TRUNCATED_FILE the source is shorter than the declared record count implies the whole records present are exposed; the rest of the file never arrived
PARTIAL_FINAL_RECORD the source ends part-way into a record nothing; only whole records are exposed and nothing is zero-padded, because padding would decode as real samples
TRAILING_BYTES bytes exist beyond the last declared data record nothing; they are never decoded and dataByteLength counts only the declared records
RECORD_SIZE_ABOVE_RECOMMENDED a data record exceeds the 61,440-byte recommendation nothing; but every read is record-aligned, so this is also the smallest amount of data any read of this file can return
NONSTANDARD_RESERVED_FIELD the reserved field is neither blank nor a recognised marker, or its marker names the other family the file is read as whatever the version block says; a D marker’s continuity is still honoured
NON_ASCII_HEADER_FIELD a header field carries bytes outside printable ASCII nothing is lost; the text is decoded as Latin-1 and raw keeps the bytes
NUMERIC_FIELD_NOT_LEFT_JUSTIFIED a numeric field is right-justified where the spec asks for left nothing; the value is used as read

Dates and identification

code what it means what to do
DATE_CLIPPED_TO_1985_2084 a two-digit year was resolved through the 85–99 / 00–84 rule for an unambiguous year read startTime.recordingIdDate, which EDF+ spells out in four digits
DATE_FIELDS_DISAGREE the header dd.mm.yy field and the EDF+ Startdate subfield name different days both stay on header.startTime; dateSource says which one was used
DATE_UNPARSEABLE neither field yielded a calendar date startTime.clock is still exact and every elapsed time is unaffected; only formatStartTimeNaive has nothing to return
PATIENT_ID_NONCONFORMANT the patient field does not follow the EDF+ four-subfield grammar header.patient keeps every subfield that could be read plus the raw text; nothing about the samples changes
RECORDING_ID_NONCONFORMANT the recording field does not follow the EDF+ grammar as above; a Startdate that could not be read leaves the two-digit year as the only date source

Signals

code what it means what to do
DUPLICATE_SIGNAL_LABEL two or more signals share a label getSignal by label throws EdfAmbiguousChannelError; use findSignals, or select by index
DIGITAL_RANGE_EXCEEDS_FORMAT the declared digital range exceeds what the sample width can represent the range is used for scaling exactly as written — edfcore never clamps — so expect physical values that extrapolate beyond the declared physical range
ZERO_SAMPLES_PER_RECORD a signal declares 0 samples per record the signal is still exposed with sampleCount 0 and a zero-length block; the other signals’ offsets skip it exactly
ZERO_RECORD_DURATION the record duration is 0 legal in EDF+ for an annotations-only recording; sampleRateHz is undefined for every signal, so index by samplesPerRecord and never divide by the duration
ANNOTATION_SIGNAL_HEADER_NONCONFORMANT an annotation signal’s header fields deviate from the EDF+ requirements the annotations are read anyway; the header block of an annotations channel describes a text region, not measurements
MISSING_EDFPLUS_MARKER an EDF Annotations signal exists with no EDF+ marker in the reserved field the annotations are parsed anyway and the channel is never exposed as an ordinary signal

Annotations and timekeeping

code what it means what to do
TIMEKEEPING_TAL_MISSING a data record carries no timekeeping TAL that record’s onset was derived as start + recordIndex * duration; the diagnostic names the record, so the derivation is never invisible
TIMEKEEPING_TAL_NONCONFORMANT the timekeeping TAL deviates, typically the widespread +t 0x14 0x00 shorthand the onset was used as the record’s start and the file was kept; reported once per call, not once per record
START_OFFSET_OUT_OF_RANGE record 0’s sub-second start offset fell outside [0, 1) the value was used as written; compare it with the header starttime, because a writer that encodes the start time twice produces exactly this
TAL_MALFORMED a TAL did not match the grammar that TAL is skipped and the rest of the region and the file are kept; hexdump at the reported offset
TAL_TRUNCATED_AT_REGION_END a TAL ran past the end of its annotation region the TAL is discarded; check the writer’s samplesPerRecord for that annotation signal, which is usually too small
TAL_REGION_NOT_NUL_TERMINATED the region tail after the last TAL was not NUL padding parsing resumes at those bytes; trailing bytes from an earlier, longer record mean the writer reused a buffer without clearing it
ANNOTATION_TEXT_NOT_UTF8 annotation text was not valid UTF-8 the affected annotations report textEncoding: 'latin-1-fallback', so you can re-decode the bytes yourself if the writer used another code page

Timeline

code what it means what to do
RECORD_ONSET_SPACING_VIOLATION consecutive record onsets are closer together than the record duration, i.e. records overlap in time the onsets were used as written and nothing was reordered; buildRecordIndex reports which records are involved
DISCONTINUITY_IN_CONTINUOUS_FILE the reserved field says EDF+C but the onsets do not line up treat the file as discontinuous: build a complete index, and readWindow then returns one chunk per contiguous run instead of crossing a gap

I/O

code what it means what to do
SOURCE_SHORT_READ_RECOVERED reserved for a ByteSource that returned fewer bytes than asked and was retried successfully nothing; see the note below
HTTP_RANGE_IGNORED an HTTP server answered 200 OK instead of 206 Partial Content serve the file from an origin or CDN that supports byte ranges, or pass allowFullDownload: true to fetch it once and serve reads from memory

Note Those last two are in the vocabulary but are not emitted as diagnostics by the 0.1 release. HTTP_RANGE_IGNORED is named inside the message of the EdfSourceError that httpSource throws when a server ignores the Range header. With allowFullDownload: true the download proceeds and nothing is reported. SOURCE_SHORT_READ_RECOVERED has no emitting call site at all: there is no retry, and a short read throws EdfSourceError instead. Do not write a handler that waits for either code.

Info

Two codes. The file is correct; the note exists because the situation surprises people.

code what it means what to do
INVERTED_PHYSICAL_RANGE physicalMinimum > physicalMaximum, which encodes a negative amplifier gain nothing; it is sanctioned by the EDF FAQ, and swapping the two inverts the signal’s polarity
NEGATIVE_ANNOTATION_ONSET an annotation onset is negative, i.e. before the file start nothing; this is how EDF+ writes a pre-stimulus event, and onsetTicks is exact and signed

The code union

type EdfKnownDiagnosticCode = 'NOT_AN_EDF_FILE' | 'SOURCE_TOO_SMALL' | /* ... */;
type EdfDiagnosticCode = EdfKnownDiagnosticCode | (string & {});

EdfDiagnosticCode is open. Known codes autocomplete, and a default branch in your switch stays mandatory. A code added in a later minor release can’t break exhaustive handling, so the vocabulary can grow without a major version. An unrecognised code is treated as a warning, so an unknown note never escalates into an error.

Codes outside the core vocabulary already exist, and none of them can ever be fatal:

code emitted by what it means
INSPECTION_FAILED inspectEdf an EdfError that was not a format error stopped the inspection; the message names what was refused
HEADER_EXCEEDS_INSPECTION_BUDGET inspectEdf the declared header is larger than the 128 KiB ceiling triage reads; call readHeader or openEdf, which read the whole header however large
SCALE_UNAVAILABLE toPhysical a signal has no scale and matches none of the four known conditions
LABEL_CONVENTION_NONCONFORMANT edfcore/validate the label is not the EDF+ "<type> <sensor>" form
PREFILTERING_NONCONFORMANT edfcore/validate the prefiltering field is not written as HP:/LP:/N:/G: terms
TRANSDUCER_TYPE_BLANK edfcore/validate a data signal leaves the transducer field empty
DATE_IMPLAUSIBLE edfcore/validate the recording start date is not a real day, or the patient birthdate is after it

Diagnostics are values

A diagnostic is a plain object sitting on whatever produced it, never a log line. Every field of EdfDiagnostic is documented in types. The arrays are:

recording.header.diagnostics;    // header parsing
recording.timeline.diagnostics;  // the record-onset probes openEdf made
chunk.diagnostics;               // the annotation regions inside the records you read
result.diagnostics;              // readAnnotations / decodeAnnotations
inspection.diagnostics;          // inspectEdf
report.diagnostics;              // validateRecording, from edfcore/validate

Each is frozen when it’s attached, so a result you’re holding cannot grow later. formatDiagnostics renders any of them for a human, deterministically enough to snapshot in a test.

Diagnostic volume is bounded by one test: does another occurrence of this code carry information available nowhere else? TIMEKEEPING_TAL_MISSING names a record whose onset had to be derived, so it’s reported per record. NEGATIVE_ANNOTATION_ONSET is a property of the writer, so it’s reported once per call. Defects inside a single annotation region are deduplicated and carry an occurrence count, because a corrupt region can hold thousands of malformed TALs.

Where to go next