Physical values

A chunk hands you the integers the file stores. Turning those into microvolts is a separate call, because the conversion can fail. This page covers the scale, the three unit fields, and the conditions that leave a signal without a gain.

toPhysical

chunk.signals[i].digital is an Int32Array of the values as stored. toPhysical returns a Float64Array in the signal’s declared units:

import { getSignal, readWindow, toPhysical } from 'edfcore';

const fp1 = getSignal(recording.header, 'Fp1');
const [chunk] = await readWindow(recording, {
  startSeconds: 30,
  durationSeconds: 10,
  signalIndices: [fp1.index],
});

const microvolts = toPhysical(fp1, chunk.signals[0].digital);

There’s no { physical: true } option and no fused “read as physical” call. Conversion throws for reasons the read cannot, so the two stay separate.

toPhysical takes any ArrayLike<number>, so it works on a trimmed chunk signal, on a raw decodeDigital result, or on a plain array.

How do I convert EDF digital sample values to microvolts?

Every EDF signal declares four numbers: a physical minimum and maximum in its own units, and a digital minimum and maximum in ADC counts. Those define an affine map, and edfcore builds it as:

bitValue = (physicalMaximum - physicalMinimum) / (digitalMaximum - digitalMinimum)
offset   = physicalMaximum / bitValue - digitalMaximum
physical = bitValue * (offset + digital)

bitValue and offset are precomputed once per signal and published as signal.scale:

fp1.scale;  // { bitValue: 0.015259021896696421, offset: 0.5 }
            // from -500..500 uV over -32768..32767

The EDFlib expression

The form above is EDFlib’s, kept verbatim, so edfcore’s float64 output can be compared bit for bit against pyEDFlib and EDFlib rather than approximately. The textbook expression physicalMinimum + (digital - digitalMinimum) * gain is numerically better (fewer operations away from the endpoints, less cancellation). edfcore does not use it.

The two forms agree at the endpoints and disagree in the last place elsewhere:

digital edfcore (EDFlib form) textbook form
-32768 -500 -500
-1 -0.007629510948348211 -0.007629510948390816
0 0.007629510948348211 0.007629510948333973
32767 500 500

(Digital zero does not map to physical zero here, and that is correct: the digital range −32768..32767 is not symmetric about zero, so neither is the map.)

For this range the two forms produce a different float64 for 37,144 of the 65,536 possible sample values (57 % of them). The largest disagreement is 8.5e-14 µV, which is 5.6e-12 of one quantisation step. That’s about eleven orders of magnitude below the smallest difference the amplifier can express, so it’s invisible in any measurement. It is visible in a golden-value comparison, so a drift in the expression fails the comparison rather than rounding itself into agreement.

Note edfcore is 0.1, and the cross-implementation harness that proves bit-parity against pyEDFlib and MNE hasn’t been built yet. Today the expression is EDFlib’s verbatim and is pinned by tests against fixed golden values. Treat “bit-identical to pyEDFlib” as the design intent, not as a measured claim. The same caution applies to every description of EDFlib’s behaviour on this page: they come from edfcore’s design notes rather than a comparison run in this repository.

Float64, always

The output is a Float64Array, and there’s no Float32 option. Float32 carries 24 significand bits and a BDF sample is a 24-bit integer, so a scaled BDF sample doesn’t fit. On a −500..500 µV BDF channel the float32 rounding error reaches 0.26 of a quantisation step, a quarter of the smallest difference the amplifier can express.

If you need float32 for a GPU buffer, convert at the boundary where you can see the cost.

Units

edfcore does not normalise units. It never turns microvolts into volts, millimetres of mercury into pascals, or anything else. Three fields describe the unit:

field value for a µV channel what it is
signal.raw.physicalDimension "µV " the eight header bytes verbatim, padding included
signal.physicalDimension "µV" the same text with the EDF padding removed, nothing else changed
signal.unit "uV" normalised for comparison: every encoding of micro becomes u

