WFDB-x

A fast, lightweight implementation of the WFDB library and command-line tools, with identical behavior across seven programming languages

View the Project on GitHub alphanumericslab/wfdb-x

Known behaviors of the original C implementation

WFDB-x reproduces the WFDB Software Package 10.7.0 (C) exactly, including behaviors that may look surprising. This document lists the notable ones, so that users (and implementers of new language ports) know they are intentional, and says for each one whether the official documentation describes it.

Sources checked: the WFDB Applications Guide (third_party/wag.pdf, built from the man pages in doc/wag-src/ of the C sources), the WFDB Programmer’s Guide (doc/wpg-src/wpg0.tex, cited as WPG), and comments in the C sources. Where the documentation and the C code disagree, WFDB-x follows the code.

The deliberate exceptions (no built-in signal parameters, files found only where they are named, in-process annotation sorting that loses nothing, no output signal file over an input signal file, and exit status 1 after a write error) are described in DIVERGENCES.md.

A few C behaviors are undefined (crashes, infinite loops, reads of uninitialized memory). WFDB-x cannot reproduce those; for each one, the behavior chosen by WFDB-x is stated below. The conformance tests check a few of these choices, with reference results recorded from WFDB-x instead of C (the cases with golden_impl in tests/conformance/cases.py); they never run the C tools on such input.

The reference platform is x86-64 Linux with glibc: char is signed, long is 64 bits, and the library is built with -DWFDB_LARGETIME (so WFDB_Time is 64 bits).

Behaviors that depend on the platform

The stored reference results were produced on that platform. These C behaviors can differ elsewhere, and WFDB-x reproduces the reference platform’s:

Documented behaviors (reproduced)

These look surprising but are described in the documentation.

Library

Applications

Undocumented behaviors of the C code (reproduced)

Neither the man pages nor the Programmer’s Guide describe these. Items marked bug are almost certainly unintended by the C authors.

Library

Applications

Converters

Documentation and code disagree

In each case WFDB-x follows the C code.

Topic Documentation C code (and WFDB-x)
wfdbquit WPG: resets “the factors used for converting between samples, seconds, and counter values … and the base counter value (reset to 0)” the counter frequency (setcfreq/getcfreq) and the base counter value are not reset
Annotations at one location WPG (“Annotation Order”): “only the last annotation written to any given location is retained” true only when the file is sorted; repeated annotations at time 0 do not trigger sorting, so all copies are kept
setheader WPG prototype takes const WFDB_Siginfo * modifies units and desc in place (strtok)
setafreq WPG (setafreq): no effect on output annotation files “already open” takes effect for open files until their first putann (as WPG’s 10.6.0 notes say)
sortann sortann(1): automatic sorting is disabled by setting WFDBNOSORT the variable is WFDBANNSORT=0 (WPG: “WFDBNOSORT was replaced by WFDBANNSORT”); WFDBNOSORT is ignored
rr2ann rr2ann(1): empty lines and blanks at the start of a line are ignored an empty or all-blank line repeats the previous interval
wrsamp -c checks that each line has the same number of fields accepted but has no effect
wrsamp -l n reads up to n characters per line (default 1024), truncating longer lines with a warning prints -l is obsolete, ignored; lines of any length are read whole
wrsamp separators default field separators are spaces and tabs; -s c makes c the only separator default separators are space, tab, CR, LF and comma; -s takes a set of characters
edf2mit default record name “use the patient ID field from the input file” the input file name up to its first ., with characters other than letters and digits replaced by _, truncated to 50 characters
wav2mit default record name “the base name of the input file” the input file name without .wav, including any directory part
parsescp byte order .ecg and .dat samples are big-endian little-endian on the reference platform (.ecg: written with native byte order, since SWAP is not defined; .dat: written by the library in format 16)
parsescp missing samples “each missing sample is assigned a special value (WFDB_INVALID_SAMPLE, or -32768)” a missing lead is written as zeros (parsescp.c: “Allocate an array of zero samples to use for the missing lead(s)”); -32768 appears only with -z
revise WFDB_MAXSIG = 32 is the maximum number of signals 32 signals are rejected

