edf2csv 0.7.203 → 0.7.204
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* +<onset>[<0x15><duration>]<0x14><text><0x14>...<0x00>
|
|
9
9
|
*
|
|
10
|
-
* The first TAL of every data record
|
|
11
|
-
*
|
|
10
|
+
* The first TAL of every data record carries that record's start time; that is how an EDF+D
|
|
11
|
+
* file states where each record actually sits in time. It may carry event text after that,
|
|
12
|
+
* which the specification allows and writers use, so an unreadable one costs a position and
|
|
13
|
+
* whatever events went with it — see `malformedTimekeepingWithText`.
|
|
12
14
|
*
|
|
13
15
|
* +1.25<0x15>0.5<0x14>Seizure onset<0x14><0x00>
|
|
14
16
|
*/
|
package/dist/edf/annotations.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* +<onset>[<0x15><duration>]<0x14><text><0x14>...<0x00>
|
|
9
9
|
*
|
|
10
|
-
* The first TAL of every data record
|
|
11
|
-
*
|
|
10
|
+
* The first TAL of every data record carries that record's start time; that is how an EDF+D
|
|
11
|
+
* file states where each record actually sits in time. It may carry event text after that,
|
|
12
|
+
* which the specification allows and writers use, so an unreadable one costs a position and
|
|
13
|
+
* whatever events went with it — see `malformedTimekeepingWithText`.
|
|
12
14
|
*
|
|
13
15
|
* +1.25<0x15>0.5<0x14>Seizure onset<0x14><0x00>
|
|
14
16
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"annotations.js","sourceRoot":"","sources":["../../src/edf/annotations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAExC,MAAM,QAAQ,GAAG,IAAI,CAAC,CAAC,yDAAyD;AAChF,MAAM,YAAY,GAAG,IAAI,CAAC,CAAC,gCAAgC;AAC3D,MAAM,OAAO,GAAG,IAAI,CAAC;AAErB,MAAM,aAAa,GAAG,MAAM,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;AACpD,MAAM,iBAAiB,GAAG,MAAM,CAAC,YAAY,CAAC,YAAY,CAAC,CAAC;AAE5D;;;;;;;;;;;;;;GAcG;AACH,MAAM,gBAAgB,GAAG,kDAAkD,CAAC;AAmE5E;;;;;;GAMG;AACH,MAAM,UAAU,uBAAuB,CACrC,KAAiB,EACjB,WAAmB,EACnB,kBAAkB,GAAG,IAAI;IAEzB,MAAM,WAAW,GAAiB,EAAE,CAAC;IACrC,IAAI,WAAW,GAAkB,IAAI,CAAC;IACtC,IAAI,UAAU,GAAG,IAAI,CAAC;IACtB,IAAI,SAAS,GAAG,CAAC,CAAC;IAClB,IAAI,oBAAoB,GAAG,CAAC,CAAC;IAC7B,IAAI,4BAA4B,GAAG,CAAC,CAAC;IACrC,IAAI,mBAAmB,GAAG,CAAC,CAAC;IAC5B,IAAI,iBAAiB,GAAG,CAAC,CAAC;IAE1B,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACvC,IAAI,CAAC,KAAK,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,OAAO;YAAE,SAAS;QAEzD,IAAI,CAAC,GAAG,KAAK,EAAE,CAAC;YACd,MAAM,KAAK,GAAG,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;YACvC;;;;;;;;;;;;cAYE;YACF,IAAI,KAAK,CAAC,KAAK,CAAC,aAAa,CAAC,EAAE,CAAC;gBAC/B,KAAK,GAAG,CAAC,GAAG,CAAC,CAAC;gBACd,SAAS;YACX,CAAC;YACD,MAAM,MAAM,GAAG,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC;YAE5C,+EAA+E;YAC/E,mFAAmF;YACnF,+EAA+E;YAC/E,iFAAiF;YACjF,+EAA+E;YAC/E,uEAAuE;YACvE;;;;;;;;;cASE;YACF,MAAM,aAAa,GAAG,UAAU,IAAI,kBAAkB,CAAC;YACvD,UAAU,GAAG,KAAK,CAAC;YAEnB,IAAI,MAAM,EAAE,CAAC;gBACX,IAAI,aAAa;oBAAE,WAAW,GAAG,MAAM,CAAC,KAAK,CAAC;gBAC9C,KAAK,MAAM,UAAU,IAAI,MAAM,CAAC,WAAW;oBAAE,WAAW,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;gBAC1E,mBAAmB,IAAI,MAAM,CAAC,mBAAmB,CAAC;gBAClD,iBAAiB,IAAI,MAAM,CAAC,iBAAiB,CAAC;YAChD,CAAC;iBAAM,CAAC;gBACN;;;;;;;;;;;;;;;;kBAgBE;gBACF,IAAI,aAAa,EAAE,CAAC;oBAClB,oBAAoB,EAAE,CAAC;oBACvB,IAAI,qBAAqB,CAAC,KAAK,CAAC,EAAE,CAAC;wBACjC,4BAA4B,EAAE,CAAC;wBAC/B,SAAS,EAAE,CAAC;oBACd,CAAC;gBACH,CAAC;;oBAAM,SAAS,EAAE,CAAC;YACrB,CAAC;QACH,CAAC;QACD,KAAK,GAAG,CAAC,GAAG,CAAC,CAAC;IAChB,CAAC;IAED,OAAO;QACL,WAAW;QACX,WAAW;QACX,SAAS;QACT,oBAAoB;QACpB,4BAA4B;QAC5B,mBAAmB;QACnB,iBAAiB;KAClB,CAAC;AACJ,CAAC;AAWD;;;;;;GAMG;AACH,SAAS,qBAAqB,CAAC,KAAiB;IAC9C,IAAI,cAAc,GAAG,KAAK,CAAC;IAC3B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YAClB,cAAc,GAAG,IAAI,CAAC;YACtB,SAAS;QACX,CAAC;QACD,IAAI,cAAc,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;IAC1D,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,iFAAiF;AACjF,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,CAAC;AAC3F,CAAC;AAED,SAAS,QAAQ,CAAC,KAAiB,EAAE,WAAmB;IACtD,mEAAmE;IACnE,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;IACvB,IAAI,KAAK,KAAK,IAAI,CAAC,OAAO,IAAI,KAAK,KAAK,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAElE,MAAM,IAAI,GAAG,UAAU,CAAC,KAAK,CAAC,CAAC;IAC/B,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC;IACxC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAE5B,IAAI,SAAS,GAAG,IAAI,CAAC;IACrB,IAAI,YAAY,GAAkB,IAAI,CAAC;IACvC,MAAM,WAAW,GAAG,IAAI,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACpD,IAAI,WAAW,IAAI,CAAC,EAAE,CAAC;QACrB,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;QACvC,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,GAAG,CAAC,CAAC,CAAC;IAC7C,CAAC;IAED,MAAM,KAAK,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC;IAChC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAEzC;;;;;;;;;;MAUE;IACF;;;;;;;;;;;;;MAaE;IACF,IAAI,QAAQ,GAAkB,IAAI,CAAC;IACnC,IAAI,kBAAkB,GAAG,KAAK,CAAC;IAC/B,IAAI,YAAY,KAAK,IAAI,IAAI,YAAY,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QACxD,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,EAAE,CAAC;QACnC,MAAM,CAAC,GAAG,gBAAgB,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;QAC/D,IAAI,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,QAAQ,GAAG,CAAC,CAAC;;YAChC,kBAAkB,GAAG,IAAI,CAAC;IACjC,CAAC;IAED;;;;;;;;;MASE;IACF,MAAM,gBAAgB,GAAG,QAAQ,KAAK,IAAI,IAAI,QAAQ,GAAG,CAAC,CAAC;IAE3D,MAAM,WAAW,GAAiB,EAAE,CAAC;IACrC,KAAK,MAAM,GAAG,IAAI,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;QACjC;;;;;;;;;;;;;;UAcE;QACF,IAAI,CAAC,GAAG,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;YAAE,SAAS;QACpE,WAAW,CAAC,IAAI,CAAC;YACf,KAAK;YACL,QAAQ;YACR,IAAI,EAAE,GAAG;YACT,WAAW;YACX,GAAG,CAAC,kBAAkB,CAAC,CAAC,CAAC,EAAE,kBAAkB,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC5D,CAAC,CAAC;IACL,CAAC;IAED,yFAAyF;IACzF,+CAA+C;IAC/C,OAAO;QACL,KAAK;QACL,WAAW;QACX,mBAAmB,EAAE,kBAAkB,CAAC,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QAChE,iBAAiB,EAAE,gBAAgB,CAAC,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;KAC7D,CAAC;AACJ,CAAC;AAED,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,OAAO,EAAE,CAAC","sourcesContent":["/**\n * EDF+ annotation (TAL) decoding.\n *\n * The annotations channel stores UTF-8 text in place of samples. Its bytes are a\n * run of Time-stamped Annotation Lists, each terminated by a NUL, with the rest\n * of the channel NUL-padded:\n *\n * +<onset>[<0x15><duration>]<0x14><text><0x14>...<0x00>\n *\n * The first TAL of every data record must carry that record's start time and no\n * text; that is how an EDF+D file states where each record actually sits in time.\n *\n * +1.25<0x15>0.5<0x14>Seizure onset<0x14><0x00>\n */\n\nimport { decodeText } from './bytes.js';\n\nconst SEP_TEXT = 0x14; // separates onset/duration from text, and text from text\nconst SEP_DURATION = 0x15; // separates onset from duration\nconst TAL_END = 0x00;\n\nconst TEXT_SEP_CHAR = String.fromCharCode(SEP_TEXT);\nconst DURATION_SEP_CHAR = String.fromCharCode(SEP_DURATION);\n\n/**\n * What EDF+ allows a TAL duration to look like, which is less than `Number()` allows.\n *\n * The header parser's DECIMAL_FIELD makes this argument at length and lists where the same\n * mistake had already been fixed: `#0x2` reaching channel 2 through `--channels`,\n * `--decimals 0o5` writing five places, `--jobs 0x10` running sixteen, a physical maximum of\n * `0x64` setting a whole calibration to 100. The annotation duration is the one field it did\n * not reach. `+1<0x15>0x10<0x14>Seizure` was exported with a duration_s of 16 — sixteen\n * seconds no writer wrote, in a column the documentation defines as the length the file\n * stated, exit 0 and no diagnostic. `0b11` is three the same way.\n *\n * A duration this cannot read is already a thing the parser knows how to say: the field is\n * left empty, counted, and reported. Sending these there costs nothing and states the truth,\n * which is that the file wrote something that is not a number of seconds.\n */\nconst DECIMAL_DURATION = /^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$/u;\n\nexport interface Annotation {\n /** Seconds from the start of the recording. */\n onset: number;\n /**\n * Seconds, or null when the TAL stated no duration that could be read.\n *\n * Null covers two cases the file distinguishes and this field does not: a TAL that omitted\n * the duration, and a TAL that stated one which is not a number. They are told apart by\n * `unreadableDurations`, which is what raises the warning; the value itself has nowhere\n * honest to put \"the file said `abc`\".\n */\n duration: number | null;\n text: string;\n /** Index of the data record this annotation was stored in. */\n recordIndex: number;\n /**\n * True when the file stated a duration that could not be read.\n *\n * `duration` is null either way, which is the ambiguity the counts beside it exist to\n * flag — and those counts were of the whole file while `annotations.csv` is filtered to\n * the requested window. A conversion of one second of a recording warned that \"1\n * annotation states a duration that is not a number, so its duration_s cell is empty\"\n * about an event two seconds outside it, and failed `--strict` for it. Carrying the fact\n * on the event lets the count be taken where the window has already been applied.\n */\n durationUnreadable?: boolean;\n}\n\nexport interface DecodedRecordAnnotations {\n /** Record start time in seconds, from the leading timekeeping TAL. */\n recordStart: number | null;\n annotations: Annotation[];\n /** Non-empty chunks that were not valid TALs, so the caller can report them. */\n malformed: number;\n /** Unreadable TALs in first position, which carry a record's start time, not an event. */\n malformedTimekeeping: number;\n /**\n * How many of those also carried event text, and so lost events as well as a position.\n *\n * A TAL in first position holds the record's start time, and may hold events after it — the\n * specification allows both in the one entry, and writers use it. When such a TAL cannot be\n * parsed, both are gone, and counting it only as lost timekeeping let the warning beside it\n * say \"No event was lost\" over a conversion that had just dropped four of them.\n *\n * Counted rather than inferred, because the sentence has to be right in the ordinary case\n * too: a bare timekeeping TAL really does lose no event, and that is nearly all of them.\n */\n malformedTimekeepingWithText: number;\n /**\n * Events kept whose stated duration could not be read.\n *\n * Counted apart again, for the same reason the two above are: the entry was exported and\n * nothing about it is missing except the one field, so calling it an entry that \"could not\n * be exported\" describes a loss that did not happen and hides the one that did.\n */\n unreadableDurations: number;\n /**\n * Events kept whose stated duration is a readable number below zero.\n *\n * Separate from the count above because the value survives: it is written to the CSV as\n * the file gave it, and what is wrong with it is arithmetic rather than parsing.\n */\n negativeDurations: number;\n}\n\n/**\n * Decode one data record's annotation bytes.\n *\n * Malformed TALs are skipped rather than thrown, because a single bad annotation\n * should not cost the user an entire conversion. The count of skipped chunks is\n * returned so the caller can tell the user rather than losing them in silence.\n */\nexport function decodeRecordAnnotations(\n bytes: Uint8Array,\n recordIndex: number,\n carriesTimekeeping = true,\n): DecodedRecordAnnotations {\n const annotations: Annotation[] = [];\n let recordStart: number | null = null;\n let isFirstTal = true;\n let malformed = 0;\n let malformedTimekeeping = 0;\n let malformedTimekeepingWithText = 0;\n let unreadableDurations = 0;\n let negativeDurations = 0;\n\n let start = 0;\n for (let i = 0; i <= bytes.length; i++) {\n if (i !== bytes.length && bytes[i] !== TAL_END) continue;\n\n if (i > start) {\n const chunk = bytes.subarray(start, i);\n /*\n Padding is not a lost annotation.\n\n The spec pads the slot with NUL, which the loop above already skips because it is what\n separates one TAL from the next. Writers pad with spaces instead, and a run of spaces\n after the last TAL is a non-empty chunk — so a file holding one perfectly readable\n event, exported in full, was told \"2 annotation entries were unreadable and could not\n be exported\", one per record. Nothing was lost. Under --strict that is a failed run\n over the whitespace at the end of a slot.\n\n Only whitespace. A chunk of anything else that does not parse is a real loss and is\n still counted, which is the case this warning exists for.\n */\n if (chunk.every(isPaddingByte)) {\n start = i + 1;\n continue;\n }\n const parsed = parseTal(chunk, recordIndex);\n\n // The timekeeping TAL is the one in first POSITION, whether or not it decodes.\n // Clearing this flag only on a successful parse meant that an unreadable first TAL\n // promoted the next ordinary annotation to timekeeping, and its onset silently\n // became the record's start time — shifting every sample in that record. Leaving\n // recordStart null instead is what the caller already handles, with a fallback\n // timestamp and an ANNOTATION_DECODE_FAILED warning naming the record.\n /*\n Only one annotation channel carries a record's start time.\n\n This flagged the first TAL of *every* annotation channel as timekeeping. In a second\n channel the first TAL is an ordinary event — so when one failed to parse, the event\n was dropped and counted as a lost timekeeping entry, which produced the warning\n \"3 data records carry a timekeeping annotation that could not be read\" followed by\n \"No event was lost\". Three events had been lost, and the timekeeping in that file was\n perfectly readable. Both sentences false, about the same three records.\n */\n const isTimekeeping = isFirstTal && carriesTimekeeping;\n isFirstTal = false;\n\n if (parsed) {\n if (isTimekeeping) recordStart = parsed.onset;\n for (const annotation of parsed.annotations) annotations.push(annotation);\n unreadableDurations += parsed.unreadableDurations;\n negativeDurations += parsed.negativeDurations;\n } else {\n /*\n Counted apart from the events, because losing one is a different loss.\n\n A timekeeping TAL is never exported — it says where the record sits, not what\n happened — so counting it among the entries that \"could not be exported\" both\n overstated what was lost from annotations.csv and said nothing about the thing that\n actually went missing, which is a record's position in time. A file with one\n unreadable timekeeping TAL and three perfectly good events reported \"1 annotation\n entry was unreadable and could not be exported\" while exporting all three.\n\n The other direction is just as wrong. A first-position TAL may carry events after\n the start time, and when one of those cannot be parsed the events go with it — so\n counting it only as lost timekeeping produced the opposite false sentence: \"No event\n was lost\", printed over a run whose annotations.csv had gone from six rows to two.\n It is one entry that could not be exported and one record with no position, and it\n is counted as both.\n */\n if (isTimekeeping) {\n malformedTimekeeping++;\n if (carriesAnnotationText(chunk)) {\n malformedTimekeepingWithText++;\n malformed++;\n }\n } else malformed++;\n }\n }\n start = i + 1;\n }\n\n return {\n recordStart,\n annotations,\n malformed,\n malformedTimekeeping,\n malformedTimekeepingWithText,\n unreadableDurations,\n negativeDurations,\n };\n}\n\ninterface ParsedTal {\n onset: number;\n annotations: Annotation[];\n /** How many of those annotations carry a duration the file stated and this could not read. */\n unreadableDurations: number;\n /** How many carry a duration that read as a number below zero. */\n negativeDurations: number;\n}\n\n/**\n * Whether a TAL that could not be parsed still carried event text.\n *\n * A TAL is `onset[<0x15>duration]<0x14>text<0x14>...`, so anything other than padding after\n * the first 0x14 is a description the file meant to export. Read from the raw chunk, since by\n * the time this is asked the parse has already failed and there is no structure to consult.\n */\nfunction carriesAnnotationText(chunk: Uint8Array): boolean {\n let afterSeparator = false;\n for (const byte of chunk) {\n if (byte === 0x14) {\n afterSeparator = true;\n continue;\n }\n if (afterSeparator && !isPaddingByte(byte)) return true;\n }\n return false;\n}\n\n/** Space, tab, CR, LF or NUL — what a writer fills the rest of the slot with. */\nfunction isPaddingByte(byte: number): boolean {\n return byte === 0x20 || byte === 0x09 || byte === 0x0d || byte === 0x0a || byte === 0x00;\n}\n\nfunction parseTal(chunk: Uint8Array, recordIndex: number): ParsedTal | null {\n // The onset must be explicitly signed; anything else is not a TAL.\n const first = chunk[0];\n if (first !== 0x2b /* + */ && first !== 0x2d /* - */) return null;\n\n const text = decodeText(chunk);\n const parts = text.split(TEXT_SEP_CHAR);\n const head = parts[0] ?? '';\n\n let onsetText = head;\n let durationText: string | null = null;\n const durationSep = head.indexOf(DURATION_SEP_CHAR);\n if (durationSep >= 0) {\n onsetText = head.slice(0, durationSep);\n durationText = head.slice(durationSep + 1);\n }\n\n const onset = Number(onsetText);\n if (!Number.isFinite(onset)) return null;\n\n /*\n A duration the file stated and this could not read is not the same as no duration.\n\n Both came out as `null` and so as an empty `duration_s` cell, which the documentation\n defines as meaning the file gave no duration — so an event whose duration was written as\n `abc` was exported as an event with no duration, indistinguishable from one beside it\n that genuinely had none, and nothing anywhere said a field had been dropped. The onset is\n already held to this standard: one that is not a number costs the whole TAL and is\n reported. A duration is one field of an otherwise readable event, so the event is kept —\n but it is counted, and the run says so.\n */\n /*\n Fill in the field is an absent duration, not a zero.\n\n `Number` reads a run of whitespace as 0 — the rule that makes `Number('')` zero, one step\n along — so a TAL whose duration field held nothing but the writer's padding was exported\n with a `duration_s` of `0`, byte-identical to the event beside it whose file really did\n say `0`. An instantaneous event is a claim about the recording, and no writer made it;\n inventing it is the one thing this tool does not do, and it did so in silence, exit 0.\n\n The empty field this condition already declines is the same field without the fill in it,\n so padding takes the same answer: the file stated no duration. `trim` empties exactly the\n strings `Number` would otherwise have swallowed into a zero, so ` 2.5 ` still reads as\n 2.5 and `abc` is still counted as unreadable below.\n */\n let duration: number | null = null;\n let durationUnreadable = false;\n if (durationText !== null && durationText.trim() !== '') {\n const stated = durationText.trim();\n const d = DECIMAL_DURATION.test(stated) ? Number(stated) : NaN;\n if (Number.isFinite(d)) duration = d;\n else durationUnreadable = true;\n }\n\n /*\n A duration is a length of time, and a length below zero is not one.\n\n The value is kept and written as the file gave it — inventing a zero, or dropping it to\n an empty cell, would put a number in annotations.csv that no writer wrote, which is the\n one thing this tool does not do. But it is reported, because everything downstream\n quietly does the wrong thing with it: the recipe this documentation gives for the samples\n an event covers is `onset_s + duration_s`, which for a duration of -3 ends three seconds\n before the event starts and selects nothing at all, with no error anywhere.\n */\n const durationNegative = duration !== null && duration < 0;\n\n const annotations: Annotation[] = [];\n for (const raw of parts.slice(1)) {\n /*\n A trailing separator yields an empty segment; a timekeeping TAL is all empty.\n\n Whitespace counts as empty here, which it did not, and the padding at the end of the\n slot became an event. The chunk loop above already refuses to call a run of spaces a\n lost annotation — but it only sees chunks between NULs, and a writer that leaves its\n last TAL unterminated puts the fill *inside* the chunk, after the final 0x14. Split on\n that separator it is a text segment like any other, and \" \" is not \"\".\n\n A file holding two events exported four rows: `0.5,,Lights off,0` and `0.5,, ,0`,\n twice, sharing the real event's onset, with annotations_written and the run summary\n agreeing with the inflated number and nothing warned. An event whose description is\n genuinely nothing but spaces cannot be told from fill, and inventing rows out of fill\n is the worse of the two answers.\n */\n if ([...raw].every((c) => isPaddingByte(c.charCodeAt(0)))) continue;\n annotations.push({\n onset,\n duration,\n text: raw,\n recordIndex,\n ...(durationUnreadable ? { durationUnreadable: true } : {}),\n });\n }\n\n // Per event rather than per TAL: one TAL may carry several texts, and each becomes a row\n // of annotations.csv with the same cell in it.\n return {\n onset,\n annotations,\n unreadableDurations: durationUnreadable ? annotations.length : 0,\n negativeDurations: durationNegative ? annotations.length : 0,\n };\n}\n\nexport { SEP_TEXT, SEP_DURATION, TAL_END };\n"]}
|
|
1
|
+
{"version":3,"file":"annotations.js","sourceRoot":"","sources":["../../src/edf/annotations.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * EDF+ annotation (TAL) decoding.\n *\n * The annotations channel stores UTF-8 text in place of samples. Its bytes are a\n * run of Time-stamped Annotation Lists, each terminated by a NUL, with the rest\n * of the channel NUL-padded:\n *\n * +<onset>[<0x15><duration>]<0x14><text><0x14>...<0x00>\n *\n * The first TAL of every data record carries that record's start time; that is how an EDF+D\n * file states where each record actually sits in time. It may carry event text after that,\n * which the specification allows and writers use, so an unreadable one costs a position and\n * whatever events went with it — see `malformedTimekeepingWithText`.\n *\n * +1.25<0x15>0.5<0x14>Seizure onset<0x14><0x00>\n */\n\nimport { decodeText } from './bytes.js';\n\nconst SEP_TEXT = 0x14; // separates onset/duration from text, and text from text\nconst SEP_DURATION = 0x15; // separates onset from duration\nconst TAL_END = 0x00;\n\nconst TEXT_SEP_CHAR = String.fromCharCode(SEP_TEXT);\nconst DURATION_SEP_CHAR = String.fromCharCode(SEP_DURATION);\n\n/**\n * What EDF+ allows a TAL duration to look like, which is less than `Number()` allows.\n *\n * The header parser's DECIMAL_FIELD makes this argument at length and lists where the same\n * mistake had already been fixed: `#0x2` reaching channel 2 through `--channels`,\n * `--decimals 0o5` writing five places, `--jobs 0x10` running sixteen, a physical maximum of\n * `0x64` setting a whole calibration to 100. The annotation duration is the one field it did\n * not reach. `+1<0x15>0x10<0x14>Seizure` was exported with a duration_s of 16 — sixteen\n * seconds no writer wrote, in a column the documentation defines as the length the file\n * stated, exit 0 and no diagnostic. `0b11` is three the same way.\n *\n * A duration this cannot read is already a thing the parser knows how to say: the field is\n * left empty, counted, and reported. Sending these there costs nothing and states the truth,\n * which is that the file wrote something that is not a number of seconds.\n */\nconst DECIMAL_DURATION = /^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$/u;\n\nexport interface Annotation {\n /** Seconds from the start of the recording. */\n onset: number;\n /**\n * Seconds, or null when the TAL stated no duration that could be read.\n *\n * Null covers two cases the file distinguishes and this field does not: a TAL that omitted\n * the duration, and a TAL that stated one which is not a number. They are told apart by\n * `unreadableDurations`, which is what raises the warning; the value itself has nowhere\n * honest to put \"the file said `abc`\".\n */\n duration: number | null;\n text: string;\n /** Index of the data record this annotation was stored in. */\n recordIndex: number;\n /**\n * True when the file stated a duration that could not be read.\n *\n * `duration` is null either way, which is the ambiguity the counts beside it exist to\n * flag — and those counts were of the whole file while `annotations.csv` is filtered to\n * the requested window. A conversion of one second of a recording warned that \"1\n * annotation states a duration that is not a number, so its duration_s cell is empty\"\n * about an event two seconds outside it, and failed `--strict` for it. Carrying the fact\n * on the event lets the count be taken where the window has already been applied.\n */\n durationUnreadable?: boolean;\n}\n\nexport interface DecodedRecordAnnotations {\n /** Record start time in seconds, from the leading timekeeping TAL. */\n recordStart: number | null;\n annotations: Annotation[];\n /** Non-empty chunks that were not valid TALs, so the caller can report them. */\n malformed: number;\n /** Unreadable TALs in first position, which carry a record's start time, not an event. */\n malformedTimekeeping: number;\n /**\n * How many of those also carried event text, and so lost events as well as a position.\n *\n * A TAL in first position holds the record's start time, and may hold events after it — the\n * specification allows both in the one entry, and writers use it. When such a TAL cannot be\n * parsed, both are gone, and counting it only as lost timekeeping let the warning beside it\n * say \"No event was lost\" over a conversion that had just dropped four of them.\n *\n * Counted rather than inferred, because the sentence has to be right in the ordinary case\n * too: a bare timekeeping TAL really does lose no event, and that is nearly all of them.\n */\n malformedTimekeepingWithText: number;\n /**\n * Events kept whose stated duration could not be read.\n *\n * Counted apart again, for the same reason the two above are: the entry was exported and\n * nothing about it is missing except the one field, so calling it an entry that \"could not\n * be exported\" describes a loss that did not happen and hides the one that did.\n */\n unreadableDurations: number;\n /**\n * Events kept whose stated duration is a readable number below zero.\n *\n * Separate from the count above because the value survives: it is written to the CSV as\n * the file gave it, and what is wrong with it is arithmetic rather than parsing.\n */\n negativeDurations: number;\n}\n\n/**\n * Decode one data record's annotation bytes.\n *\n * Malformed TALs are skipped rather than thrown, because a single bad annotation\n * should not cost the user an entire conversion. The count of skipped chunks is\n * returned so the caller can tell the user rather than losing them in silence.\n */\nexport function decodeRecordAnnotations(\n bytes: Uint8Array,\n recordIndex: number,\n carriesTimekeeping = true,\n): DecodedRecordAnnotations {\n const annotations: Annotation[] = [];\n let recordStart: number | null = null;\n let isFirstTal = true;\n let malformed = 0;\n let malformedTimekeeping = 0;\n let malformedTimekeepingWithText = 0;\n let unreadableDurations = 0;\n let negativeDurations = 0;\n\n let start = 0;\n for (let i = 0; i <= bytes.length; i++) {\n if (i !== bytes.length && bytes[i] !== TAL_END) continue;\n\n if (i > start) {\n const chunk = bytes.subarray(start, i);\n /*\n Padding is not a lost annotation.\n\n The spec pads the slot with NUL, which the loop above already skips because it is what\n separates one TAL from the next. Writers pad with spaces instead, and a run of spaces\n after the last TAL is a non-empty chunk — so a file holding one perfectly readable\n event, exported in full, was told \"2 annotation entries were unreadable and could not\n be exported\", one per record. Nothing was lost. Under --strict that is a failed run\n over the whitespace at the end of a slot.\n\n Only whitespace. A chunk of anything else that does not parse is a real loss and is\n still counted, which is the case this warning exists for.\n */\n if (chunk.every(isPaddingByte)) {\n start = i + 1;\n continue;\n }\n const parsed = parseTal(chunk, recordIndex);\n\n // The timekeeping TAL is the one in first POSITION, whether or not it decodes.\n // Clearing this flag only on a successful parse meant that an unreadable first TAL\n // promoted the next ordinary annotation to timekeeping, and its onset silently\n // became the record's start time — shifting every sample in that record. Leaving\n // recordStart null instead is what the caller already handles, with a fallback\n // timestamp and an ANNOTATION_DECODE_FAILED warning naming the record.\n /*\n Only one annotation channel carries a record's start time.\n\n This flagged the first TAL of *every* annotation channel as timekeeping. In a second\n channel the first TAL is an ordinary event — so when one failed to parse, the event\n was dropped and counted as a lost timekeeping entry, which produced the warning\n \"3 data records carry a timekeeping annotation that could not be read\" followed by\n \"No event was lost\". Three events had been lost, and the timekeeping in that file was\n perfectly readable. Both sentences false, about the same three records.\n */\n const isTimekeeping = isFirstTal && carriesTimekeeping;\n isFirstTal = false;\n\n if (parsed) {\n if (isTimekeeping) recordStart = parsed.onset;\n for (const annotation of parsed.annotations) annotations.push(annotation);\n unreadableDurations += parsed.unreadableDurations;\n negativeDurations += parsed.negativeDurations;\n } else {\n /*\n Counted apart from the events, because losing one is a different loss.\n\n A timekeeping TAL is never exported — it says where the record sits, not what\n happened — so counting it among the entries that \"could not be exported\" both\n overstated what was lost from annotations.csv and said nothing about the thing that\n actually went missing, which is a record's position in time. A file with one\n unreadable timekeeping TAL and three perfectly good events reported \"1 annotation\n entry was unreadable and could not be exported\" while exporting all three.\n\n The other direction is just as wrong. A first-position TAL may carry events after\n the start time, and when one of those cannot be parsed the events go with it — so\n counting it only as lost timekeeping produced the opposite false sentence: \"No event\n was lost\", printed over a run whose annotations.csv had gone from six rows to two.\n It is one entry that could not be exported and one record with no position, and it\n is counted as both.\n */\n if (isTimekeeping) {\n malformedTimekeeping++;\n if (carriesAnnotationText(chunk)) {\n malformedTimekeepingWithText++;\n malformed++;\n }\n } else malformed++;\n }\n }\n start = i + 1;\n }\n\n return {\n recordStart,\n annotations,\n malformed,\n malformedTimekeeping,\n malformedTimekeepingWithText,\n unreadableDurations,\n negativeDurations,\n };\n}\n\ninterface ParsedTal {\n onset: number;\n annotations: Annotation[];\n /** How many of those annotations carry a duration the file stated and this could not read. */\n unreadableDurations: number;\n /** How many carry a duration that read as a number below zero. */\n negativeDurations: number;\n}\n\n/**\n * Whether a TAL that could not be parsed still carried event text.\n *\n * A TAL is `onset[<0x15>duration]<0x14>text<0x14>...`, so anything other than padding after\n * the first 0x14 is a description the file meant to export. Read from the raw chunk, since by\n * the time this is asked the parse has already failed and there is no structure to consult.\n */\nfunction carriesAnnotationText(chunk: Uint8Array): boolean {\n let afterSeparator = false;\n for (const byte of chunk) {\n if (byte === 0x14) {\n afterSeparator = true;\n continue;\n }\n if (afterSeparator && !isPaddingByte(byte)) return true;\n }\n return false;\n}\n\n/** Space, tab, CR, LF or NUL — what a writer fills the rest of the slot with. */\nfunction isPaddingByte(byte: number): boolean {\n return byte === 0x20 || byte === 0x09 || byte === 0x0d || byte === 0x0a || byte === 0x00;\n}\n\nfunction parseTal(chunk: Uint8Array, recordIndex: number): ParsedTal | null {\n // The onset must be explicitly signed; anything else is not a TAL.\n const first = chunk[0];\n if (first !== 0x2b /* + */ && first !== 0x2d /* - */) return null;\n\n const text = decodeText(chunk);\n const parts = text.split(TEXT_SEP_CHAR);\n const head = parts[0] ?? '';\n\n let onsetText = head;\n let durationText: string | null = null;\n const durationSep = head.indexOf(DURATION_SEP_CHAR);\n if (durationSep >= 0) {\n onsetText = head.slice(0, durationSep);\n durationText = head.slice(durationSep + 1);\n }\n\n const onset = Number(onsetText);\n if (!Number.isFinite(onset)) return null;\n\n /*\n A duration the file stated and this could not read is not the same as no duration.\n\n Both came out as `null` and so as an empty `duration_s` cell, which the documentation\n defines as meaning the file gave no duration — so an event whose duration was written as\n `abc` was exported as an event with no duration, indistinguishable from one beside it\n that genuinely had none, and nothing anywhere said a field had been dropped. The onset is\n already held to this standard: one that is not a number costs the whole TAL and is\n reported. A duration is one field of an otherwise readable event, so the event is kept —\n but it is counted, and the run says so.\n */\n /*\n Fill in the field is an absent duration, not a zero.\n\n `Number` reads a run of whitespace as 0 — the rule that makes `Number('')` zero, one step\n along — so a TAL whose duration field held nothing but the writer's padding was exported\n with a `duration_s` of `0`, byte-identical to the event beside it whose file really did\n say `0`. An instantaneous event is a claim about the recording, and no writer made it;\n inventing it is the one thing this tool does not do, and it did so in silence, exit 0.\n\n The empty field this condition already declines is the same field without the fill in it,\n so padding takes the same answer: the file stated no duration. `trim` empties exactly the\n strings `Number` would otherwise have swallowed into a zero, so ` 2.5 ` still reads as\n 2.5 and `abc` is still counted as unreadable below.\n */\n let duration: number | null = null;\n let durationUnreadable = false;\n if (durationText !== null && durationText.trim() !== '') {\n const stated = durationText.trim();\n const d = DECIMAL_DURATION.test(stated) ? Number(stated) : NaN;\n if (Number.isFinite(d)) duration = d;\n else durationUnreadable = true;\n }\n\n /*\n A duration is a length of time, and a length below zero is not one.\n\n The value is kept and written as the file gave it — inventing a zero, or dropping it to\n an empty cell, would put a number in annotations.csv that no writer wrote, which is the\n one thing this tool does not do. But it is reported, because everything downstream\n quietly does the wrong thing with it: the recipe this documentation gives for the samples\n an event covers is `onset_s + duration_s`, which for a duration of -3 ends three seconds\n before the event starts and selects nothing at all, with no error anywhere.\n */\n const durationNegative = duration !== null && duration < 0;\n\n const annotations: Annotation[] = [];\n for (const raw of parts.slice(1)) {\n /*\n A trailing separator yields an empty segment; a timekeeping TAL is all empty.\n\n Whitespace counts as empty here, which it did not, and the padding at the end of the\n slot became an event. The chunk loop above already refuses to call a run of spaces a\n lost annotation — but it only sees chunks between NULs, and a writer that leaves its\n last TAL unterminated puts the fill *inside* the chunk, after the final 0x14. Split on\n that separator it is a text segment like any other, and \" \" is not \"\".\n\n A file holding two events exported four rows: `0.5,,Lights off,0` and `0.5,, ,0`,\n twice, sharing the real event's onset, with annotations_written and the run summary\n agreeing with the inflated number and nothing warned. An event whose description is\n genuinely nothing but spaces cannot be told from fill, and inventing rows out of fill\n is the worse of the two answers.\n */\n if ([...raw].every((c) => isPaddingByte(c.charCodeAt(0)))) continue;\n annotations.push({\n onset,\n duration,\n text: raw,\n recordIndex,\n ...(durationUnreadable ? { durationUnreadable: true } : {}),\n });\n }\n\n // Per event rather than per TAL: one TAL may carry several texts, and each becomes a row\n // of annotations.csv with the same cell in it.\n return {\n onset,\n annotations,\n unreadableDurations: durationUnreadable ? annotations.length : 0,\n negativeDurations: durationNegative ? annotations.length : 0,\n };\n}\n\nexport { SEP_TEXT, SEP_DURATION, TAL_END };\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "edf2csv",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.204",
|
|
4
4
|
"description": "Convert EDF, EDF+ and BDF biosignal recordings (European Data Format) to CSV from the command line. Local, streaming, and never resamples or alters units.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"edf",
|