Display physicalDimension. Compare against unit. Micro has several spellings that all mean the same thing (U+00B5 MICRO SIGN, U+03BC GREEK SMALL LETTER MU, and a raw 0xB5 header byte). Latin-1 decodes that byte to the first of them. A reader that string-compares against 'uV' alone will reject files it should accept. unit collapses all of them to u and changes nothing else: case stays meaningful, mV is not MV, and no unit is ever rewritten into another.

Converting is your call, and it’s one line:

const microvolts = toPhysical(fp1, chunk.signals[0].digital);

if (fp1.unit !== 'uV') {
  throw new Error(`expected uV, got ${JSON.stringify(fp1.physicalDimension)}`);
}
const volts = Float64Array.from(microvolts, (v) => v * 1e-6);

Note the check. The factor you write is only correct if the unit is what you assumed, so assert it on any file you didn’t produce.

Negative gain

A signal may declare physicalMinimum > physicalMaximum. That’s how a negative amplifier gain is written, and the EDF FAQ sanctions it. bitValue comes out negative and the map works as written:

// physicalMinimum 500, physicalMaximum -500, over -32768..32767
inverted.scale;  // { bitValue: -0.015259021896696421, offset: 0.5 }

toPhysical(inverted, new Int32Array([-32768, 0, 32767]));
// Float64Array [ 500, -0.007629510948348211, -500 ]

edfcore never swaps the two fields. Swapping them flips the polarity of every sample in the channel. A polarity-flipped EEG is a clinically wrong result that looks completely normal: the traces still have the right amplitude, the right frequency content and the right artifacts.

header.diagnostics carries an INVERTED_PHYSICAL_RANGE entry at info severity naming the signal, the raw bytes, the byte offset, and the spec clause. On a one-signal file it reads:

signal 0 "Inv" declares physicalMinimum 500 greater than physicalMaximum -500
(raw "500     " at byte offset 360). This is legal and encodes a negative amplifier gain
(EDF FAQ Q6), so bitValue comes out negative. Next: nothing to do — edfcore never swaps
the two, because a silent polarity flip is a clinically wrong result that looks normal.

Signals with no scale

signal.scale is EdfScale | undefined. undefined means edfcore found no usable way to compute a gain, and toPhysical on such a signal throws EdfScalingError. The | undefined in the type makes reading the gain without a check a compile error. It does not gate the toPhysical call itself, which takes any EdfSignal and fails at runtime.

Four conditions produce it, checked in this order:

error.code condition reason
DEGENERATE_DIGITAL_RANGE digitalMinimum === digitalMaximum the gain is a division by zero
DEGENERATE_PHYSICAL_RANGE physicalMinimum === physicalMaximum every sample would map to one value
INVERTED_DIGITAL_RANGE digitalMinimum > digitalMaximum no sanctioned meaning, unlike the physical case
LOG_TRANSFORMED_CHANNEL physicalDimension is exactly "Filtered" the samples are log-compressed, so a linear map is wrong by orders of magnitude

The first is the most common header defect in practice. EDFlib substitutes a gain of 1 here and returns ADC counts labelled as microvolts. edfcore sets scale to undefined.

INVERTED_DIGITAL_RANGE throws where INVERTED_PHYSICAL_RANGE is accepted. An inverted physical range has a documented meaning; an inverted digital range does not. Nothing in the file says whether the writer swapped two fields or inverted the samples.

LOG_TRANSFORMED_CHANNEL follows the EDF edffloat convention, where a physical dimension of exactly Filtered marks a logarithmically transformed channel. edfcore detects it and leaves scale undefined rather than applying an inverse transform it can’t verify.

Each condition also appears in header.diagnostics at parse time, at error severity, with the byte offset and the raw field text. You can report the problem before anyone calls toPhysical.