Undefined behaviors (not reproduced)

Where the documentation states what should happen, the last column says whether the WFDB-x choice matches it.

Program / function C behavior WFDB-x behavior
time strings whose value is out of the range of WFDB_Time (e.g. 1e300 seconds), or frame numbers (fN) when no record is open (frame frequency 0) strtim converts an out-of-range double (infinite or NaN for fN) to WFDB_Time LONG_MIN, as the x86-64 conversion gives ([...] times: the subtraction of the base time wraps around, as WFDB_Time arithmetic does on x86-64; the MATLAB/Octave and R ports hold times in doubles, exact up to 2^53)
isgsettime(g, t) with a group number g that is not that of a signal group, when the input pointer is already at t (this also happens inside isigsettime when opening a segment of a multi-segment record leaves fewer signal groups, e.g. a zero-length first segment followed by segments with fewer signal files) isgsetframe reads igd[g] past the end of its array before checking g (crash, or a return value of 0) the group number is reported as incorrect (isgsettime: incorrect signal group number, -2), as for other times. A group that isigopen allocated but could not open (a segment whose header is valid but none of whose signal files can be read) is not past the end: C reads its zeroed slot, needs no seek and prints nothing, and so does WFDB-x
a multi-segment record whose segment lengths add up to more than the range of WFDB_Time (e.g. a segment length of 99999999999999999999, read by strtol as LONG_MAX) the sum overflows (signed overflow) the sum wraps around as a two’s-complement 64-bit integer, as on x86-64 (the warning about the stated record length shows the wrapped value)
reading a multi-segment record after a segment that could not be opened because its frame is too large (sigmap_init: frame size too large) the signal map of the earlier segment is kept, and sigmap reads past the end of the segment’s input vector (usually 0 is read) a sample beyond the input vector reads as 0
snip when the last frame to copy cannot be read and no frame was read before it (e.g. a segment that cannot be opened) putvec writes the sample vector anyway, which is uninitialized memory (malloc) the vector is initially 0, so 0 is written
mit2edf with a record whose sampling frequency is nan the number of frames per data record is computed by an integer division by zero (SIGFPE) the EDF header is written with no data records, and the exit status is 0
edf2mit with an input file shorter than the 256-byte fixed part of the EDF header the fields that were not read are taken from uninitialized memory: the number of signals and the output differ from run to run edf2mit: incomplete EDF header in FILE, no output files, exit status 2
xform changing the sampling frequency or gain of samples close to the limits of an int (format 32, or large gains) int overflow in the difference of consecutive samples, and conversions of out-of-range doubles to int as on x86-64: the difference wraps around, and out-of-range conversions give INT_MIN
edf2mit with a signal that has 0 samples per data record (sampling frequency 0) the number of samples per frame is the conversion of 0.0/0.0 (NaN) to int; with another signal of nonzero frequency, gcd() loops forever INT_MIN (as on x86-64); the loop is not avoided
reading an EDF file (rdsamp, with or without -f, or any program that reads its samples) whose signals have 0 samples per data record, or a blank field division by zero (SIGFPE) a signal group whose frames hold no samples is at its end at once: no samples are read
a header whose number of signals is negative or larger than the number of signal lines that follow (e.g. n -1 250 10) the count is converted to an unsigned size and allocated (can't allocate (4294967295*8) bytes for hsd, exit status 1, or, where the allocation succeeds, unexpected EOF in header file) init: unexpected EOF in header file for record n, at once, without allocating for the declared count
a header block size of -2147483648 negating INT_MIN overflows, and the result is converted to an unsigned size (can't allocate) the block size is 2147483648, as the negation of a negative block size intends; the read buffer is never larger than the signal file
reading a FLAC signal file (formats 508, 516 and 524) of 3 or more samples per frame whose FLAC block size is not a multiple of them (e.g. format 508x3 with blocks of 1001 samples: the test record flacspf) iflac_samples makes room for one block plus one frame, but the block also completes the frame left unfinished by the previous block: it writes past the end of its buffer (heap corruption; an abort such as free(): invalid next size, or no visible effect) the samples are read correctly
xform -M with an output record whose signals have more samples per frame than the input signals the array of output samples is sized for the input frame: the output frame is written past its end, and the input frame is read past its end (free(): invalid pointer, abort, or a segmentation fault) both arrays have room for every sample; the output samples are those C computes, with 0 for the input samples beyond the input frame
wfdb2mat -r R with a one-character record name reads the byte before the name to build the output name: writes R.mat and R.hea, overwriting the input header the output record is Rm (Rm.mat, Rm.hea), as for longer names
mit2wav with a signal of more than 16 bits of ADC resolution (formats 24 and 32) shifts by a negative amount: on x86-64 the gain multiplier becomes 65536 times too large and the samples are written as 0 the samples are shifted right to 16 bits, and the gain multiplier is 0
edf2mit with a start date or time field that does not hold three numbers (e.g. a NUL byte in the field) the day, month, year, hour, minute or second not read by sscanf is an uninitialized variable 0 is used
rdsamp -c/-X with a signal description or units string containing ' or \ escapify() loops forever or crashes the characters are escaped with a backslash (the evident intent of escapify(); wrsamp, which rdsamp(1) says parses -X output, treats \ as an escape)
wrann input line with no space after column 9 (including fragments of lines longer than 399 bytes) scans past the end of the line buffer (crash or garbage) the remainder of the line is treated as empty
wrann input with a mnemonic longer than 9 characters buffer overflow the full mnemonic is used (matches rdedfann(1): unrecognized mnemonics are copied into the aux field)
wrann description longer than its 256-byte aux buffer buffer overflow the aux string is truncated as for shorter overflows (the intent of the truncation code)
headers whose format field ends with x, : or + reads past the end of the token the empty field is ignored (as if absent; header(5) makes these fields optional)
wrsamp input line ending with a field separator (e.g. trailing space, or CRLF line endings) heap corruption (malloc(): corrupted top size, abort) the trailing separator is ignored (matches wrsamp(1): “no special treatment is required for files containing both carriage returns and linefeeds”)
wrsamp line with fewer fields than selected columns uses a dangling pointer the missing sample is written as invalid (matches wrsamp(1), which records missing and non-numeric entries as invalid samples)
signame -s N where N equals the number of signals reads past the end of an array (segfault) prints [INVALID] (matches signame(1): “If the specified signal does not exist in record, signame outputs [INVALID]”)
sortann replacing a duplicate annotation whose earlier copy is at the head of its list (e.g. two time-0 annotations with a negative num; or an earlier copy that follows a time-0, num 0, chan 0 annotation, which sortann itself drops) reads freed memory (the C program then drops the earlier annotations) the duplicate is replaced and the earlier annotations are kept (matches sortann(1) and WPG: only the last annotation at a location is dropped)
xform -o OREC without -M, where OREC has signals with more than one sample per frame putvec reads one sample per output sample of a frame from an output array that holds only one per signal (reads past its end) the output signals are (re)opened with one sample per frame, as xform does for output signals specified interactively (without -M, xform(1) processes one sample of each signal per frame; with -n or -N, the new header says so)
md2a with a multi-frequency record (including EDF files, in which each signal has as many samples per frame as per data record) putvec reads one sample per output sample of a frame from a local array of WFDB_MAXSIG elements of which getvec sets only the first nsig: the others are uninitialized, and frames of more than WFDB_MAXSIG samples read past the array (crash) the array has one element per output sample of a frame (at least WFDB_MAXSIG), initially zero: each frame holds the getvec samples followed by zeros
snip with -a given more than once the count of annotators is not reset: the names are the arguments that follow the last -a, as many as all the -a options name together, continuing into the following arguments (e.g. -a atr -a qrs -f 10 opens annotators qrs and -f, which fails); past the end of the argument list it reads argv[argc] (a null pointer: crash) and beyond as in C, except that names beyond the last argument are ignored (-t 5 -a atr -a qrs copies qrs only)
edf2mit without -r, with an input file name that has no . and is shorter than 50 characters the terminating NUL becomes _ and the loop reads past the end of the name the record name ends with the file name
wav2mit without -r the record name contains one uninitialized byte the input name without .wav is used (wav2mit(1): “the base name of the input file”)
ahaecg2mit with .txt input each line is read into a 16-byte buffer that is not NUL-terminated and keeps the bytes of earlier lines; sscanf reads past its end (output depends on stack contents) the buffer is emulated, with bytes beyond it treated as NUL (the buffer, the samples and the annotation start from zero for each input file)
ahaecg2mit with an input file name of fewer than 4 characters before the extension the record name is taken from memory before the string the available characters are used
ahaecg2mit -s with a record name of fewer than 2 characters the terminating NUL of the name is incremented, so the name runs into the following memory the name is left unchanged (the documented renaming, n0nn to n2nn and n1nn to n3nn, applies to 4-digit AHA names)
a2m, ad2m without -r, with an input file name ending in / the extension search starts past the end of the (empty) record name the record name is empty
ad2m with a record name of more than 7 characters the signal file name overflows its 12-byte buffer the full name (RECORD.dat) is used
A header that gives no description for a signal, with a record name (or path) longer than about 85 characters readheader writes the default description “record NAME, signal N” into a 101-byte buffer (lib/signal.c), overflowing the heap; the C tools crash (malloc(): invalid next size) the full description is used
a2m, ad2m with a directory as input file (-i DIR) reads forever can't read input file, exit status 2
mit2edf, makeid uninitialized variables in rare cases (e.g. a single annotation) 0 is used
parsescp -v/-x with a section 7 (global measurements) the dump of the remaining bytes of section 7 reads one byte past its end (past the end of the input if section 7 is the last section) bytes beyond the end of the input are read as 0
parsescp with a lead shorter than lead 0 (or than 5000 samples), or whose sample count cannot be allocated reads past the end of the lead’s array, or dereferences a null pointer the missing samples are read as 0, as the C code does for missing leads (parsescp(1) documents -32768 for samples missing from a recording shorter than 10 seconds)
parsescp with lead indices outside the signal array (a section 3 longer than its lead table, whose entries past the declared number of leads set the indices of leads such as I or V2; or indices left over from an earlier section 3 with more leads) reads and writes outside the array of signals (heap corruption, abort) such a lead reads as an array of zero samples, like a missing lead, and its sample count as 0
parsescp with a section 3 after section 5 that gives more leads reads reference beats past the end of their array the missing reference beats are absent (nothing is added to those leads)
parsescp with QRS subtraction zones missing from section 4, or zones (shifted by -s) outside the signal or the template uses uninitialized memory, or writes outside the arrays missing zones are ignored, and samples outside the arrays are skipped
parsescp with no date or time of acquisition, or an invalid month, in section 1; patient ID, referring physician or comments longer than their 30-, 100- or 200-byte buffers null pointer dereference, read outside the month table, or an unterminated string the date or time is left empty, the month is printed as ???, and the strings are truncated

Build-specific values

wfdbversion, wfdbldflags, wfdbcflags and wfdbdefwfdb return strings fixed when the C library is configured and built (installation prefix, compiler flags), and wfdberror (before any error) includes the build date. All ports of WFDB-x return the values of a release build of 10.7.0 installed in /usr/local: "10.7.0", "", "", ". /usr/local/database http://physionet.org/physiobank/database", and "WFDB library version 10.7.0 (Jun 10 2022).\n". wfdbdefwfdbcal is "wfdbcal", as in C.