API: helpers
Everything that sits on top of the reading layer rather than inside it. Each is a convenience over primitives you already have, and each exists because the hand-rolled version is easy to get subtly wrong.
Finding signals and describing the recording
header.signals includes the annotations channel, and forgetting that is the usual way a “select
every EEG channel” filter ends up decoding a TAL region as if it were samples.
import { matchSignals, declaredDurationSeconds, contiguityOf } from 'edfcore';
const eeg = matchSignals(recording.header, /^EEG /);
const byPredicate = matchSignals(recording.header, (s) => s.physicalDimension === 'uV');
matchSignals never returns an annotations channel. A g or y flag on the pattern is harmless:
RegExp.prototype.test is stateful with those flags and would otherwise make each label’s answer
depend on the previous one — returning about half a montage, silently. Both this and
filterAnnotationsByText test against a clone with lastIndex reset, so your own regex object is
never mutated either (fixed in 0.2.21).
getSignal remains the right call for one channel by name — it throws EdfChannelNotFoundError or EdfAmbiguousChannelError rather than
returning undefined; matchSignals returns a family, possibly empty.
declaredDurationSeconds(recording.header); // what the records COVER
recording.timeline.spanSeconds; // first record start to last record end
The two differ on an EDF+D file, and the difference is the gaps: they belong to no record, so they are inside the span and outside the coverage.
contiguityOf(recording.index); // 'contiguous' | 'discontinuous' | 'unknown'
Three answers, not a boolean. openEdf probes two records and does not scan; a probed index has
seen the first and the last and cannot rule out a gap between them. 'unknown' is that state, and
await buildRecordIndex(recording) is what turns it into one of the other two.
import { segmentAt } from 'edfcore';
const index = await buildRecordIndex(recording);
segmentAt(index, 3612.5); // EdfSegment, or undefined if that instant is in a gap
segmentAt is the pure, synchronous form of index.locate(): a binary search over segments the
scan already produced, with no reads. A viewer asking on every mouse move wants this one.
It throws on a probed index rather than returning undefined, because undefined here means
“no records cover this instant” and a probed index cannot say that about anything in the middle of
the file.
On a file whose record duration is zero it returns undefined for every time, and that is
correct rather than a gap in the implementation: records then occupy no time, so each segment’s
half-open interval [start, start) is empty and no instant is inside one. A real sleep-staging
file is shaped exactly like that. Index by record with readRecords instead. Merging “there is a gap here” with “nobody looked” is the one confusion this whole area
of the API exists to prevent.
import { gapAt } from 'edfcore';
const gap = gapAt(index, 3612.5); // EdfGap, or undefined if a record covers that instant
gap?.durationSeconds;
gap?.endSeconds; // when the recording resumes
gapAt is the complement. segmentAt returning undefined tells a viewer there is no data under
the cursor and nothing else; how long the hole is and when data resumes are on the EdfGap.
Exactly one of the two returns a value for any instant strictly inside the recording.
Envelope decimation
A twelve-hour recording at 256 Hz is about eleven million samples per channel. A plot is a thousand pixels wide. Something has to reduce one to the other, and taking every 11,000th sample is the wrong reduction: a spike or a spindle is a handful of samples wide, so subsampling misses it and the trace looks calm exactly where a reader most needs it not to.
readEnvelope keeps the minimum and maximum of each bucket, at two numbers per pixel.
import { openEdf, readEnvelope, toPhysicalEnvelope, getSignal } from 'edfcore';
const recording = await openEdf(source);
const eeg = getSignal(recording.header, 'EEG Fpz-Cz');
const [chunk] = await readEnvelope(recording, {
signalIndices: [eeg.index],
startSeconds: 0,
durationSeconds: recording.timeline.spanSeconds,
buckets: 1000,
});
chunk.signals[0].min;
chunk.signals[0].max;
chunk.signals[0].counts;
The shape mirrors readWindow: an array of chunks, one per contiguous run, empty when the window
selects nothing. Memory is bounded by the record chunk rather than by the window, so an envelope
over a whole recording costs the buckets plus one chunk.
buckets is per run, not per window. Each chunk gets the full count, so a window spanning two
gaps answers a 1000-pixel width with three chunks of 1000, and each run is divided evenly into
them, so the widths differ run to run. On a continuous file that is one chunk of exactly what you
asked for; when the runs have to share one axis, use readEnvelopeAtResolution below.
buckets is clamped to the sample count of the densest signal in the run — asking for more
buckets than there are samples would leave holes that mean nothing.
The argument’s type is EnvelopeSelection: a WindowSelection plus buckets, so everything
readWindow accepts is accepted here.
What comes back
readEnvelope resolves to EdfEnvelopeChunk[], one per contiguous run.
| field | type | what it is |
|---|---|---|
records |
RecordRange |
the records this run covers |
startSeconds, startTicks |
number, bigint |
the run’s start on the recording’s axis; the ticks are exact |
durationSeconds, durationTicks |
number, bigint |
its span, on the same terms as EdfChunk.durationTicks |
bucketCount |
number |
buckets in the grid, filled or not |
secondsPerBucket |
number |
the grid’s resolution |
byteLength |
number |
bytes read to produce it |
signals |
EdfEnvelopeSignal[] |
one per signal you asked for, in that order |
precededByGap |
EdfGap | undefined |
the discontinuity before this run, if any |
diagnostics |
EdfDiagnostic[] |
anything the decode found |
bucketCount is the field to read before indexing, and it is not always the buckets you asked
for. readEnvelope clamps it to the densest signal’s sample count, so a short run returns fewer.
readEnvelopeAtResolution does not clamp: its count is ceil(runTicks / bucketTicks), because
reducing it would shorten the grid rather than coarsen it. A 4-second run of a 2 Hz signal at
0.25 s per bucket reports 16 buckets with 8 of them filled.
Each entry of signals is an EdfEnvelopeSignal:
| field | type | what it is |
|---|---|---|
signalIndex |
number |
which signal, by header index |
min, max |
Int32Array |
digital extremes per bucket — convert with toPhysicalEnvelope, never toPhysical |
counts |
Int32Array |
samples that landed in each bucket; 0 is an ordinary answer |
sampleCount |
number |
total reduced, i.e. the sum of counts |
firstSampleIndex |
number |
index of the first sample in the run, on this signal’s own grid |
startSeconds, startTicks |
number, bigint |
where this signal’s first sample sits |
outOfDigitalRangeCount |
number |
samples outside the header’s declared digital range |
counts is the authoritative answer to whether a bucket holds anything. min and max are
Int32Arrays, so an empty bucket carries a digital 0 there, which is a real sample value —
the next section is about why that matters.
Physical units
Use toPhysicalEnvelope, not toPhysical.
const { min, max } = toPhysicalEnvelope(eeg, chunk.signals[0]);
It returns an EdfPhysicalEnvelope — two Float64Arrays, min and max, and nothing else. It is
a separate type from EdfEnvelopeSignal for the same reason toPhysical is a separate call from
decodeDigital: the digital extremes are what the file holds, and the physical ones are a derived
view that only exists once a signal has a scale.
The affine transform is decreasing when bitValue is negative, which is a spec-sanctioned
arrangement edfcore reports rather than rejects. A decreasing map sends the smallest digital value
to the largest physical one, so mapping min to min would produce an envelope whose lower bound
sits above its upper bound, and a viewer would draw it inside out. toPhysicalEnvelope swaps the
bounds when it has to.
A bucket no sample landed in — counts[i] === 0 — comes back as NaN in both arrays. min
and max are Int32Arrays and cannot hold a sentinel outside the sample range, so an empty bucket
carries a digital 0; through the transform that becomes bitValue * offset, which is mid-scale
for any channel whose declared range is not centred on zero. On a 0..1000 channel it is 500 — a
completely believable reading, drawn as a flat trace across a hole. NaN cannot be mistaken for a
measurement, and plotting libraries break the line at it. counts is unchanged and remains the
authoritative answer to how many samples a bucket holds.
Samples already in hand
import { envelopeOfSamples } from 'edfcore';
const envelope = envelopeOfSamples(chunk.signals[0], 1000);
A resolution instead of a bucket count
readEnvelope takes buckets, which is what a pixel-width knows. A time axis knows seconds per
pixel instead, and converting one to the other by hand is a rounding decision.
import { readEnvelopeAtResolution } from 'edfcore';
const chunks = await readEnvelopeAtResolution(recording, {
signalIndices: [eeg.index],
startSeconds: 0,
durationSeconds: 40,
secondsPerBucket: 30,
});
The bucket count is rounded UP: 40 seconds at 30 seconds per bucket is two buckets, not one.
Rounding down would drop the last ten seconds off the picture entirely. The last bucket of a run
is short by whatever the division left over — 40 s at 30 s per bucket is one full bucket and one
of 10 s — and its counts entry says by how much.
Every chunk of one call has the width you asked for, whatever its own length. A chunk covers one record-aligned contiguous run, and a run is not the window: an EDF+D window spanning a gap gives two runs of different lengths, and even a contiguous window that does not begin on a record boundary gives a run wider than it asked for. Two things had to be right for that:
- The bucket count comes from each run’s own span, not once from the window. Before 0.2.31 one count was handed to every chunk, so a window of 11 s asked at 1 s per bucket came back as 0.27 s per bucket in one chunk and 0.09 s in the other.
- The bucket a sample lands in is decided by when it is, not by dividing the run evenly into that count. Before 0.3.9 it was the latter, so the width still followed the run whenever the span was not a whole multiple of the request: a 100 s run at 30 s per bucket gave four buckets of 25 s while a 60 s run in the same call gave two of 30 s.
Widths that disagree cannot be drawn on one axis, which is the entire reason this function exists
separately from readEnvelope.
A resolution finer than the sample interval is allowed and produces empty buckets, which is the
honest picture: counts[i] is 0 and toPhysicalEnvelope converts them to NaN, which plotting
libraries break the line at. Until 0.3.30 the bucket count was clamped to the sample count, which
is right for readEnvelope — a pixel width — and wrong here, because the count is
ceil(run / secondsPerBucket) and reducing it shortens the grid: a 4 s run of a 2 Hz signal asked
at 0.25 s per bucket came back as 8 buckets covering 2 s, with the whole second half of the run in
the last one. A resolution fine enough to need more buckets than maxMaterializeBytes allows is
refused by name.
Streaming iteration
readWindow returns every chunk at once, which is right for a window you are about to draw and
wrong for a whole recording: the array holds every sample the window covers.
import { streamRecords } from 'edfcore';
for await (const chunk of streamRecords(recording, {
signalIndices: [0, 1],
startSeconds: 0,
durationSeconds: recording.timeline.spanSeconds,
chunkRecords: 256,
})) {
process(chunk);
}
chunkRecords is a count of records, not of bytes or seconds, because the record is the only unit
every signal in an EDF file shares — channels sample at different rates, so “a second of data” is
a different number of samples per channel. It defaults to 256.
The argument’s type is StreamSelection: a WindowSelection plus chunkRecords, which is the
one field that decides peak memory — a chunk’s worth of records rather than the whole window.
Every argument is checked by the CALL, not by the first turn of the loop. streamRecords is a plain
function that validates and returns a generator; until 0.6.118 it was a generator function, whose
body does not run until the first next(), so streamRecords(recording) with no selection at all
returned an object successfully. That matters because the two happen in different places: a pipeline
that builds the stream in one function and consumes it in another got the refusal in the second, and
a stream that was built and then dropped never reported the mistake at all.
signalIndices is validated before the window is resolved, so a non-existent index or the
annotations channel is refused even when the window selects no records at all — the same
EdfChannelNotFoundError readWindow raises. Before 0.2.22 a window past the end, inside an
EDF+D gap, or of zero duration reported a bad channel as “no data here”, which is the wrong
diagnosis. A valid selection over an empty window still yields nothing, silently; that is an
ordinary answer, not an error.
Chunks arrive in time order, never span a gap, and carry the same precededByGap a readWindow
chunk would. They come from readRecords, so a streamed chunk and a read chunk are the same
object in every respect, diagnostics included.
Joining chunks
readWindow splits at every discontinuity, so a window over an EDF+D file arrives as one chunk
per contiguous run. Code that wants one array — a filter, an FFT, a CSV writer — has to join them,
and joining is where the gap gets lost.
import { mergeChunks } from 'edfcore';
const chunks = await readWindow(recording, selection);
const one = mergeChunks(chunks); // throws if they are not joinable
Concatenating two runs five minutes apart produces an array in which sample i and sample i + 1
are five minutes apart, and every time derived from an index past the join is wrong by five
minutes with nothing in the result to say so. mergeChunks throws a RangeError instead — for a
gap, for chunks out of order or not adjacent, for a different signal selection, and for a chunk
already narrowed by trimToWindow. That last one is invisible to a record-adjacency check, which
is why the check is per signal on firstSampleIndex. Trim after merging, not before.
The gap check does not depend on the index. precededByGap is undefined in two different
situations — no gap, and nobody looked — and openEdf returns a probed index that has looked for
none. So readRecords by record number on an EDF+D file could hand two chunks a minute apart to
mergeChunks with that field empty on both. Since 0.2.19 the refusal compares the chunks’ own
startSeconds, in exact ticks: each chunk decoded its onset from its own bytes, so the evidence
never needed an index at all.
A single chunk is returned unchanged, so the continuous case costs no allocation.
Annotation queries
readAnnotations returns every event in a record range. Narrowing it by hand goes wrong in one
specific way: the obvious filter compares onsetSecondsFromFirstRecord, which is float64 seconds
divided out of an exact tick count, so an onset and a bound that should be equal need not compare
equal.
import {
filterAnnotationsByTime,
filterAnnotationsByText,
countAnnotationsByText,
annotationsAt,
} from 'edfcore';
const epoch = filterAnnotationsByTime(annotations, {
startSeconds: 3600,
durationSeconds: 30,
});
const wake = filterAnnotationsByText(annotations, 'Sleep stage W');
const stages = countAnnotationsByText(annotations);
const underCursor = annotationsAt(annotations, 3612.5);
Every comparison is on onsetTicksFromFirstRecord. Windows are half-open, so adjacent windows
partition a recording without double-counting an epoch that ends exactly on the boundary. An
annotation with a duration is returned when it overlaps the window — containment would return
nothing for a window inside a 30-second sleep epoch.
annotationsAt is the instant form a cursor needs. The window form does not work for it: a
zero-length window is a non-positive duration, which filterAnnotationsByTime refuses, so the
obvious call returns nothing at every position.
The window argument is an EdfAnnotationWindow — startSeconds and durationSeconds, and
nothing else. It is a separate type from WindowSelection because there is no reading here and so
no channel to name: these annotations are already in hand, and the call narrows an array. That is
also why it is the one window type in the package that never touches a ByteSource.
Which onset field to compare
An annotation carries its onset four ways, and two of them are exact.
| Field | Axis | Exact |
|---|---|---|
onsetTicks |
header start time | yes |
onsetTicksFromFirstRecord |
start of record 0 | yes |
onsetSecondsFromHeaderStart |
header start time | no — float64 |
onsetSecondsFromFirstRecord |
start of record 0 | no — float64 |
resolveTimeWindow, readWindow and readEnvelope all put t = 0 at the start of record 0, so
onsetTicksFromFirstRecord is the field that lines up with a window you have read. It differs
from onsetTicks by the sub-second start offset a file may declare in record 0’s timekeeping TAL:
identical on a file that declares none, and up to a second apart on one that does.
Before 0.2.10 these queries compared onsetTicks, which put events in the neighbouring window on
exactly the files careful enough to state an offset.
filterAnnotationsByText matches a string exactly, because annotation vocabularies are controlled
and a substring match on W would also catch spellings like W/REM. Pass a RegExp or a
predicate when you want something looser.
Exactly means verbatim on both sides. annotation.text is the TAL’s bytes as written and is never
trimmed, so an event a scorer spelled 'Sleep stage W ' is not matched by 'Sleep stage W' — and
the miss is silent, because the result is an empty list rather than an error. For a file whose
vocabulary carries padding, say so: filterAnnotationsByText(events, (t) => t.trim() === label).
The sample grid
The obvious spelling of “which sample is at 3600 s” is Math.round(seconds * signal.sampleRateHz)
and it breaks three ways.
| Problem | Consequence |
|---|---|
sampleRateHz is samplesPerRecord / recordDurationSeconds |
128 samples over 0.3 s is 426.666… with no exact float representation, and the index drifts by one over a long recording |
sampleRateHz is undefined for a zero record duration |
legal EDF, which a real sleep-staging file relies on — the expression yields NaN silently |
| Rounding rather than flooring | a window boundary lands one sample late |
import { gridSampleIndexAt, gridSampleStartTicks, gridSampleStartSeconds } from 'edfcore';
const { sampleIndex, recordIndex, sampleWithinRecord } = gridSampleIndexAt(
eeg,
3600,
recording.header.recordDurationTicks,
);
const ticks = gridSampleStartTicks(eeg, sampleIndex, recording.header.recordDurationTicks);
These do the arithmetic in integers on (record, sampleWithinRecord) — the same rule
trimToWindow follows — and throw a RangeError for a zero record duration rather than returning
NaN.
Renamed in 0.3.0. These three were
sampleIndexAt,sampleStartTicksandsampleStartSeconds. The behaviour did not change — only the name, which never said which of two different quantities it returns. See Migrating to 0.3 for the find-and-replace, and note that the new names begin withgrid: the recording-aware functions below are a different family, not the rename. On a discontinuous filegridSampleStartSeconds(signal, 12, d)andsampleStartSecondsOf(recording, i, 12)answer3and10for the same sample.
These measure the signal’s own sample grid, not the recording clock. Sample
nis thenth sample the file stores for that signal, atn * recordDuration / samplesPerRecord. On a contiguous recording that is also elapsed recording time and the two are the same number — which is exactly why the difference is easy to miss.On a discontinuous file they part company. Samples are adjacent in the array across a gap while their times are not, so on a file with a seven-second hole after record 2,
gridSampleStartSeconds(signal, 12, d)answers3for a sample whose record truly begins at10, andgridSampleIndexAt(signal, 10, d)names record10of a six-record file. They are handed a signal, a number and a record duration — no index, no timeline — so a gap is not in their arguments and nothing inside them could find it.For a file that may be discontinuous, use the recording-aware pair below instead.
The recording-aware form
import { sampleAt, sampleStartTicksOf, sampleStartSecondsOf } from 'edfcore';
sampleAt(recording, eeg.index, 3612.5); // EdfSampleLocation, or undefined
sampleStartSecondsOf(recording, eeg.index, 940); // when sample 940 actually starts
These take the recording, so a gap is in their arguments and they answer the question people
usually mean. On a contiguous file they agree with the grid functions exactly — a test asserts that
sample by sample. On a discontinuous one they differ by the gaps, and sampleAt can say something
the grid functions structurally cannot: undefined, meaning no sample exists at that instant
because it falls in a hole, or before the recording, or after it. gridSampleIndexAt given only a
signal and a record duration always returns an index — including one past the end of the file.
They refuse a probed index on a file with gaps rather than guessing, for the reason segmentAt
does. index.locate(seconds) remains the read-based form; contiguityOf(index) tells you which
regime you are in.
There is a third regime, and it is the one to know about. Where records overlap — a file whose
timeline earns RECORD_ONSET_SPACING_VIOLATION — two samples start at the same instant, so
sampleAt(sampleStartSecondsOf(recording, i, i)) can answer with a sample that is not i. It is
not wrong and it is not undefined: the sample it names does start at the instant asked for, and
there is no way to choose between two that do. If you are round-tripping an index through an
instant, check contiguityOf(index) first, or compare the instants rather than the indices.
gridSampleStartTicks rounds up to a whole tick. A sample boundary need not fall on one: 128 samples
over 0.3 s puts sample 1 at 23,437.5 ticks, and 100 ns is the finest unit edfcore has. Truncating
would return a tick lying inside the previous sample, and gridSampleIndexAt would send it straight
back there. Rounding up keeps the two functions inverse for every index.
The BioSemi Status channel
A BioSemi ActiveTwo writes BDF files whose last channel is labelled Status. Its 24-bit samples
are not a measurement — they are a bit field the amplifier latched at each sample, and the low 16
bits are the parallel trigger input.
import { getStatusSignal, readTriggers, decodeStatusWord } from 'edfcore';
if (getStatusSignal(recording.header) !== undefined) {
const events = await readTriggers(recording, {
startSeconds: 0,
durationSeconds: recording.timeline.spanSeconds,
});
}
readTriggers reports one event per change of the trigger word, not one per sample: a parallel
trigger is held for as long as the stimulus computer asserts it, so the transition is what carries
the information. A return to 0 is reported too, because the release time is what gives a trigger
its duration.
The argument’s type is TriggerSelection, and it is the one selection in the package with no
signalIndices: readTriggers locates the Status channel itself. That is deliberate rather than
a convenience — a 24-bit EEG sample decoded as a trigger word yields plausible-looking events out
of ordinary data, so there is no way to point this call at the wrong channel.
The two types
readTriggers resolves to EdfTriggerEvent[]; decodeStatusWord returns one EdfStatusWord.
EdfTriggerEvent |
type | what it is |
|---|---|---|
sampleIndex |
number |
Status samples from the start of the file — a position, not a time |
seconds, ticks |
number, bigint |
elapsed recording time; compare with the ticks, never the float |
trigger |
number |
the code now in force, 0 on release |
status |
EdfStatusWord |
the whole word this event was decoded from |
precededByGap |
EdfGap | undefined |
set on the event whose tick is a run’s resume instant, and on no other |
The status field is the one worth knowing about: every event carries its full word, so a rig
that encodes something in the bits above the trigger field is readable without a second pass.
EdfStatusWord |
type | what it is |
|---|---|---|
raw |
number |
all 24 bits, unsigned |
trigger |
number |
the low 16 — the parallel input |
newEpoch |
boolean |
bit 16 |
cmsInRange |
boolean |
bit 20 |
batteryLow |
boolean |
bit 22 |
The bit numbers are not decoration; the table further down says which bits are not these, and that distinction was wrong in shipped code until 0.3.54.
Times and the window
seconds and ticks are elapsed recording time on the same axis as everything else: t = 0 is
the start of record 0, matching chunk.startSeconds and the bounds you pass here. Each event is
timed from its own record’s true onset, so on an EDF+D file the gaps are in the numbers.
sampleIndex is a different quantity — a count of Status samples from the start of the file — and
deriving a time from it would place every post-gap event early by the whole gap. Before 0.2.18 that
is exactly what happened: a stimulus the hardware latched at 10 s was reported at 2 s.
Events outside the window are never returned, even though the scan itself is record-aligned. The
first in-window sample always produces an event carrying the code in force at that instant,
whether or not it is a transition — the same rule a whole-file read follows at t = 0, so an
aligned and an unaligned window behave alike. If you want assertions only, filter on trigger:
const onsets = (await readTriggers(recording, window)).filter((e) => e.trigger !== 0);
A gap is a left edge too. The running trigger state does not survive one, so the first in-window sample of every contiguous run produces an event.
precededByGap — the same EdfGap an EdfChunk carries, and undefined on a probed index for the
same reason — goes on the event whose tick is the run’s resume instant, and on no other. A
window that begins part-way into the first record after a gap therefore yields events and none of
them carries it: the gap precedes the run, not whichever sample the window happened to admit first,
which can be a whole record later.
Until 0.3.13 the state carried across a gap, so a code held before and after a five-minute hole returned a single event and a consumer differencing consecutive events read one 308-second epoch out of eight seconds of recording. The records between two segments do not exist; what the trigger did in between is unknown, and staying silent asserted that it did nothing. A contiguous file has one run, so nothing about it changed.
decodeStatusWord masks the sample back to 24 unsigned bits first. decodeDigital sign-extends
BDF samples, as it must for a measurement, so a Status word with bit 23 set arrives negative.
Only the bits BioSemi documents are named — trigger, newEpoch, cmsInRange, batteryLow.
raw carries all 24, because a wrong trigger code is worse than none.
| Bit | Meaning | Exposed as |
|---|---|---|
| 0–15 | the sixteen parallel trigger inputs | trigger |
| 16 | a new epoch was started | newEpoch |
| 17–19, 21 | speed bits 0–2 and 3 | raw only |
| 20 | CMS is within range | cmsInRange |
| 22 | the battery is low | batteryLow |
| 23 | the amplifier is an ActiveTwo MK2 | raw only |
The flags are not the two bits directly above the trigger field: 17 and 18 are speed bits. Until
0.3.54 cmsInRange read speed bit 0 and batteryLow read speed bit 1, so both quality flags were
wrong in both directions while trigger and newEpoch were right.
This is file access, not analysis: the codes were written by the hardware at acquisition time, exactly like an EDF+ annotation. Nothing here inspects a biosignal.
Text formatters
import { formatHeader } from 'edfcore';
import { formatValidationReport } from 'edfcore/validate';
console.log(formatHeader(recording.header));
console.log(formatValidationReport(report, { header: recording.header }));
formatHeader omits patient identification unless you pass { includePatientId: true }. A header
carries a name and a birth date, and the obvious thing to do with this string is paste it into an
issue or a log, so the default is the safe one. The data is still on header.patient.
Neither formatter invents a value. An unresolved date prints as unknown, and a rate that is
genuinely undefined prints as an em dash rather than 0 Hz or Infinity.
The length line is labelled for what it measures. On a continuous file it is duration. On an
EDF+D or BDF+D file it is covered, and two lines under it say that the gaps are not in the
number and that buildRecordIndex is what reports the span. A header alone cannot know the span —
it is the last record’s onset minus the first’s, and those live in the timekeeping TALs — so a
four-record file with an hour-long hole in it used to print duration 00:00:04 for a recording
that reaches 3604 s.
formatAnnotations is the third formatter, for a hypnogram or an event list:
import { formatAnnotations } from 'edfcore';
console.log(formatAnnotations(annotations, { maxItems: 20 }));
// 00:00:00.000 Sleep stage W
// 08:30:30.000 00:02:00.000 Sleep stage 1
The clock is built from onsetTicksFromFirstRecord by integer division, never from the float
seconds — an event list is exactly where someone reads a number off the screen and types it into
something else, and a millisecond field derived from a float can be off by one. Hours are not
wrapped at 24, because a 30-hour recording is real and 30:12 beats 06:12 on day two. A negative
onset prints as one: EDF+ measures onsets from the header start time, a recording may begin after
its first annotation, and clamping to zero would silently move the event.
formatValidationReport leads with the severity counts and the distinct codes sorted by how much
of the file each affects, then a capped sample of individual entries. A sweep over a damaged file
can produce six figures of diagnostics — TIMEKEEPING_TAL_MISSING is per record — and a wall of
them buries the answer.
The options types
Each formatter’s second argument has a name, exported from the same entry as the function. You need it to build one of these ahead of the call, or to accept one in a wrapper of your own.
FormatHeaderOptions, from edfcore:
| field | default | what it does |
|---|---|---|
includePatientId |
false |
print the identification line. Opt-in, because the obvious thing to do with this string is paste it somewhere |
diagnosticsHint |
true |
append “Call formatDiagnostics(header.diagnostics) for the detail” under the counts. Opt-out, for the caller who is already printing that detail — edfcore header turns it off for exactly that reason |
FormatAnnotationsOptions, from edfcore:
| field | default | what it does |
|---|---|---|
maxItems |
every row | rows to print. The count of the rest is stated either way. A number: Infinity for no cap, and anything that is not a number — a flag or a query parameter still in its text form — is refused rather than coerced |
includeChannel |
false |
show the description@@channel label EDF+ allows. Off because most files carry none |
FormatReportOptions, from edfcore/validate:
| field | default | what it does |
|---|---|---|
header |
none | only used to name signals; a report reads fine without it |
maxItems |
20 |
individual diagnostics to print. The counts above them are the summary. Refused rather than coerced when it is not a number, the same as everywhere else the option appears |
redactFields |
none | forwarded to formatDiagnostics; pass ['patientId', 'recordingId'] before a report leaves your machine — see the redaction note on the CLI reference |
The two defaults point opposite ways on purpose. includePatientId withholds until asked, because
the cost of forgetting is a person’s name in an issue tracker. diagnosticsHint prints until told
not to, because the cost of forgetting is one redundant line.
formatDiagnostics takes FormatDiagnosticsOptions, which is the same shape one level down:
color, maxItems, and the redactFields the report forwards to it.
The options argument itself has to be an object. formatAnnotations(annotations, 20) — the call
written when the intent is twenty rows — has no maxItems on it, so until 0.6.130 the limit read
as absent, the default applied, and every annotation printed with no ... and N more line to give
it away. A bare number is now refused by all three. undefined and null still mean “no options”.
The CLI
The command line is its own page: CLI reference. Everything above is reachable from
it — header, validate, events, signals, gaps and json are these helpers with an exit
code attached.