Note A fifth, rarer condition exists: four finite fields whose derived pair is not usable, such as a physical range that underflows or overflows float64 against the digital range. The header reports it as DEGENERATE_PHYSICAL_RANGE. The error thrown later by toPhysical carries SCALE_UNAVAILABLE, because toPhysical re-derives the cause from the signal alone and can’t re-derive this one.

Digital data still works

A signal with no scale is still readable. Its samples are ordinary integers, and only their interpretation in physical units is missing:

import { decodeDigital, getSignal, isEdfError, readRecordBytes } from 'edfcore';

const signal = getSignal(recording.header, 'EMG Chin');
const records = { start: 0, count: 10 };
const bytes = await readRecordBytes(recording.source, recording.header, records);
const digital = decodeDigital(recording.header, bytes, records, signal.index);
// Always works.

try {
  const physical = toPhysical(signal, digital);
} catch (error) {
  if (isEdfError(error) && error.edfErrorKind === 'scaling') {
    // error.code, error.signalIndex, error.label — plot the digital counts and say so.
  } else {
    throw error;
  }
}

Use isEdfError plus edfErrorKind rather than instanceof. instanceof is false across a realm boundary (an iframe, a worker, two copies of the package in one dependency tree). It stops working the day someone adds a web worker.

The thrown message names the signal, the reason, every raw field as written, the spec clause, and what to do next:

[DEGENERATE_DIGITAL_RANGE] signal 0 "D" declares digitalMinimum == digitalMaximum == 0,
which makes the gain a division by zero, so physical units are undefined for it. Raw fields:
digital minimum "0       ", digital maximum "0       ", physical minimum "-500    ",
physical maximum "500     ", physical dimension "uV      ". EDF+ additional specification 5:
"Digital maximum must be larger than Digital minimum". Next: decodeDigital() still works on
this signal; edfcore will not invent a gain.

Out-of-range samples

A file can contain samples outside the digital range its own header declares. edfcore decodes them as they are and converts them as they are. It never clamps on read.

The count is taken in the same pass that decodes:

// A signal declaring -100..100 digital, holding samples at -500 and +500.
const narrow = getSignal(recording.header, 'Narrow');

const [chunk] = await readWindow(recording, {
  startSeconds: 0,
  durationSeconds: 1,
  signalIndices: [narrow.index],
});

chunk.signals[0].digital;                 // Int32Array [ -500, -50, 50, 500 ]
chunk.signals[0].outOfDigitalRangeCount;  // 2

A non-zero count means the declared range is wrong, not that the samples are. The samples are what the amplifier wrote. The header field is a claim about them that has turned out to be false, so the count is the cue to go and look at the header.

For the whole file rather than one window’s tally, validateRecording(recording, { scanSamples: true }) from edfcore/validate fills report.signalStats with the observed digital minimum and maximum per signal. That call reads every record. No window read does it for you.

The comparison uses min and max of the two declared bounds rather than the pair as written. A file with an inverted digital range therefore does not report every sample as out of range.

Reproducing a clamping reader

EDFlib clamps when it loads samples. clampToDigitalRange reproduces that behaviour as an explicit, post-hoc step, for cross-validating edfcore’s output against a reader that does:

import { clampToDigitalRange } from 'edfcore';

const clamped = clampToDigitalRange(narrow, chunk.signals[0].digital);
clamped;                       // Int32Array [ -100, -50, 50, 100 ]

toPhysical(narrow, clamped);   // now matches the clamping reader

Nothing on the read path calls it. It clamps to [min(digMin, digMax), max(digMin, digMax)] rather than to [digMin, digMax]. On an inverted declaration the naive bounds are an empty interval that collapses every sample onto a single value. Like the other allocating primitives, it takes an out array for reuse.

Where to go next

  • Reading signals: selecting channels and turning a time window into samples.
  • Large files: what conversion costs in memory, and the allocation budget.
  • Validation: the conformance sweep, including the observed digital range of every signal across the whole file.