edf2csv 0.8.95 → 0.8.96

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.
@@ -277,12 +277,29 @@ export function selectChannels(signals, terms) {
277
277
  // A channel with no label at all gets the column `signal_<index>`, and offering its
278
278
  // label back would be offering `""` — there is nothing to type. Position is the only
279
279
  // way to reach it, and saying so is more use than quoting an empty string twice.
280
+ /*
281
+ And escaped, like the term it is quoted beside.
282
+
283
+ 0.8.92 took `term` through `printable` because a newline in one cut the refusal's
284
+ first line in half; the label in the same sentence went on being handed over raw,
285
+ and a header field carries these bytes as easily as a shell argument does —
286
+ NONPRINTABLE_LABEL exists to say so. A recording labelled `ECG`, newline, `X`
287
+ answered a request for its second column with
288
+
289
+ error: "ECG\x0aX_ch1" is a column name, not a channel name: --channels
290
+ matches the label, which for this channel is "ECG
291
+ X".
292
+
293
+ the escaped form and the raw one in one sentence, and the first line — the one this
294
+ file's own comments keep whole for grepping — ending mid-word. `typeable` below
295
+ already refuses a label carrying these, so this half was the one left.
296
+ */
280
297
  throw new ChannelSelectionError(owner.label === ''
281
298
  ? `"${shownTerm}" is a column name, not a channel name: --channels matches the label, ` +
282
299
  `and this channel has none.\n` +
283
300
  `Use "#${owner.index}" — a channel with no label can only be addressed by position.`
284
301
  : `"${shownTerm}" is a column name, not a channel name: --channels matches the label, ` +
285
- `which for this channel is "${owner.label}".\n` +
302
+ `which for this channel is "${printable(owner.label)}".\n` +
286
303
  (typeable(owner.label) === null
287
304
  ? `Use "#${owner.index}" — ${untypeableBecause(owner.label)}, so position is ` +
288
305
  `the only way to reach it.`
@@ -1 +1 @@
1
- {"version":3,"file":"channels.js","sourceRoot":"","sources":["../../src/convert/channels.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE5D;;;;;GAKG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,QAAQ,CAAC;AAEpC,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IAC9C,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IACtC,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAA6B;IAC5D,4FAA4F;IAC5F,4FAA4F;IAC5F,aAAa,CAAC,OAAO,CAAC,CAAC;IACvB,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,MAAM,CAAC,aAAa;YAAE,SAAS;QACnC,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC9B,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAChD,CAAC;IAED,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;IACxC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,MAAM,CAAC,aAAa;YAAE,SAAS;QACnC,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC9B,MAAM,UAAU,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC;QAC/C,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAC3E,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;MAyBE;IACF,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC;QACvC,MAAM,KAAK,GAAG,IAAI,GAAG,CAAiB,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAC1D,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,MAAM,EAAE;YAAE,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QAE/E,MAAM,QAAQ,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QAC7E,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;YAAE,MAAM;QACjC,KAAK,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,QAAQ;YAAE,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,IAAI,MAAM,KAAK,EAAE,CAAC,CAAC;IAC/E,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,QAAQ,CAAC,MAAiB;IACjC,OAAO,MAAM,CAAC,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,UAAU,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;AACvE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAChC,OAA6B,EAC7B,WAAwC;IAExC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,MAAM,CAAC,aAAa;YAAE,SAAS;QACnC,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC9B,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAChD,CAAC;IACD,OAAO,OAAO,CAAC,MAAM,CACnB,CAAC,MAAM,EAAE,EAAE,CACT,CAAC,MAAM,CAAC,aAAa;QACrB,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC;QACzC,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,QAAQ,CAAC,MAAM,CAAC,CACrD,CAAC;AACJ,CAAC;AAQD;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,OAA6B,EAAE,KAAwB;IACpF;;;;;;;;;MASE;IACF,aAAa,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC;IACnC,sEAAsE;IACtE,aAAa,CAAC,OAAO,CAAC,CAAC;IAEvB,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC;IAC3D,MAAM,OAAO,GAAG,IAAI,GAAG,EAAuB,CAAC;IAC/C,KAAK,MAAM,MAAM,IAAI,UAAU,EAAE,CAAC;QAChC,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;QACvC,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAChC,IAAI,MAAM;YAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;;YAC3B,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IAClC,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,GAAG,EAAqB,CAAC;IAC5C,MAAM,SAAS,GAA6C,EAAE,CAAC;IAC/D;;;;;;;;;;;MAWE;IACF;;;;;;;;MAQE;IACF,MAAM,SAAS,GACb,UAAU,CAAC,MAAM,KAAK,CAAC;QACrB,CAAC,CAAC,iEAAiE;QACnE,CAAC,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC;YACvB,CAAC,CAAC,yCAAyC,UAAU,CAAC,CAAC,CAAC,EAAE,KAAK,IAAI,CAAC,GAAG;YACvE,CAAC,CAAC,oCAAoC,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,GAAG,CAAC;IAE5F,KAAK,MAAM,OAAO,IAAI,KAAK,EAAE,CAAC;QAC5B,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,IAAI,KAAK,EAAE;YAAE,SAAS;QAC1B,yFAAyF;QACzF,yFAAyF;QACzF,MAAM,SAAS,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC;QAElC,2EAA2E;QAC3E,+DAA+D;QAC/D,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;QAChD,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,EAAE,CAAC;YAC/D;;;;;;;;;cASE;YACF,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YAC/B,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAC7B,MAAM,IAAI,qBAAqB,CAC7B,IAAI,SAAS,oEAAoE;oBAC/E,SAAS,CACZ,CAAC;YACJ,CAAC;YACD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;YAC/B,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,KAAK,CAAC,CAAC;YACzD,IAAI,CAAC,MAAM,EAAE,CAAC;gBACZ;;;;;;;;;;;;;;;;;;kBAkBE;gBACF;;;;;;;kBAOE;gBACF,MAAM,WAAW,GACf,UAAU,CAAC,MAAM,KAAK,CAAC;oBACrB,CAAC,CAAC,SAAS;oBACX,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,aAAa,IAAI,CAAC,CAAC,KAAK,KAAK,KAAK,CAAC,CAAC;gBAChE,IAAI,WAAW,EAAE,CAAC;oBAChB,MAAM,IAAI,qBAAqB;oBAC7B,gFAAgF;oBAChF,gFAAgF;oBAChF,+CAA+C;oBAC/C,GAAG,SAAS,6CAA6C,WAAW,CAAC,KAAK,YAAY;wBACpF,0EAA0E;wBAC1E,WAAW;wBACX,8EAA8E;wBAC9E,8DAA8D,CACjE,CAAC;gBACJ,CAAC;gBACD;;;;;;;kBAOE;gBACF,MAAM,IAAI,qBAAqB,CAC7B,0BAA0B,SAAS,MAAM,SAAS,EAAE,CACrD,CAAC;YACJ,CAAC;YACD,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;YACjC,SAAS;QACX,CAAC;QAED,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;QAChD,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACrC;;;;;;;;;;;;;cAaE;YACF,MAAM,OAAO,GAAG,gBAAgB,CAAC,UAAU,CAAC,CAAC;YAC7C,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,CAC3B,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,KAAK,IAAI,CAAC,WAAW,EAAE,CACnF,CAAC;YACF,IAAI,KAAK,EAAE,CAAC;gBACV,oFAAoF;gBACpF,qFAAqF;gBACrF,iFAAiF;gBACjF,MAAM,IAAI,qBAAqB,CAC7B,KAAK,CAAC,KAAK,KAAK,EAAE;oBAChB,CAAC,CAAC,IAAI,SAAS,wEAAwE;wBACrF,8BAA8B;wBAC9B,SAAS,KAAK,CAAC,KAAK,gEAAgE;oBACtF,CAAC,CAAC,IAAI,SAAS,wEAAwE;wBACrF,8BAA8B,KAAK,CAAC,KAAK,MAAM;wBAC/C,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,IAAI;4BAC7B,CAAC,CAAC,SAAS,KAAK,CAAC,KAAK,OAAO,iBAAiB,CAAC,KAAK,CAAC,KAAK,CAAC,mBAAmB;gCAC5E,2BAA2B;4BAC7B,CAAC,CAAC,SAAS,KAAK,CAAC,KAAK,iCAAiC,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG;gCAC7E,uCAAuC,CAAC,CACjD,CAAC;YACJ,CAAC;YACD;;;;;;;;;;cAUE;YACF,MAAM,aAAa,GAAG,OAAO,CAAC,IAAI,CAChC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,aAAa,IAAI,MAAM,CAAC,KAAK,CAAC,WAAW,EAAE,KAAK,IAAI,CAAC,WAAW,EAAE,CACtF,CAAC;YACF;;;;;;;;;;;;;cAaE;YACF,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;YACvC,MAAM,eAAe,GACnB,IAAI,KAAK,EAAE;gBACT,CAAC,CAAC,OAAO,CAAC,IAAI,CACV,CAAC,MAAM,EAAE,EAAE,CACT,MAAM,CAAC,aAAa;oBACpB,MAAM,CAAC,KAAK,KAAK,EAAE;oBACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,CAC9C;gBACH,CAAC,CAAC,SAAS,CAAC;YAChB,MAAM,iBAAiB,GAAG,aAAa,IAAI,eAAe,CAAC;YAC3D,IAAI,iBAAiB,EAAE,CAAC;gBACtB,MAAM,KAAK,GAAG,iBAAiB,CAAC,KAAK,CAAC,WAAW,EAAE,KAAK,IAAI,CAAC,WAAW,EAAE,CAAC;gBAC3E,MAAM,IAAI,qBAAqB,CAC7B,CAAC,KAAK;oBACJ,CAAC,CAAC,IAAI,SAAS,yEAAyE;oBACxF,CAAC,CAAC,8BAA8B,SAAS,qCAAqC;wBAC5E,mCAAmC,iBAAiB,CAAC,KAAK,uBAAuB,CAAC;oBACpF,4DAA4D;oBAC5D,8EAA8E;oBAC9E,8DAA8D,CACjE,CAAC;YACJ,CAAC;YACD;;;;;;;;;;;;;;cAcE;YACF,MAAM,IAAI,qBAAqB,CAC7B,qBAAqB,SAAS,KAAK,IAAI,IAAI;gBACzC,sEAAsE,CACzE,CAAC;QACJ,CAAC;QACD,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC;YAAE,SAAS,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,GAAG,OAAO,CAAC,EAAE,CAAC,CAAC;QACxE,KAAK,MAAM,MAAM,IAAI,OAAO;YAAE,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IACjE,CAAC;IAED,MAAM,QAAQ,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;IACxE,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,qBAAqB,CAAC,4BAA4B,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC;AAC1C,CAAC;AAED;;;;;GAKG;AACH,SAAS,QAAQ,CAAC,IAAY,EAAE,KAAa;IAC3C,MAAM,MAAM,GAAG,IAAI,CAAC,WAAW,EAAE,CAAC;IAClC,MAAM,KAAK,GAAG,KAAK,CAAC,WAAW,EAAE,CAAC;IAClC,OAAO,MAAM,KAAK,EAAE,IAAI,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AACnF,CAAC;AAED,SAAS,KAAK,CAAC,IAAY;IACzB,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC;AAClD,CAAC;AAED,SAAS,OAAO,CAAC,IAAY,EAAE,UAAgC;IAC7D,6EAA6E;IAC7E,iDAAiD;IACjD,MAAM,MAAM,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC5D;;;;;;;;;MASE;IACF,MAAM,MAAM,GAAG,MAAM;SAClB,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC;SAC5D,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,EAAE,IAAI,CAAC,CAAC,QAAQ,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC;SAC1D,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC;IAC3C,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACnC;;;;;;;;;;;;MAYE;IACF;;;;;;;;;;;MAWE;IACF,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,KAAK,EAAE,UAAU,CAAC,GAAG,CAAC,CAAC;IAChG,OAAO,iBAAiB,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC,GAAG,CAAC;AAChD,CAAC;AAED,2FAA2F;AAC3F,SAAS,UAAU,CAAC,KAAa,EAAE,UAAgC;IACjE,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,KAAK,KAAK,CAAC,EAAE,KAAK,IAAI,CAAC,CAAC;AACzE,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,QAAQ,CAAC,KAAa;IACpC,IAAI,KAAK,KAAK,EAAE,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IACrD,IAAI,+BAA+B,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAC7D,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,KAAK,GAAG,CAAC;IACjD,OAAO,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC;AAC/C,CAAC;AAED,yFAAyF;AACzF,SAAS,iBAAiB,CAAC,KAAa;IACtC,IAAI,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,8CAA8C,CAAC;IAC/E,OAAO,2BAA2B,CAAC;AACrC,CAAC","sourcesContent":["/**\n * Channel naming and selection.\n *\n * Two real-world facts drive this module. EDF labels are free text that routinely\n * contain spaces and punctuation (\"EEG Fpz-Cz\"), and they are not guaranteed to be\n * unique — CHB-MIT recordings ship two channels both labelled \"T8-P8\", and some\n * carry a channel labelled \"-\". Labels are therefore preserved verbatim in output,\n * and only disambiguated when the file itself is ambiguous.\n */\n\nimport type { EdfSignal } from '../edf/header.js';\nimport { editDistance } from '../format/distance.js';\nimport { listed } from '../format/list.js';\nimport { printable } from '../format/unprintable.js';\nimport { assertOptions, assertSignals } from './options.js';\n\n/**\n * The name of the column the writer puts in front of the channels, which no channel may take.\n *\n * Exported and used by the writer rather than repeated there, because the whole point of\n * reserving it here is that the two cannot drift apart.\n */\nexport const TIME_COLUMN = 'time_s';\n\nexport class ChannelSelectionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ChannelSelectionError';\n }\n}\n\n/**\n * Column name for a signal.\n *\n * Names are derived from the whole file, not from the current selection, so a given\n * channel always produces the same column regardless of which channels were asked\n * for. Duplicated labels get a `_ch<index>` suffix pointing at their position in\n * the file, which is the only thing that reliably tells them apart.\n */\nexport function buildColumnNames(signals: readonly EdfSignal[]): Map<number, string> {\n // The same check `selectChannels` makes below, for the same reason: this is exported, and a\n // string is iterable, so `buildColumnNames('ECG')` came back as a Map rather than an error.\n assertSignals(signals);\n const counts = new Map<string, number>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n counts.set(base, (counts.get(base) ?? 0) + 1);\n }\n\n const names = new Map<number, string>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n const duplicated = (counts.get(base) ?? 0) > 1;\n names.set(signal.index, duplicated ? `${base}_ch${signal.index}` : base);\n }\n\n /*\n The suffix has to be checked against the file, not only against the label it disambiguates.\n\n Counting collisions on the raw label alone made the suffix a guess: `_ch<index>` is unique\n among the channels sharing that label, and nothing stopped it from landing on a label some\n other channel already had. A file labelled T8, T8, T8_ch0 — all three legal, since EDF\n labels are free text and nothing enforces uniqueness — produced\n\n time_s,T8_ch0,T8_ch1,T8_ch0\n\n two columns with one name, while the warning beside it said the suffix kept them\n \"distinguishable\". channels.csv listed the same name against two signal indices, so the\n join it exists for could not resolve it either, and `df[\"T8_ch0\"]` in pandas or R returns\n one of the two with nothing to say which.\n\n Anything still shared after the first pass takes its own position as well. That is unique\n by construction, so the loop settles immediately in practice; the bound is there because\n a second round could in principle land on yet another literal label.\n\n The time column counts as taken, for the same reason. It is a name this file does not\n supply and the writer does, and a channel labelled `time_s` collided with it in silence:\n the header came out `time_s,time_s,ECG`, channels.csv named the channel's column `time_s`,\n and every read-back the documentation gives — `index_col=\"time_s\"`, `pop(\"time_s\")`,\n `pivot(index=\"time_s\")` — resolves that to one of the two columns without saying which.\n pandas and Python's own `csv.DictReader` resolve it opposite ways round.\n */\n for (let round = 0; round < 8; round++) {\n const taken = new Map<string, number>([[TIME_COLUMN, 1]]);\n for (const name of names.values()) taken.set(name, (taken.get(name) ?? 0) + 1);\n\n const clashing = [...names].filter(([, name]) => (taken.get(name) ?? 0) > 1);\n if (clashing.length === 0) break;\n for (const [index, name] of clashing) names.set(index, `${name}_ch${index}`);\n }\n return names;\n}\n\nfunction baseName(signal: EdfSignal): string {\n return signal.label === '' ? `signal_${signal.index}` : signal.label;\n}\n\n/**\n * Channels whose column name was pushed off their own label to keep the header unique.\n *\n * A channel genuinely labelled `T8_ch0` loses that name when another label's disambiguating\n * suffix wants it, and the resulting column is the one thing in the output that no longer\n * matches the file. Silence there is what made the collision hard to see in the first place:\n * the only warning raised was about the *other* label.\n */\nexport function renamedByCollision(\n signals: readonly EdfSignal[],\n columnNames: ReadonlyMap<number, string>,\n): EdfSignal[] {\n const counts = new Map<string, number>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n counts.set(base, (counts.get(base) ?? 0) + 1);\n }\n return signals.filter(\n (signal) =>\n !signal.isAnnotations &&\n (counts.get(baseName(signal)) ?? 0) === 1 &&\n columnNames.get(signal.index) !== baseName(signal),\n );\n}\n\nexport interface ChannelSelection {\n signals: EdfSignal[];\n /** Labels that matched more than one channel, so the user knows why they got extras. */\n ambiguous: { term: string; matched: EdfSignal[] }[];\n}\n\n/**\n * Resolve a `--channels` specification against the file's signals.\n *\n * Matching is case-insensitive on the exact label, with `#<index>` available to\n * address a specific channel when labels collide. A term that matches nothing is an\n * error rather than a silent omission: quietly dropping a requested channel would\n * hand the user a CSV that is missing data they explicitly asked for.\n */\nexport function selectChannels(signals: readonly EdfSignal[], terms: readonly string[]): ChannelSelection {\n /*\n The same check `buildPlan` makes, made here too, because this is exported on its own.\n\n `assertOptions` covers `convert`; a caller holding a header and calling this directly got\n the shapes it names in full. `'ECG'` was iterated character by character and answered `No\n channel named \"E\". Did you mean \"ECG\"?` — which reads like a real answer about the file —\n and `[1]`, a position written the way a position is written, came back as `TypeError:\n rawTerm.trim is not a function` from the loop below. Both are the argument being the wrong\n shape, which is the case that checker exists for, and both now say so.\n */\n assertOptions({ channels: terms });\n // And the argument in front of it, which that call has never covered.\n assertSignals(signals);\n\n const candidates = signals.filter((s) => !s.isAnnotations);\n const byLabel = new Map<string, EdfSignal[]>();\n for (const signal of candidates) {\n const key = signal.label.toLowerCase();\n const bucket = byLabel.get(key);\n if (bucket) bucket.push(signal);\n else byLabel.set(key, [signal]);\n }\n\n const chosen = new Map<number, EdfSignal>();\n const ambiguous: { term: string; matched: EdfSignal[] }[] = [];\n /*\n A file may have no signal channels at all, and both position errors below assumed it had\n some. `listed([])` is the empty string, so `--channels \"#0\"` on an annotations-only\n recording — one of this tool's own fixtures — was refused with\n\n No channel at position #0. This file has signal channels at .\n\n a sentence that states there are channels and then names none, ending mid-clause. The\n conversion already raises NO_SIGNAL_CHANNELS for this file and says what it is, so the\n fact was known one layer up; the message that a `--channels` user actually reaches was\n the one that did not have it.\n */\n /*\n And one channel is not \"channels\", which is the same sentence one item further on.\n\n The zero case above was given its own wording because \"signal channels at .\" states there\n are some and then names none. A file with exactly one got \"This file has signal channels\n at #0.\" — a plural over a list of one, on the line whose job is to say what may be typed\n instead. Single-channel recordings are ordinary; two of this repository's own fixtures are\n one.\n */\n const positions =\n candidates.length === 0\n ? 'This file has no signal channels; it contains only annotations.'\n : candidates.length === 1\n ? `This file has one signal channel, at #${candidates[0]?.index ?? 0}.`\n : `This file has signal channels at ${listed(candidates.map((s) => `#${s.index}`))}.`;\n\n for (const rawTerm of terms) {\n const term = rawTerm.trim();\n if (term === '') continue;\n // Quoted back into every refusal below, and a `--channels` term is whatever the caller's\n // shell handed over — a newline in one cut the refusal's first line in half. See 0.8.92.\n const shownTerm = printable(term);\n\n // '#N' addresses a channel by position, but a label may literally be \"#5\".\n // A real label always wins, so no channel becomes unreachable.\n const literal = byLabel.get(term.toLowerCase());\n if (term.startsWith('#') && (!literal || literal.length === 0)) {\n /*\n A position must be written in plain digits.\n\n `Number()` was doing the parsing, and it accepts a great deal more than a position:\n `#0x2` reached channel 2 through hex, `#0b1` channel 1 through binary, `#1e0` and\n `#2.0` and `# 2` all landed somewhere, and `#` on its own became `Number('')`, which\n is 0. Every one of them selected a channel and exited 0, so a slip did not fail —\n it quietly converted a different channel than the one asked for, which for this tool\n is the worst way to be wrong.\n */\n const position = term.slice(1);\n if (!/^\\d+$/u.test(position)) {\n throw new ChannelSelectionError(\n `\"${shownTerm}\" is not a channel position: a position is #0, #1, #2 and so on.\\n` +\n positions,\n );\n }\n const index = Number(position);\n const signal = candidates.find((s) => s.index === index);\n if (!signal) {\n /*\n The annotation channel occupies a position, and this denied the position existed.\n\n `candidates` is the signals; the annotation channel is filtered out of it and keeps\n its index in the file — which is what `signal_index` in channels.csv is documented\n as, \"counting the annotation channel if present\", and what `#N` addresses. So on an\n ordinary EDF+ recording of one signal, `--channels \"#1\"` was refused with\n\n No channel at position #1. This file has one signal channel, at #0.\n\n The first sentence is false: #1 is where `EDF Annotations` sits. Asking for the same\n channel by *name* has had the whole explanation since 0.7.x — \"is this recording's\n annotation channel, not a signal ... pass --annotations-only\" — so one tool gave two\n answers to one question depending on how the channel was named, and the answer that\n denied the channel existed is the one reached by the reader who looked its position\n up in channels.csv.\n\n Said here in the words the by-name refusal uses, since it is the same fact.\n */\n /*\n Only on a file that has signal channels to be one of.\n\n On an annotations-only recording every position is the annotation channel, and the\n fact worth having is the one the sentence below already carries: there are no signal\n channels at all, so no position will select anything. Naming the channel at `#0`\n there would answer a narrower question than the reader asked.\n */\n const annotations =\n candidates.length === 0\n ? undefined\n : signals.find((s) => s.isAnnotations && s.index === index);\n if (annotations) {\n throw new ChannelSelectionError(\n // The label quoted the way the by-name refusal quotes it — this channel's label\n // is one of the two the specification reserves, so there is nothing else it can\n // be, and the two sentences stay one sentence.\n `${shownTerm} is this recording's annotation channel (\"${annotations.label}\"), not a ` +\n `signal: it holds event text rather than samples, so it has no column to ` +\n `select.\\n` +\n `Its events are already written to annotations.csv by any conversion of this ` +\n `file — pass --annotations-only for those and no signal data.`,\n );\n }\n /*\n The list of positions is advice, and advice goes on the continuation line.\n\n It sat on the first line here and on the second in the refusal six lines up, which\n is the same sentence about the same thing. The first line is the one a log gets\n grepped for and the one `printableLines` leaves whole at any width, so on a\n 40-channel recording this refusal ran to 111 columns where its neighbour ran to 74.\n */\n throw new ChannelSelectionError(\n `No channel at position ${shownTerm}.\\n${positions}`,\n );\n }\n chosen.set(signal.index, signal);\n continue;\n }\n\n const matched = byLabel.get(term.toLowerCase());\n if (!matched || matched.length === 0) {\n /*\n The one term that is certainly not a typo: a column name.\n\n Matching is on the label, and where a label collides the column gains a `_ch<index>`\n suffix — so `T8-P8_ch1` is a name this tool invented, prints in the COLUMN column of\n --info, writes into channels.csv and puts at the head of signals.csv, and then\n rejects with \"No channel named \"T8-P8_ch1\". Run with --info to list the channels in\n this file\", which is where the user copied it from. The reference documents the trap;\n the message a user actually hits did not.\n\n Answered where it is asked instead, and with the thing that works: `#<index>` selects\n one channel of a colliding pair, which is exactly what someone reaching for the\n suffixed column name wants and the only way to get it.\n */\n const columns = buildColumnNames(candidates);\n const owner = candidates.find(\n (signal) => (columns.get(signal.index) ?? '').toLowerCase() === term.toLowerCase(),\n );\n if (owner) {\n // A channel with no label at all gets the column `signal_<index>`, and offering its\n // label back would be offering `\"\"` — there is nothing to type. Position is the only\n // way to reach it, and saying so is more use than quoting an empty string twice.\n throw new ChannelSelectionError(\n owner.label === ''\n ? `\"${shownTerm}\" is a column name, not a channel name: --channels matches the label, ` +\n `and this channel has none.\\n` +\n `Use \"#${owner.index}\" — a channel with no label can only be addressed by position.`\n : `\"${shownTerm}\" is a column name, not a channel name: --channels matches the label, ` +\n `which for this channel is \"${owner.label}\".\\n` +\n (typeable(owner.label) === null\n ? `Use \"#${owner.index}\" — ${untypeableBecause(owner.label)}, so position is ` +\n `the only way to reach it.`\n : `Use \"#${owner.index}\" to select just this one, or ${typeable(owner.label)} ` +\n `for every channel sharing that label.`),\n );\n }\n /*\n The annotation channel is a channel, and this said the file had none by that name.\n\n `EDF Annotations` is a label the file really carries — the spec reserves it, --info\n counts it on the \"Channels\" line, and it is the name anyone reading about EDF+ meets\n first. Asking for it got \"No channel named \"EDF Annotations\". Run with --info to list\n the channels in this file\", which is false about the file and points at a table that\n does not list it either, so following the advice returns the reader to the same\n message. What they were after is already being written: every conversion of a file\n with this channel writes annotations.csv from it.\n */\n const asAnnotations = signals.find(\n (signal) => signal.isAnnotations && signal.label.toLowerCase() === term.toLowerCase(),\n );\n /*\n And a term that is merely near it, which the paragraph above describes and did not\n cover.\n\n The exact spelling got the whole explanation; one character off got \"No channel named\n \"EDF Annotation\". Run with --info to list the channels in this file\" — which is the\n sentence that comment calls false about the file and a pointer at a table that does\n not list the channel either. `EDF Annotations` on a BDF+ recording is the same miss by\n the prefix, and `--channels annotations` by everything but the noun; all three are how\n somebody who has read about EDF+ actually types it.\n\n Only when no signal label is close, so a suggestion about a real column always wins,\n and by the same distance rule that suggestion uses rather than a second one.\n */\n const hint = suggest(term, candidates);\n const nearAnnotations =\n hint === ''\n ? signals.find(\n (signal) =>\n signal.isAnnotations &&\n signal.label !== '' &&\n nearness(term, signal.label) <= reach(term),\n )\n : undefined;\n const annotationChannel = asAnnotations ?? nearAnnotations;\n if (annotationChannel) {\n const exact = annotationChannel.label.toLowerCase() === term.toLowerCase();\n throw new ChannelSelectionError(\n (exact\n ? `\"${shownTerm}\" is this recording's annotation channel, not a signal: it holds event `\n : `There is no channel named \"${shownTerm}\"; the nearest thing to it is this ` +\n `recording's annotation channel \"${annotationChannel.label}\", which holds event `) +\n `text rather than samples, so it has no column to select.\\n` +\n `Its events are already written to annotations.csv by any conversion of this ` +\n `file — pass --annotations-only for those and no signal data.`,\n );\n }\n /*\n Advice that works in the mode it was given in.\n\n `--info` takes `--channels` too — it is how you check a selection before converting\n with it — and a term that matches nothing is refused there exactly as it is here. So\n `edf2csv rec.edf --info --channels nope` answered:\n\n error: No channel named \"nope\".\n Run with --info to list the channels in this file.\n\n which is the command that just printed it. The listing is what `--info` does; what\n stopped it was the `--channels` beside it. Naming both makes the sentence true of a\n conversion as well, where dropping `--channels` alone would convert the whole file\n rather than list anything.\n */\n throw new ChannelSelectionError(\n `No channel named \"${shownTerm}\".${hint}\\n` +\n `Run with --info and no --channels to list the channels in this file.`,\n );\n }\n if (matched.length > 1) ambiguous.push({ term, matched: [...matched] });\n for (const signal of matched) chosen.set(signal.index, signal);\n }\n\n const selected = [...chosen.values()].sort((a, b) => a.index - b.index);\n if (selected.length === 0) {\n throw new ChannelSelectionError('No channels were selected.');\n }\n return { signals: selected, ambiguous };\n}\n\n/**\n * How far a label is from what was typed, and how far the suggestion reaches.\n *\n * Two functions ask the same question — the \"did you mean\" list, and the annotation channel\n * one branch above it — and a rule with two copies is a rule that will get two answers.\n */\nfunction nearness(term: string, label: string): number {\n const needle = term.toLowerCase();\n const lower = label.toLowerCase();\n return needle !== '' && lower.includes(needle) ? 0 : editDistance(needle, lower);\n}\n\nfunction reach(term: string): number {\n return Math.max(2, Math.floor(term.length / 3));\n}\n\nfunction suggest(term: string, candidates: readonly EdfSignal[]): string {\n // Duplicated labels would otherwise be suggested twice, which reads like two\n // different options while naming the same thing.\n const unique = [...new Set(candidates.map((s) => s.label))];\n /*\n A label that contains the term is the answer, however many edits away it is.\n\n Edit distance charges one edit per character the label has and the term does not, so an\n abbreviation — the commonest way to get this wrong — scores worse the more of the label it\n leaves out. On a recording whose channels are `EEG Fpz-Cz` and `ECG`, `--channels EEG` is\n seven edits from the channel it names and one from the other, and the answer was `Did you\n mean \"ECG\"?`: a suggestion that is retypeable, close, and about the wrong signal. Taking it\n converts a heart trace under the belief it is an EEG, and the run succeeds.\n */\n const scored = unique\n .map((label) => ({ label, distance: nearness(term, label) }))\n .filter((c) => c.label !== '' && c.distance <= reach(term))\n .sort((a, b) => a.distance - b.distance);\n if (scored.length === 0) return '';\n /*\n A suggestion is something to retype, so it has to be retypeable.\n\n `Did you mean \"EEG \"A1\"\"?` collapses in a shell to `EEG A1`, which this then rejects with\n the same sentence and the same suggestion — a loop the reader cannot get out of by doing\n what it says. Same failure the header parser's `--channels` advice was fixed for in\n 0.7.18, and the branch above it here; a label carrying `$` or a backtick is the same thing\n again, since a shell expands both inside double quotes.\n\n Where nothing can be typed — a label with a comma, which --channels splits after the shell\n has finished with it, or one with a control byte in it — the position is offered instead.\n It is not the name they asked about, but it is the answer to what they wanted.\n */\n /*\n Cut to three, and the rest counted rather than dropped.\n\n `.slice(0, 3)` said nothing about what it left. On a recording with channels ECG1 to ECG5,\n `--channels ECG` is one edit from all five and the answer was `Did you mean \"ECG1\", \"ECG2\",\n \"ECG3\"?` — three of five equally good answers, with nothing to say the list was cut. A\n reader has no way to tell ECG4 from a channel that does not exist, and this sentence is the\n only place the tool offers to tell them what does.\n\n Through `listed`, which every other list in a sentence goes through: it counts what it\n leaves, and shows a fourth item rather than hiding it behind a phrase longer than the item.\n */\n const offered = scored.map((c) => typeable(c.label) ?? `\"#${positionOf(c.label, candidates)}\"`);\n return ` Did you mean ${listed(offered, 3)}?`;\n}\n\n/** The first channel carrying this label, for a suggestion that cannot be made by name. */\nfunction positionOf(label: string, candidates: readonly EdfSignal[]): number {\n return candidates.find((signal) => signal.label === label)?.index ?? 0;\n}\n\n/**\n * The label written so that typing it back selects this channel, or null when nothing does.\n *\n * Double quotes wherever they survive, since they also show where the label begins and ends\n * and every documented example is written that way. They do not survive a label containing a\n * quote of their own, and — less obviously — a shell still expands `$`, a backtick and a\n * backslash inside them, so `EEG $ref` would arrive as `EEG ` with nothing said. Those go in\n * single quotes, the one POSIX form with no escapes inside it, where a single quote in the\n * label closes, escapes and reopens.\n *\n * Two labels have no form at all. `--channels` splits its list on commas after the shell has\n * finished quoting, so no quoting reaches a label with one in it; and a control character\n * cannot be typed. Both take a position instead, which is what NONPRINTABLE_LABEL already\n * says for the same two reasons.\n */\nexport function typeable(label: string): string | null {\n if (label === '' || label.includes(',')) return null;\n if (/[\\u0000-\\u001f\\u007f-\\u009f]/u.test(label)) return null;\n if (!/[\"$`\\\\]/u.test(label)) return `\"${label}\"`;\n return `'${label.replaceAll(\"'\", \"'\\\\''\")}'`;\n}\n\n/** Why a label has no typeable form, for the sentence that offers a position instead. */\nfunction untypeableBecause(label: string): string {\n if (label.includes(',')) return 'a comma in the label would read as two names';\n return 'the label cannot be typed';\n}\n"]}
1
+ {"version":3,"file":"channels.js","sourceRoot":"","sources":["../../src/convert/channels.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Channel naming and selection.\n *\n * Two real-world facts drive this module. EDF labels are free text that routinely\n * contain spaces and punctuation (\"EEG Fpz-Cz\"), and they are not guaranteed to be\n * unique — CHB-MIT recordings ship two channels both labelled \"T8-P8\", and some\n * carry a channel labelled \"-\". Labels are therefore preserved verbatim in output,\n * and only disambiguated when the file itself is ambiguous.\n */\n\nimport type { EdfSignal } from '../edf/header.js';\nimport { editDistance } from '../format/distance.js';\nimport { listed } from '../format/list.js';\nimport { printable } from '../format/unprintable.js';\nimport { assertOptions, assertSignals } from './options.js';\n\n/**\n * The name of the column the writer puts in front of the channels, which no channel may take.\n *\n * Exported and used by the writer rather than repeated there, because the whole point of\n * reserving it here is that the two cannot drift apart.\n */\nexport const TIME_COLUMN = 'time_s';\n\nexport class ChannelSelectionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ChannelSelectionError';\n }\n}\n\n/**\n * Column name for a signal.\n *\n * Names are derived from the whole file, not from the current selection, so a given\n * channel always produces the same column regardless of which channels were asked\n * for. Duplicated labels get a `_ch<index>` suffix pointing at their position in\n * the file, which is the only thing that reliably tells them apart.\n */\nexport function buildColumnNames(signals: readonly EdfSignal[]): Map<number, string> {\n // The same check `selectChannels` makes below, for the same reason: this is exported, and a\n // string is iterable, so `buildColumnNames('ECG')` came back as a Map rather than an error.\n assertSignals(signals);\n const counts = new Map<string, number>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n counts.set(base, (counts.get(base) ?? 0) + 1);\n }\n\n const names = new Map<number, string>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n const duplicated = (counts.get(base) ?? 0) > 1;\n names.set(signal.index, duplicated ? `${base}_ch${signal.index}` : base);\n }\n\n /*\n The suffix has to be checked against the file, not only against the label it disambiguates.\n\n Counting collisions on the raw label alone made the suffix a guess: `_ch<index>` is unique\n among the channels sharing that label, and nothing stopped it from landing on a label some\n other channel already had. A file labelled T8, T8, T8_ch0 — all three legal, since EDF\n labels are free text and nothing enforces uniqueness — produced\n\n time_s,T8_ch0,T8_ch1,T8_ch0\n\n two columns with one name, while the warning beside it said the suffix kept them\n \"distinguishable\". channels.csv listed the same name against two signal indices, so the\n join it exists for could not resolve it either, and `df[\"T8_ch0\"]` in pandas or R returns\n one of the two with nothing to say which.\n\n Anything still shared after the first pass takes its own position as well. That is unique\n by construction, so the loop settles immediately in practice; the bound is there because\n a second round could in principle land on yet another literal label.\n\n The time column counts as taken, for the same reason. It is a name this file does not\n supply and the writer does, and a channel labelled `time_s` collided with it in silence:\n the header came out `time_s,time_s,ECG`, channels.csv named the channel's column `time_s`,\n and every read-back the documentation gives — `index_col=\"time_s\"`, `pop(\"time_s\")`,\n `pivot(index=\"time_s\")` — resolves that to one of the two columns without saying which.\n pandas and Python's own `csv.DictReader` resolve it opposite ways round.\n */\n for (let round = 0; round < 8; round++) {\n const taken = new Map<string, number>([[TIME_COLUMN, 1]]);\n for (const name of names.values()) taken.set(name, (taken.get(name) ?? 0) + 1);\n\n const clashing = [...names].filter(([, name]) => (taken.get(name) ?? 0) > 1);\n if (clashing.length === 0) break;\n for (const [index, name] of clashing) names.set(index, `${name}_ch${index}`);\n }\n return names;\n}\n\nfunction baseName(signal: EdfSignal): string {\n return signal.label === '' ? `signal_${signal.index}` : signal.label;\n}\n\n/**\n * Channels whose column name was pushed off their own label to keep the header unique.\n *\n * A channel genuinely labelled `T8_ch0` loses that name when another label's disambiguating\n * suffix wants it, and the resulting column is the one thing in the output that no longer\n * matches the file. Silence there is what made the collision hard to see in the first place:\n * the only warning raised was about the *other* label.\n */\nexport function renamedByCollision(\n signals: readonly EdfSignal[],\n columnNames: ReadonlyMap<number, string>,\n): EdfSignal[] {\n const counts = new Map<string, number>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n counts.set(base, (counts.get(base) ?? 0) + 1);\n }\n return signals.filter(\n (signal) =>\n !signal.isAnnotations &&\n (counts.get(baseName(signal)) ?? 0) === 1 &&\n columnNames.get(signal.index) !== baseName(signal),\n );\n}\n\nexport interface ChannelSelection {\n signals: EdfSignal[];\n /** Labels that matched more than one channel, so the user knows why they got extras. */\n ambiguous: { term: string; matched: EdfSignal[] }[];\n}\n\n/**\n * Resolve a `--channels` specification against the file's signals.\n *\n * Matching is case-insensitive on the exact label, with `#<index>` available to\n * address a specific channel when labels collide. A term that matches nothing is an\n * error rather than a silent omission: quietly dropping a requested channel would\n * hand the user a CSV that is missing data they explicitly asked for.\n */\nexport function selectChannels(signals: readonly EdfSignal[], terms: readonly string[]): ChannelSelection {\n /*\n The same check `buildPlan` makes, made here too, because this is exported on its own.\n\n `assertOptions` covers `convert`; a caller holding a header and calling this directly got\n the shapes it names in full. `'ECG'` was iterated character by character and answered `No\n channel named \"E\". Did you mean \"ECG\"?` — which reads like a real answer about the file —\n and `[1]`, a position written the way a position is written, came back as `TypeError:\n rawTerm.trim is not a function` from the loop below. Both are the argument being the wrong\n shape, which is the case that checker exists for, and both now say so.\n */\n assertOptions({ channels: terms });\n // And the argument in front of it, which that call has never covered.\n assertSignals(signals);\n\n const candidates = signals.filter((s) => !s.isAnnotations);\n const byLabel = new Map<string, EdfSignal[]>();\n for (const signal of candidates) {\n const key = signal.label.toLowerCase();\n const bucket = byLabel.get(key);\n if (bucket) bucket.push(signal);\n else byLabel.set(key, [signal]);\n }\n\n const chosen = new Map<number, EdfSignal>();\n const ambiguous: { term: string; matched: EdfSignal[] }[] = [];\n /*\n A file may have no signal channels at all, and both position errors below assumed it had\n some. `listed([])` is the empty string, so `--channels \"#0\"` on an annotations-only\n recording — one of this tool's own fixtures — was refused with\n\n No channel at position #0. This file has signal channels at .\n\n a sentence that states there are channels and then names none, ending mid-clause. The\n conversion already raises NO_SIGNAL_CHANNELS for this file and says what it is, so the\n fact was known one layer up; the message that a `--channels` user actually reaches was\n the one that did not have it.\n */\n /*\n And one channel is not \"channels\", which is the same sentence one item further on.\n\n The zero case above was given its own wording because \"signal channels at .\" states there\n are some and then names none. A file with exactly one got \"This file has signal channels\n at #0.\" — a plural over a list of one, on the line whose job is to say what may be typed\n instead. Single-channel recordings are ordinary; two of this repository's own fixtures are\n one.\n */\n const positions =\n candidates.length === 0\n ? 'This file has no signal channels; it contains only annotations.'\n : candidates.length === 1\n ? `This file has one signal channel, at #${candidates[0]?.index ?? 0}.`\n : `This file has signal channels at ${listed(candidates.map((s) => `#${s.index}`))}.`;\n\n for (const rawTerm of terms) {\n const term = rawTerm.trim();\n if (term === '') continue;\n // Quoted back into every refusal below, and a `--channels` term is whatever the caller's\n // shell handed over — a newline in one cut the refusal's first line in half. See 0.8.92.\n const shownTerm = printable(term);\n\n // '#N' addresses a channel by position, but a label may literally be \"#5\".\n // A real label always wins, so no channel becomes unreachable.\n const literal = byLabel.get(term.toLowerCase());\n if (term.startsWith('#') && (!literal || literal.length === 0)) {\n /*\n A position must be written in plain digits.\n\n `Number()` was doing the parsing, and it accepts a great deal more than a position:\n `#0x2` reached channel 2 through hex, `#0b1` channel 1 through binary, `#1e0` and\n `#2.0` and `# 2` all landed somewhere, and `#` on its own became `Number('')`, which\n is 0. Every one of them selected a channel and exited 0, so a slip did not fail —\n it quietly converted a different channel than the one asked for, which for this tool\n is the worst way to be wrong.\n */\n const position = term.slice(1);\n if (!/^\\d+$/u.test(position)) {\n throw new ChannelSelectionError(\n `\"${shownTerm}\" is not a channel position: a position is #0, #1, #2 and so on.\\n` +\n positions,\n );\n }\n const index = Number(position);\n const signal = candidates.find((s) => s.index === index);\n if (!signal) {\n /*\n The annotation channel occupies a position, and this denied the position existed.\n\n `candidates` is the signals; the annotation channel is filtered out of it and keeps\n its index in the file — which is what `signal_index` in channels.csv is documented\n as, \"counting the annotation channel if present\", and what `#N` addresses. So on an\n ordinary EDF+ recording of one signal, `--channels \"#1\"` was refused with\n\n No channel at position #1. This file has one signal channel, at #0.\n\n The first sentence is false: #1 is where `EDF Annotations` sits. Asking for the same\n channel by *name* has had the whole explanation since 0.7.x — \"is this recording's\n annotation channel, not a signal ... pass --annotations-only\" — so one tool gave two\n answers to one question depending on how the channel was named, and the answer that\n denied the channel existed is the one reached by the reader who looked its position\n up in channels.csv.\n\n Said here in the words the by-name refusal uses, since it is the same fact.\n */\n /*\n Only on a file that has signal channels to be one of.\n\n On an annotations-only recording every position is the annotation channel, and the\n fact worth having is the one the sentence below already carries: there are no signal\n channels at all, so no position will select anything. Naming the channel at `#0`\n there would answer a narrower question than the reader asked.\n */\n const annotations =\n candidates.length === 0\n ? undefined\n : signals.find((s) => s.isAnnotations && s.index === index);\n if (annotations) {\n throw new ChannelSelectionError(\n // The label quoted the way the by-name refusal quotes it — this channel's label\n // is one of the two the specification reserves, so there is nothing else it can\n // be, and the two sentences stay one sentence.\n `${shownTerm} is this recording's annotation channel (\"${annotations.label}\"), not a ` +\n `signal: it holds event text rather than samples, so it has no column to ` +\n `select.\\n` +\n `Its events are already written to annotations.csv by any conversion of this ` +\n `file — pass --annotations-only for those and no signal data.`,\n );\n }\n /*\n The list of positions is advice, and advice goes on the continuation line.\n\n It sat on the first line here and on the second in the refusal six lines up, which\n is the same sentence about the same thing. The first line is the one a log gets\n grepped for and the one `printableLines` leaves whole at any width, so on a\n 40-channel recording this refusal ran to 111 columns where its neighbour ran to 74.\n */\n throw new ChannelSelectionError(\n `No channel at position ${shownTerm}.\\n${positions}`,\n );\n }\n chosen.set(signal.index, signal);\n continue;\n }\n\n const matched = byLabel.get(term.toLowerCase());\n if (!matched || matched.length === 0) {\n /*\n The one term that is certainly not a typo: a column name.\n\n Matching is on the label, and where a label collides the column gains a `_ch<index>`\n suffix — so `T8-P8_ch1` is a name this tool invented, prints in the COLUMN column of\n --info, writes into channels.csv and puts at the head of signals.csv, and then\n rejects with \"No channel named \"T8-P8_ch1\". Run with --info to list the channels in\n this file\", which is where the user copied it from. The reference documents the trap;\n the message a user actually hits did not.\n\n Answered where it is asked instead, and with the thing that works: `#<index>` selects\n one channel of a colliding pair, which is exactly what someone reaching for the\n suffixed column name wants and the only way to get it.\n */\n const columns = buildColumnNames(candidates);\n const owner = candidates.find(\n (signal) => (columns.get(signal.index) ?? '').toLowerCase() === term.toLowerCase(),\n );\n if (owner) {\n // A channel with no label at all gets the column `signal_<index>`, and offering its\n // label back would be offering `\"\"` — there is nothing to type. Position is the only\n // way to reach it, and saying so is more use than quoting an empty string twice.\n /*\n And escaped, like the term it is quoted beside.\n\n 0.8.92 took `term` through `printable` because a newline in one cut the refusal's\n first line in half; the label in the same sentence went on being handed over raw,\n and a header field carries these bytes as easily as a shell argument does —\n NONPRINTABLE_LABEL exists to say so. A recording labelled `ECG`, newline, `X`\n answered a request for its second column with\n\n error: \"ECG\\x0aX_ch1\" is a column name, not a channel name: --channels\n matches the label, which for this channel is \"ECG\n X\".\n\n the escaped form and the raw one in one sentence, and the first line — the one this\n file's own comments keep whole for grepping — ending mid-word. `typeable` below\n already refuses a label carrying these, so this half was the one left.\n */\n throw new ChannelSelectionError(\n owner.label === ''\n ? `\"${shownTerm}\" is a column name, not a channel name: --channels matches the label, ` +\n `and this channel has none.\\n` +\n `Use \"#${owner.index}\" — a channel with no label can only be addressed by position.`\n : `\"${shownTerm}\" is a column name, not a channel name: --channels matches the label, ` +\n `which for this channel is \"${printable(owner.label)}\".\\n` +\n (typeable(owner.label) === null\n ? `Use \"#${owner.index}\" — ${untypeableBecause(owner.label)}, so position is ` +\n `the only way to reach it.`\n : `Use \"#${owner.index}\" to select just this one, or ${typeable(owner.label)} ` +\n `for every channel sharing that label.`),\n );\n }\n /*\n The annotation channel is a channel, and this said the file had none by that name.\n\n `EDF Annotations` is a label the file really carries — the spec reserves it, --info\n counts it on the \"Channels\" line, and it is the name anyone reading about EDF+ meets\n first. Asking for it got \"No channel named \"EDF Annotations\". Run with --info to list\n the channels in this file\", which is false about the file and points at a table that\n does not list it either, so following the advice returns the reader to the same\n message. What they were after is already being written: every conversion of a file\n with this channel writes annotations.csv from it.\n */\n const asAnnotations = signals.find(\n (signal) => signal.isAnnotations && signal.label.toLowerCase() === term.toLowerCase(),\n );\n /*\n And a term that is merely near it, which the paragraph above describes and did not\n cover.\n\n The exact spelling got the whole explanation; one character off got \"No channel named\n \"EDF Annotation\". Run with --info to list the channels in this file\" — which is the\n sentence that comment calls false about the file and a pointer at a table that does\n not list the channel either. `EDF Annotations` on a BDF+ recording is the same miss by\n the prefix, and `--channels annotations` by everything but the noun; all three are how\n somebody who has read about EDF+ actually types it.\n\n Only when no signal label is close, so a suggestion about a real column always wins,\n and by the same distance rule that suggestion uses rather than a second one.\n */\n const hint = suggest(term, candidates);\n const nearAnnotations =\n hint === ''\n ? signals.find(\n (signal) =>\n signal.isAnnotations &&\n signal.label !== '' &&\n nearness(term, signal.label) <= reach(term),\n )\n : undefined;\n const annotationChannel = asAnnotations ?? nearAnnotations;\n if (annotationChannel) {\n const exact = annotationChannel.label.toLowerCase() === term.toLowerCase();\n throw new ChannelSelectionError(\n (exact\n ? `\"${shownTerm}\" is this recording's annotation channel, not a signal: it holds event `\n : `There is no channel named \"${shownTerm}\"; the nearest thing to it is this ` +\n `recording's annotation channel \"${annotationChannel.label}\", which holds event `) +\n `text rather than samples, so it has no column to select.\\n` +\n `Its events are already written to annotations.csv by any conversion of this ` +\n `file — pass --annotations-only for those and no signal data.`,\n );\n }\n /*\n Advice that works in the mode it was given in.\n\n `--info` takes `--channels` too — it is how you check a selection before converting\n with it — and a term that matches nothing is refused there exactly as it is here. So\n `edf2csv rec.edf --info --channels nope` answered:\n\n error: No channel named \"nope\".\n Run with --info to list the channels in this file.\n\n which is the command that just printed it. The listing is what `--info` does; what\n stopped it was the `--channels` beside it. Naming both makes the sentence true of a\n conversion as well, where dropping `--channels` alone would convert the whole file\n rather than list anything.\n */\n throw new ChannelSelectionError(\n `No channel named \"${shownTerm}\".${hint}\\n` +\n `Run with --info and no --channels to list the channels in this file.`,\n );\n }\n if (matched.length > 1) ambiguous.push({ term, matched: [...matched] });\n for (const signal of matched) chosen.set(signal.index, signal);\n }\n\n const selected = [...chosen.values()].sort((a, b) => a.index - b.index);\n if (selected.length === 0) {\n throw new ChannelSelectionError('No channels were selected.');\n }\n return { signals: selected, ambiguous };\n}\n\n/**\n * How far a label is from what was typed, and how far the suggestion reaches.\n *\n * Two functions ask the same question — the \"did you mean\" list, and the annotation channel\n * one branch above it — and a rule with two copies is a rule that will get two answers.\n */\nfunction nearness(term: string, label: string): number {\n const needle = term.toLowerCase();\n const lower = label.toLowerCase();\n return needle !== '' && lower.includes(needle) ? 0 : editDistance(needle, lower);\n}\n\nfunction reach(term: string): number {\n return Math.max(2, Math.floor(term.length / 3));\n}\n\nfunction suggest(term: string, candidates: readonly EdfSignal[]): string {\n // Duplicated labels would otherwise be suggested twice, which reads like two\n // different options while naming the same thing.\n const unique = [...new Set(candidates.map((s) => s.label))];\n /*\n A label that contains the term is the answer, however many edits away it is.\n\n Edit distance charges one edit per character the label has and the term does not, so an\n abbreviation — the commonest way to get this wrong — scores worse the more of the label it\n leaves out. On a recording whose channels are `EEG Fpz-Cz` and `ECG`, `--channels EEG` is\n seven edits from the channel it names and one from the other, and the answer was `Did you\n mean \"ECG\"?`: a suggestion that is retypeable, close, and about the wrong signal. Taking it\n converts a heart trace under the belief it is an EEG, and the run succeeds.\n */\n const scored = unique\n .map((label) => ({ label, distance: nearness(term, label) }))\n .filter((c) => c.label !== '' && c.distance <= reach(term))\n .sort((a, b) => a.distance - b.distance);\n if (scored.length === 0) return '';\n /*\n A suggestion is something to retype, so it has to be retypeable.\n\n `Did you mean \"EEG \"A1\"\"?` collapses in a shell to `EEG A1`, which this then rejects with\n the same sentence and the same suggestion — a loop the reader cannot get out of by doing\n what it says. Same failure the header parser's `--channels` advice was fixed for in\n 0.7.18, and the branch above it here; a label carrying `$` or a backtick is the same thing\n again, since a shell expands both inside double quotes.\n\n Where nothing can be typed — a label with a comma, which --channels splits after the shell\n has finished with it, or one with a control byte in it — the position is offered instead.\n It is not the name they asked about, but it is the answer to what they wanted.\n */\n /*\n Cut to three, and the rest counted rather than dropped.\n\n `.slice(0, 3)` said nothing about what it left. On a recording with channels ECG1 to ECG5,\n `--channels ECG` is one edit from all five and the answer was `Did you mean \"ECG1\", \"ECG2\",\n \"ECG3\"?` — three of five equally good answers, with nothing to say the list was cut. A\n reader has no way to tell ECG4 from a channel that does not exist, and this sentence is the\n only place the tool offers to tell them what does.\n\n Through `listed`, which every other list in a sentence goes through: it counts what it\n leaves, and shows a fourth item rather than hiding it behind a phrase longer than the item.\n */\n const offered = scored.map((c) => typeable(c.label) ?? `\"#${positionOf(c.label, candidates)}\"`);\n return ` Did you mean ${listed(offered, 3)}?`;\n}\n\n/** The first channel carrying this label, for a suggestion that cannot be made by name. */\nfunction positionOf(label: string, candidates: readonly EdfSignal[]): number {\n return candidates.find((signal) => signal.label === label)?.index ?? 0;\n}\n\n/**\n * The label written so that typing it back selects this channel, or null when nothing does.\n *\n * Double quotes wherever they survive, since they also show where the label begins and ends\n * and every documented example is written that way. They do not survive a label containing a\n * quote of their own, and — less obviously — a shell still expands `$`, a backtick and a\n * backslash inside them, so `EEG $ref` would arrive as `EEG ` with nothing said. Those go in\n * single quotes, the one POSIX form with no escapes inside it, where a single quote in the\n * label closes, escapes and reopens.\n *\n * Two labels have no form at all. `--channels` splits its list on commas after the shell has\n * finished quoting, so no quoting reaches a label with one in it; and a control character\n * cannot be typed. Both take a position instead, which is what NONPRINTABLE_LABEL already\n * says for the same two reasons.\n */\nexport function typeable(label: string): string | null {\n if (label === '' || label.includes(',')) return null;\n if (/[\\u0000-\\u001f\\u007f-\\u009f]/u.test(label)) return null;\n if (!/[\"$`\\\\]/u.test(label)) return `\"${label}\"`;\n return `'${label.replaceAll(\"'\", \"'\\\\''\")}'`;\n}\n\n/** Why a label has no typeable form, for the sentence that offers a position instead. */\nfunction untypeableBecause(label: string): string {\n if (label.includes(',')) return 'a comma in the label would read as two names';\n return 'the label cannot be typed';\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edf2csv",
3
- "version": "0.8.95",
3
+ "version": "0.8.96",
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",