edf2csv 0.8.98 → 0.9.0
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.
- package/dist/convert/options.js +45 -0
- package/dist/convert/options.js.map +1 -1
- package/dist/edf/scale.js +42 -2
- package/dist/edf/scale.js.map +1 -1
- package/package.json +1 -1
package/dist/convert/options.js
CHANGED
|
@@ -315,6 +315,51 @@ export function assertSignals(signals) {
|
|
|
315
315
|
export function assertPlanInput(input) {
|
|
316
316
|
assertSignals(input.signals);
|
|
317
317
|
assertRecordShape(input);
|
|
318
|
+
assertPlannableSignals(input.signals);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* The two numbers on a channel that `buildPlan` reads and `assertSignals` does not ask about.
|
|
322
|
+
*
|
|
323
|
+
* `assertSignals` asks for `index`, `label` and `isAnnotations`, which is what its other two
|
|
324
|
+
* callers read — they name columns and match terms. `buildPlan` goes further: it groups the
|
|
325
|
+
* channels by `samplingRate` and counts rows from `samplesPerRecord`, and asked nothing of
|
|
326
|
+
* either. So a list of channel-shaped objects carrying the three fields it does check reached
|
|
327
|
+
* the rate formatter and came back
|
|
328
|
+
*
|
|
329
|
+
* buildPlan({ signals: [{ index: 0, label: 'ECG', isAnnotations: false }], … }, {})
|
|
330
|
+
* OptionError: hz must be a sampling rate in hertz, got undefined.
|
|
331
|
+
*
|
|
332
|
+
* naming `hz`, a parameter of a function three calls down, at a caller who passed `signals`.
|
|
333
|
+
* That is the failure `assertSignals` exists to remove, and it is the same one its own
|
|
334
|
+
* docstring quotes for `label`.
|
|
335
|
+
*
|
|
336
|
+
* The values it takes are the ones a header can really state, which is wider than it looks:
|
|
337
|
+
* `samplesPerRecord` may be zero — that is what `NO_SAMPLES` reports — and a rate may be zero
|
|
338
|
+
* or `Infinity`, because a record duration small enough to overflow the division is five
|
|
339
|
+
* characters in an eight-character field. What a header cannot state is a fractional or
|
|
340
|
+
* negative sample count, and those went through as arithmetic:
|
|
341
|
+
*
|
|
342
|
+
* samplesPerRecord: 2.5 // estimate.rows: 394.5, half a row
|
|
343
|
+
* samplesPerRecord: -4 // estimate.rows falls, with nothing said
|
|
344
|
+
*
|
|
345
|
+
* Split out rather than folded into `assertSignals`, for the reason `assertRecordShape` gives
|
|
346
|
+
* one function down: `selectChannels` and `buildColumnNames` never look at either field, and a
|
|
347
|
+
* checker should not demand what its caller does not read.
|
|
348
|
+
*/
|
|
349
|
+
function assertPlannableSignals(signals) {
|
|
350
|
+
for (const [at, signal] of signals.entries()) {
|
|
351
|
+
const rate = signal['samplingRate'];
|
|
352
|
+
if (typeof rate !== 'number' || Number.isNaN(rate) || rate < 0) {
|
|
353
|
+
throw new OptionError(`signals[${at}].samplingRate must be a sampling rate in hertz, got ` +
|
|
354
|
+
`${describeValue(rate)}. It is samplesPerRecord over the record duration, and it is ` +
|
|
355
|
+
`what the channels are grouped into output files by.`);
|
|
356
|
+
}
|
|
357
|
+
const samples = signal['samplesPerRecord'];
|
|
358
|
+
if (!Number.isInteger(samples) || samples < 0) {
|
|
359
|
+
throw new OptionError(`signals[${at}].samplesPerRecord must be a whole number of samples, got ` +
|
|
360
|
+
`${describeValue(samples)}. Every row this plan counts comes from it.`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
318
363
|
}
|
|
319
364
|
/**
|
|
320
365
|
* The two numbers a window is measured against, apart from the channel list.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"options.js","sourceRoot":"","sources":["../../src/convert/options.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,yFAAyF;AACzF,MAAM,OAAO,WAAY,SAAQ,KAAK;IACpC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;IAC5B,CAAC;CACF;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,EAAE,CAAC;AAE/B,MAAM,UAAU,aAAa,CAAC,OAkB7B;IACC;;;;;;;;;;;;;;;;;MAiBE;IACF,KAAK,MAAM,IAAI,IAAI,CAAC,WAAW,EAAE,cAAc,EAAE,SAAS,CAAU,EAAE,CAAC;QACrE,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,IAAI,KAAK,KAAK,SAAS,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YACrD,MAAM,IAAI,WAAW,CACnB,GAAG,IAAI,2CAA2C,aAAa,CAAC,KAAK,CAAC,iBAAiB;gBACrF,oFAAoF,CACvF,CAAC;QACJ,CAAC;IACH,CAAC;IACD,MAAM,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;IAC7B,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,QAAQ,GAAG,CAAC,IAAI,QAAQ,GAAG,YAAY,EAAE,CAAC;YAC3E,MAAM,IAAI,WAAW,CACnB,iDAAiD,YAAY,SAAS,aAAa,CAAC,QAAQ,CAAC,GAAG,CACjG,CAAC;QACJ,CAAC;IACH,CAAC;IAED;;;;;;;;;;;MAWE;IACF,KAAK,MAAM,IAAI,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,KAAK,CAAU,EAAE,CAAC;QACzD,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,IAAI,KAAK,KAAK,SAAS;YAAE,SAAS;QAClC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;YACzD,MAAM,IAAI,WAAW,CAAC,GAAG,IAAI,qCAAqC,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC7F,CAAC;QACD;;;;;;;;;UASE;QACF,IAAI,IAAI,KAAK,UAAU,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;YACrC,MAAM,IAAI,WAAW,CAAC,iDAAiD,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAClG,CAAC;IACH,CAAC;IAED;;;;;;;;MAQE;IACF,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAC3B,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,MAAM,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QACnE,MAAM,IAAI,WAAW,CAAC,wCAAwC,aAAa,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC1F,CAAC;IAED;;;;;;;;;;;;;;;MAeE;IACF;;;;;;;;;;;;;;MAcE;IACF;;;;;;;;;;;;;;;MAeE;IACF,MAAM,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC;IAC9B,IAAI,SAAS,KAAK,SAAS,IAAI,OAAO,SAAS,KAAK,QAAQ,EAAE,CAAC;QAC7D,MAAM,IAAI,WAAW,CAAC,iCAAiC,aAAa,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;IACtF,CAAC;IACD,IAAI,SAAS,KAAK,EAAE,EAAE,CAAC;QACrB,MAAM,IAAI,WAAW,CAAC,kEAAkE,CAAC,CAAC;IAC5F,CAAC;IAED;;;;;;;;;;;;;;;MAeE;IACF,KAAK,MAAM,IAAI,IAAI,CAAC,iBAAiB,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,CAAU,EAAE,CAAC;QAChG,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC5B,IAAI,KAAK,KAAK,SAAS,IAAI,OAAO,KAAK,KAAK,SAAS,EAAE,CAAC;YACtD,MAAM,IAAI,WAAW,CAAC,GAAG,IAAI,+BAA+B,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACvF,CAAC;IACH,CAAC;IAED;;;;;;;;;;;;;;;;;MAiBE;IACF,MAAM,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC;IAC/B,IAAI,UAAU,KAAK,SAAS,IAAI,OAAO,UAAU,KAAK,UAAU,EAAE,CAAC;QACjE,MAAM,IAAI,WAAW,CAAC,sCAAsC,aAAa,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;IAC5F,CAAC;IAED,MAAM,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;IAC7B,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B;;;;;;;UAOE;QACF,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC,EAAE,CAAC;YAClF,MAAM,IAAI,WAAW,CAAC,iDAAiD,aAAa,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QACrG,CAAC;QACD,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;YACjD,MAAM,IAAI,WAAW,CAAC,gDAAgD,CAAC,CAAC;QAC1E,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,aAAa,CAAC,OAAgB;IAC5C,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QAC5B,MAAM,IAAI,WAAW,CACnB,uDAAuD,aAAa,CAAC,OAAO,CAAC,GAAG,CACjF,CAAC;IACJ,CAAC;IACD;;;;;;;;;;;;;;;;;;;;;MAqBE;IACF,MAAM,MAAM,GAAG;QACb,CAAC,OAAO,EAAE,QAAQ,CAAC;QACnB,CAAC,OAAO,EAAE,QAAQ,CAAC;QACnB,CAAC,eAAe,EAAE,SAAS,CAAC;KACpB,CAAC;IACX,KAAK,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;QAC7C,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;YAClD,MAAM,IAAI,WAAW,CACnB,WAAW,EAAE,yCAAyC,aAAa,CAAC,MAAM,CAAC,GAAG,CAC/E,CAAC;QACJ,CAAC;QACD,MAAM,MAAM,GAAG,MAAiC,CAAC;QACjD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,OAAO,MAAM,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC;QAC1E,IAAI,KAAK,KAAK,SAAS;YAAE,SAAS;QAClC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,KAAK,CAAC;QAC3B,MAAM,IAAI,WAAW,CACnB,WAAW,EAAE,KAAK,IAAI,cAAc,IAAI,SAAS,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,cAAc;YACxF,yBAAyB,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAChE,CAAC;IACJ,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,eAAe,CAAC,KAI/B;IACC,aAAa,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IAC7B,iBAAiB,CAAC,KAAK,CAAC,CAAC;AAC3B,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAIjC;IACC,MAAM,EAAE,cAAc,EAAE,WAAW,EAAE,YAAY,EAAE,GAAG,KAAK,IAAI,EAAE,CAAC;IAClE,IAAI,OAAO,cAAc,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,IAAI,cAAc,IAAI,CAAC,EAAE,CAAC;QAClG,MAAM,IAAI,WAAW,CACnB,4DAA4D,aAAa,CAAC,cAAc,CAAC,GAAG,CAC7F,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,WAAW,CAAC,IAAK,WAAsB,GAAG,CAAC,EAAE,CAAC;QAClE,MAAM,IAAI,WAAW,CACnB,2DAA2D,aAAa,CAAC,WAAW,CAAC,GAAG,CACzF,CAAC;IACJ,CAAC;IACD;;;;;;;;;;;;;;;;;;;MAmBE;IACF,IACE,YAAY,KAAK,IAAI;QACrB,YAAY,KAAK,SAAS;QAC1B,CAAC,KAAK,CAAC,OAAO,CAAC,YAAY,CAAC;QAC5B,CAAC,WAAW,CAAC,MAAM,CAAC,YAAY,CAAC,EACjC,CAAC;QACD,MAAM,IAAI,WAAW,CACnB,kEAAkE;YAChE,GAAG,aAAa,CAAC,YAAY,CAAC,sDAAsD;YACpF,+DAA+D,CAClE,CAAC;IACJ,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,MAAM,IAAI,WAAW,CAAC,4CAA4C,aAAa,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC7F,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC;AAC5F,CAAC","sourcesContent":["/**\n * Checking the options a caller passed, before anything is written.\n *\n * The command line has always validated these — `--decimals 1.5` is a usage error and always\n * has been — and the library did not, so the same value behaved differently depending on how\n * it arrived. `convert(file, { decimals: NaN })` resolved successfully having written whole\n * numbers into a column the caller had asked for decimals in, which is the worst of the\n * three: no error, no warning, and output that looks like a deliberate choice. `decimals: -1`\n * reached `toFixed` and came back as a bare RangeError from deep inside the formatter, naming\n * nothing the caller had written. `start: NaN` created the output directory, wrote\n * signals.csv, and then failed with a message about the input being unreadable — a partial\n * conversion, blamed on the file.\n *\n * These run at the top of `buildPlan`, which every path goes through before a directory is\n * created or a stream is opened, so a rejected option leaves nothing behind.\n */\n\n/** A problem with the options a caller passed, as opposed to a problem with the file. */\nexport class OptionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'OptionError';\n }\n}\n\n/**\n * The largest `--decimals` accepts, and what both documentation pages state.\n *\n * Not a limit of `toFixed`, whatever this comment used to say. `toFixed` takes 0 to 100 and\n * throws a RangeError at 101 — which is exactly the belief `MAX_DERIVED_DECIMALS` in\n * edf/scale.ts exists to correct, having once clamped the *derived* precision to 20 on the\n * same wrong grounds and rounded a magnetometer channel needing 23 places onto a grid three\n * digital codes wide, losing 69% of its samples in silence.\n *\n * Twenty is a bound on a number a person types by hand, not on what the format can express.\n * The derived precision, which nobody types, runs to 100 and says so.\n */\nexport const MAX_DECIMALS = 20;\n\nexport function assertOptions(options: {\n decimals?: number | undefined;\n start?: number | undefined;\n duration?: number | undefined;\n end?: number | undefined;\n layout?: string | undefined;\n channels?: readonly string[] | undefined;\n outputDir?: string | undefined;\n annotationsOnly?: boolean | undefined;\n gzip?: boolean | undefined;\n bom?: boolean | undefined;\n force?: boolean | undefined;\n checksum?: boolean | undefined;\n toStdout?: boolean | undefined;\n onProgress?: unknown;\n startText?: unknown;\n durationText?: unknown;\n endText?: unknown;\n}): void {\n /*\n The three options that exist only to be quoted back, quoted back unexamined.\n\n `startText`, `durationText` and `endText` carry the value exactly as the caller's user\n typed it, so a refusal names that rather than its parsed form — \"--start \\\"4h\\\"\" rather\n than \"--start 14400s\". Nothing asked whether they were text, and they reach the sentence\n as they are:\n\n convert(file, { start: 99, startText: {} })\n TimeRangeError: --start \"[object Object]\" is at or past the end of this 3s recording.\n\n convert(file, { start: 1, end: 0.5, endText: [] })\n TimeRangeError: The requested window ends at \"\", which is not after its start at 1s.\n\n `[object Object]` is the string `assertInputPath`'s own docstring names as the reason that\n function exists, and the empty quotation is the hole `describeValue` was written to stop —\n both in the one place whose entire purpose is to show the reader what they typed.\n */\n for (const name of ['startText', 'durationText', 'endText'] as const) {\n const value = options[name];\n if (value !== undefined && typeof value !== 'string') {\n throw new OptionError(\n `${name} must be the value as it was typed, got ${describeValue(value)}. It is quoted ` +\n `back in the window errors so they name what was given rather than its parsed form.`,\n );\n }\n }\n const { decimals } = options;\n if (decimals !== undefined) {\n if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) {\n throw new OptionError(\n `decimals must be a whole number between 0 and ${MAX_DECIMALS}, got ${describeValue(decimals)}.`,\n );\n }\n }\n\n /*\n `start` and `end` are positions on the recording's own clock, `duration` is a length.\n\n All three were held above zero, which is right for a length and wrong for a position: a\n recording timed from its first record's timekeeping annotation may sit before zero, and\n -100 is then where its first sample is. So a caller could read `plan.range` back as\n `recordingStartSeconds: -100` and not be allowed to ask for it — the same wall\n `parseTimeSpec` put in front of the command line until 0.5.120.\n\n Non-finite is still refused for all three, since NaN reaches a comparison as false and\n would take the whole recording without saying so.\n */\n for (const name of ['start', 'duration', 'end'] as const) {\n const value = options[name];\n if (value === undefined) continue;\n if (typeof value !== 'number' || !Number.isFinite(value)) {\n throw new OptionError(`${name} must be a number of seconds, got ${describeValue(value)}.`);\n }\n /*\n A length below zero is refused for what it is, rather than for not being a number.\n\n `duration: -1` came back as \"duration must be a number of seconds, got -1\", which is\n not the reason and not true: -1 is a number of seconds, and this same call accepts it\n for `start` and for `end`, where a recording timed from before zero makes it an\n ordinary position. What is wrong is that a duration is a length, and no length is\n negative — which is what the command line says for the same value, and what the API\n reference has always said this check enforces.\n */\n if (name === 'duration' && value < 0) {\n throw new OptionError(`duration is a length of time, so it cannot be ${describeValue(value)}.`);\n }\n }\n\n /*\n Two words, and a caller who writes a third means something the tool cannot do.\n\n The command line has always rejected `--layout tall`. The library took it, put it in\n `plan.layout` for the caller to read back, and wrote the wide layout — so a programmatic\n caller with a typo got a conversion that was not the one they asked for, described by a\n plan that agreed with the typo. Every other option that has a shape is checked here; this\n one was added in 0.5.0 and never joined them.\n */\n const { layout } = options;\n if (layout !== undefined && layout !== 'wide' && layout !== 'long') {\n throw new OptionError(`layout must be \"wide\" or \"long\", got ${describeValue(layout)}.`);\n }\n\n /*\n A selection that names nothing is not the absence of a selection.\n\n `buildPlan` asks `options.channels.length > 0` before selecting, so an empty array fell\n through to the branch that means \"no channels option was given\" — and `convert(file, {\n channels: [] })` wrote every channel in the recording, resolved, and said nothing. That is\n the one shape of this the command line has always refused, in as many words: \"Returning\n undefined here would mean 'no --channels given' and convert everything, which is the\n opposite of what someone passing an empty list is asking for.\"\n\n A list of blanks is the same request written differently — it is what `''.split(',')`\n produces, which is how a caller building the array from user input arrives here — and it\n reached `selectChannels` and came back \"No channels were selected\", a sentence about the\n file rather than about the call. Both are the option being wrong, so both are refused\n here, before a directory exists.\n */\n /*\n An empty destination, refused here rather than by the filesystem.\n\n `convert(file, { outputDir: '' })` went the whole way to `mkdir('')` and came back a\n `ConversionError`: \"Cannot create \\\"\\\": part of the path does not exist. Check the path\n exists and that you have permission to write there.\" Advice about a path and a permission\n for a value that is neither, and a failure class that means the conversion went wrong\n where the option did.\n\n The command line refused this at 0.6.x, with the reasoning that `--out \"$DEST\"` and `DEST`\n unset is how it gets written by accident — and left the library, which a caller building\n the path in code reaches the same way. Not trimmed, for the reason given there: a\n directory whose name is a space is a strange thing to ask for, but it is a thing the\n filesystem has and a path is not a keyword.\n */\n /*\n And its shape, which was the one option with a value and no check on it.\n\n The empty string was refused and nothing else was, so `outputDir` failed two ways that the\n paragraph above describes for the flags. A value of the wrong type reached `path.join` and\n came back as a Node error about an argument this caller never passed:\n\n convert('rec.edf', { outputDir: 42 })\n TypeError: The \"path\" argument must be of type string. Received type number (42)\n\n And `null` — which is what `JSON.parse` of a config file gives for a field left unset, the\n same door `1` and `'true'` come through — was not an error at all. It is not `undefined`,\n so it never meant \"use the default\", but every read of it is `?? default` or a truthiness\n test, so that is what it did: the rows went to `<recording>_csv` beside the input, a\n directory the caller had not named, and the run reported success.\n */\n const { outputDir } = options;\n if (outputDir !== undefined && typeof outputDir !== 'string') {\n throw new OptionError(`outputDir must be a path, got ${describeValue(outputDir)}.`);\n }\n if (outputDir === '') {\n throw new OptionError('outputDir is empty. Give a directory, for example \"./converted\".');\n }\n\n /*\n The six flags, every one of which is read as `=== true` where it is read.\n\n Which means a value that is not a boolean is not merely tolerated: it is taken as the\n opposite of what it says. `convert(file, { annotationsOnly: 'true' })` wrote every signal\n the caller had asked to leave out; `{ gzip: 1 }` wrote plain CSVs under names ending\n `.csv`, so a caller who then opened `signals.csv.gz` found nothing there. No error, no\n warning, and output that looks like a deliberate choice — which is the sentence at the\n top of this file, describing the case it was written for.\n\n `1` and `'true'` are how a flag arrives from `JSON.parse` of a config file, from a query\n string, or from a CLI wrapper that did not coerce; none of them is a caller being\n careless in a way TypeScript would catch, since the callers this reaches are the ones not\n using it. The layout check above states the rule these were missing from: \"Every other\n option that has a shape is checked here.\"\n */\n for (const name of ['annotationsOnly', 'gzip', 'bom', 'force', 'checksum', 'toStdout'] as const) {\n const value = options[name];\n if (value !== undefined && typeof value !== 'boolean') {\n throw new OptionError(`${name} must be true or false, got ${describeValue(value)}.`);\n }\n }\n\n /*\n The one option that is called rather than read, and the only one that was not checked.\n\n `convert` invokes it as `options.onProgress?.(...)` once a record has been written, so a\n value that is not a function passes every check here, opens the destination, writes rows\n into it and then fails from inside the loop:\n\n convert('rec.edf', { outputDir: 'out', onProgress: 'every record' })\n ConversionError: The onProgress callback threw: options.onProgress is not a function\n\n A callback that threw is what that sentence reports, and no callback was given; the text\n after the colon names an expression inside this package. Worse is what it leaves: `out`\n exists with a half-written signals.csv in it, which is the case the paragraph at the top\n of this file describes — \"`start: NaN` created the output directory, wrote signals.csv,\n and then failed with a message about the input being unreadable — a partial conversion,\n blamed on the file\". Checked here, the same call writes nothing and says which argument\n is wrong.\n */\n const { onProgress } = options;\n if (onProgress !== undefined && typeof onProgress !== 'function') {\n throw new OptionError(`onProgress must be a function, got ${describeValue(onProgress)}.`);\n }\n\n const { channels } = options;\n if (channels !== undefined) {\n /*\n A list of strings, checked as one. `selectChannels` calls `.trim()` on every term, so a\n caller who passed the string `'ECG'` had it iterated character by character and was told\n `No channel named \"E\"`, and one who passed `[1]` — a position, reasonably enough — got\n `TypeError: rawTerm.trim is not a function` out of the middle of the selector, naming\n nothing they had written. Both are the option being the wrong shape, which is the case\n this function exists for.\n */\n if (!Array.isArray(channels) || channels.some((term) => typeof term !== 'string')) {\n throw new OptionError(`channels must be a list of channel names, got ${describeValue(channels)}.`);\n }\n if (channels.every((term) => term.trim() === '')) {\n throw new OptionError('channels was given but lists no channel names.');\n }\n }\n}\n\n/**\n * The channel list two exported functions take, checked the way their other argument is.\n *\n * `selectChannels(signals, terms)` has checked `terms` since 0.6.x — \"`'ECG'` was iterated\n * character by character and answered `No channel named \\\"E\\\"`… naming nothing the caller had\n * written\" — and never checked `signals`, which is the argument in front of it. Passing one\n * signal where the list goes, or a header where its `signals` goes, came back as\n * `TypeError: signals.filter is not a function`: a local of this package, over a value the\n * caller did write.\n *\n * `buildColumnNames` is worse off, because a string is iterable. `buildColumnNames('ECG')`\n * returned `Map { null => 'undefined_chundefined' }` and no error at all — a column name for a\n * channel that does not exist, keyed by a position that is not one.\n *\n * The bad entry is named by position rather than the whole list being printed back: a header\n * may declare hundreds of channels, and a message is not the place for all of them.\n */\nexport function assertSignals(signals: unknown): void {\n if (!Array.isArray(signals)) {\n throw new OptionError(\n `signals must be the channel list from a header, got ${describeValue(signals)}.`,\n );\n }\n /*\n The fields the callers read, not just the one that identifies a channel.\n\n This asked for `index` and stopped, and its callers go on to read `label` and\n `isAnnotations` off the same objects. So a list of channel-shaped objects without them\n passed the check that exists to say \"these are not channels from a header\" and failed one\n line further in, or did not fail at all:\n\n selectChannels([{ index: 0 }], ['ECG'])\n TypeError: Cannot read properties of undefined (reading 'toLowerCase')\n\n selectChannels([{ index: 0, label: 42 }], ['42'])\n TypeError: signal.label.toLowerCase is not a function\n\n buildColumnNames([{ index: 0 }])\n Map { 0 => null }\n\n The first two are the failure this function was written to remove, one level down — a\n local of this package named at a caller who wrote neither. The third is worse: a column\n name of `null`, where a channel with no label at all is named `signal_0`, so `null` is not\n a name this tool ever writes.\n */\n const FIELDS = [\n ['index', 'number'],\n ['label', 'string'],\n ['isAnnotations', 'boolean'],\n ] as const;\n for (const [at, signal] of signals.entries()) {\n if (typeof signal !== 'object' || signal === null) {\n throw new OptionError(\n `signals[${at}] is not a channel from a header, got ${describeValue(signal)}.`,\n );\n }\n const fields = signal as Record<string, unknown>;\n const wrong = FIELDS.find(([name, kind]) => typeof fields[name] !== kind);\n if (wrong === undefined) continue;\n const [name, kind] = wrong;\n throw new OptionError(\n `signals[${at}].${name} must be a ${kind}, got ${describeValue(fields[name])}. A channel ` +\n `from a header carries ${FIELDS.map(([f]) => f).join(', ')}.`,\n );\n }\n}\n\n/**\n * What `buildPlan` is told about the recording, checked the way what it is asked for is.\n *\n * `assertOptions` runs at the top of `buildPlan` and covers the second argument completely.\n * The first was not looked at, and it is the one carrying the numbers every figure in the plan\n * is derived from. Two of them missing produced a plan rather than an error:\n *\n * buildPlan({ signals, recordDuration: 1 }, {})\n * // groups: 3, estimate.rows: 0, range.endSeconds: null\n *\n * A plan saying the conversion writes nothing, handed back as an answer — which is the \"takes\n * the whole recording without saying so\" this checker exists to stop, one field over. A record\n * count below zero was worse: it came back as\n *\n * TimeRangeError: --start 0s is at or past the end of this -5s recording.\n *\n * a flag the caller never passed, about a recording that cannot exist, blaming the request for\n * the input. And `recordDuration: '1'` was coerced by the arithmetic and accepted, where the\n * same string is refused for `end` two functions down.\n *\n * A real header cannot produce any of them: the parser refuses a record duration that is not a\n * positive number, \"Infinity\" included.\n */\nexport function assertPlanInput(input: {\n signals?: unknown;\n recordDuration?: unknown;\n recordCount?: unknown;\n}): void {\n assertSignals(input.signals);\n assertRecordShape(input);\n}\n\n/**\n * The two numbers a window is measured against, apart from the channel list.\n *\n * `resolveRange` is exported on its own and has its own signature block on the api page, and\n * `buildPlan` calls it — so it was covered only from above. Called directly it took both\n * numbers unexamined and answered with a range:\n *\n * resolveRange({ recordDuration: 1 }) // recordCount undefined\n * // { startSeconds: 0, endSeconds: null, startRecord: 0, endRecord: 0 }\n *\n * A range over no records, returned as a fact about a recording. Its own opening comment\n * already says why that is the wrong answer — \"no error, no warning, and a range read back as\n * `startSeconds: null, startRecord: null`, which is the 'takes the whole recording without\n * saying so'\" — about the three fields it does check. `resolveRange(42)` went the same way,\n * since reading `.start` off a number is `undefined` rather than a throw.\n *\n * Split out rather than calling `assertPlanInput`, which would demand a channel list this\n * function never looks at.\n */\nexport function assertRecordShape(input: {\n recordDuration?: unknown;\n recordCount?: unknown;\n recordStarts?: unknown;\n}): void {\n const { recordDuration, recordCount, recordStarts } = input ?? {};\n if (typeof recordDuration !== 'number' || !Number.isFinite(recordDuration) || recordDuration <= 0) {\n throw new OptionError(\n `recordDuration must be a positive number of seconds, got ${describeValue(recordDuration)}.`,\n );\n }\n if (!Number.isInteger(recordCount) || (recordCount as number) < 0) {\n throw new OptionError(\n `recordCount must be a whole number of data records, got ${describeValue(recordCount)}.`,\n );\n }\n /*\n And where the records really sit, which is what makes a discontinuous file's span longer\n than its duration. Nothing asked what it was, and the two ways of getting it wrong fail\n differently:\n\n resolveRange({ recordDuration: 1, recordCount: 3, recordStarts: 'x' })\n { startSeconds: 0, endSeconds: 3, startRecord: 0, endRecord: 0, isWholeRecording: true }\n\n A string is iterable, so it spreads to its characters and the span comes out over no\n records at all — a range that says it is the whole recording and covers none of it, which\n is a contradiction rather than an answer.\n\n resolveRange({ ..., recordStarts: 42 })\n TypeError: recordStarts is not iterable\n\n which names this function's own parameter at a caller holding the wrong thing. A list is\n what it takes: the `Float64Array` the reader builds, or an ordinary array — `readAnnotations`\n hands back `(number | null)[]`, and a null start is a record whose position is not known,\n which this already allows for.\n */\n if (\n recordStarts !== null &&\n recordStarts !== undefined &&\n !Array.isArray(recordStarts) &&\n !ArrayBuffer.isView(recordStarts)\n ) {\n throw new OptionError(\n `recordStarts must be a list of record start times, or null, got ` +\n `${describeValue(recordStarts)}. It is where the records really sit, which is what ` +\n `makes a discontinuous recording span more time than it holds.`,\n );\n }\n}\n\n/**\n * The recording to read, checked before it is opened.\n *\n * `EdfFile.open` hands whatever it is given to `fs`, and the refusal comes back as an\n * `EdfError` coded `UNREADABLE`, hinted \"Check the path is spelled the way it is on disk and\n * that you have permission to read it\" — advice about a path, over a value that is not one,\n * filed as a problem with the recording rather than with the call. `convert({ input: 'a.edf' })`,\n * which is the option-bag shape the second parameter has, answered `Cannot read \"[object\n * Object]\"`; `convert(['a.edf', 'b.edf'])` answered `Cannot read \"a.edf,b.edf\"`, a path the\n * caller never wrote, because `String` of an array joins it with commas.\n *\n * The empty string is left to `fs`, which has no such file and says so truthfully — the same\n * reasoning `outputDir` states for not trimming: a path is not a keyword.\n */\nexport function assertInputPath(input: unknown): void {\n if (typeof input !== 'string') {\n throw new OptionError(`input must be a path to a recording, got ${describeValue(input)}.`);\n }\n}\n\n/**\n * How a rejected value reads in the refusal: numbers bare, everything else quoted so its\n * type is visible.\n *\n * `JSON.stringify` has no text for a function or a symbol — it returns `undefined`, not a\n * string — so `layout: () => 'long'` came back as `layout must be \"wide\" or \"long\", got\n * undefined.`, which names the one value that does not raise this: every option here is\n * optional, and `undefined` is how a caller says they are not passing it. `input` was worse,\n * since `convert(undefined)` and `convert(someFunction)` then produced the same sentence, and\n * the first is a forgotten argument while the second is a wrong one.\n *\n * Exported because time-range.ts had the identical function, fixed there and not here — the\n * same two-copies-of-one-helper the derived precision and the pluraliser were each pulled\n * together for.\n */\nexport function describeValue(value: unknown): string {\n return typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"options.js","sourceRoot":"","sources":["../../src/convert/options.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Checking the options a caller passed, before anything is written.\n *\n * The command line has always validated these — `--decimals 1.5` is a usage error and always\n * has been — and the library did not, so the same value behaved differently depending on how\n * it arrived. `convert(file, { decimals: NaN })` resolved successfully having written whole\n * numbers into a column the caller had asked for decimals in, which is the worst of the\n * three: no error, no warning, and output that looks like a deliberate choice. `decimals: -1`\n * reached `toFixed` and came back as a bare RangeError from deep inside the formatter, naming\n * nothing the caller had written. `start: NaN` created the output directory, wrote\n * signals.csv, and then failed with a message about the input being unreadable — a partial\n * conversion, blamed on the file.\n *\n * These run at the top of `buildPlan`, which every path goes through before a directory is\n * created or a stream is opened, so a rejected option leaves nothing behind.\n */\n\n/** A problem with the options a caller passed, as opposed to a problem with the file. */\nexport class OptionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'OptionError';\n }\n}\n\n/**\n * The largest `--decimals` accepts, and what both documentation pages state.\n *\n * Not a limit of `toFixed`, whatever this comment used to say. `toFixed` takes 0 to 100 and\n * throws a RangeError at 101 — which is exactly the belief `MAX_DERIVED_DECIMALS` in\n * edf/scale.ts exists to correct, having once clamped the *derived* precision to 20 on the\n * same wrong grounds and rounded a magnetometer channel needing 23 places onto a grid three\n * digital codes wide, losing 69% of its samples in silence.\n *\n * Twenty is a bound on a number a person types by hand, not on what the format can express.\n * The derived precision, which nobody types, runs to 100 and says so.\n */\nexport const MAX_DECIMALS = 20;\n\nexport function assertOptions(options: {\n decimals?: number | undefined;\n start?: number | undefined;\n duration?: number | undefined;\n end?: number | undefined;\n layout?: string | undefined;\n channels?: readonly string[] | undefined;\n outputDir?: string | undefined;\n annotationsOnly?: boolean | undefined;\n gzip?: boolean | undefined;\n bom?: boolean | undefined;\n force?: boolean | undefined;\n checksum?: boolean | undefined;\n toStdout?: boolean | undefined;\n onProgress?: unknown;\n startText?: unknown;\n durationText?: unknown;\n endText?: unknown;\n}): void {\n /*\n The three options that exist only to be quoted back, quoted back unexamined.\n\n `startText`, `durationText` and `endText` carry the value exactly as the caller's user\n typed it, so a refusal names that rather than its parsed form — \"--start \\\"4h\\\"\" rather\n than \"--start 14400s\". Nothing asked whether they were text, and they reach the sentence\n as they are:\n\n convert(file, { start: 99, startText: {} })\n TimeRangeError: --start \"[object Object]\" is at or past the end of this 3s recording.\n\n convert(file, { start: 1, end: 0.5, endText: [] })\n TimeRangeError: The requested window ends at \"\", which is not after its start at 1s.\n\n `[object Object]` is the string `assertInputPath`'s own docstring names as the reason that\n function exists, and the empty quotation is the hole `describeValue` was written to stop —\n both in the one place whose entire purpose is to show the reader what they typed.\n */\n for (const name of ['startText', 'durationText', 'endText'] as const) {\n const value = options[name];\n if (value !== undefined && typeof value !== 'string') {\n throw new OptionError(\n `${name} must be the value as it was typed, got ${describeValue(value)}. It is quoted ` +\n `back in the window errors so they name what was given rather than its parsed form.`,\n );\n }\n }\n const { decimals } = options;\n if (decimals !== undefined) {\n if (!Number.isInteger(decimals) || decimals < 0 || decimals > MAX_DECIMALS) {\n throw new OptionError(\n `decimals must be a whole number between 0 and ${MAX_DECIMALS}, got ${describeValue(decimals)}.`,\n );\n }\n }\n\n /*\n `start` and `end` are positions on the recording's own clock, `duration` is a length.\n\n All three were held above zero, which is right for a length and wrong for a position: a\n recording timed from its first record's timekeeping annotation may sit before zero, and\n -100 is then where its first sample is. So a caller could read `plan.range` back as\n `recordingStartSeconds: -100` and not be allowed to ask for it — the same wall\n `parseTimeSpec` put in front of the command line until 0.5.120.\n\n Non-finite is still refused for all three, since NaN reaches a comparison as false and\n would take the whole recording without saying so.\n */\n for (const name of ['start', 'duration', 'end'] as const) {\n const value = options[name];\n if (value === undefined) continue;\n if (typeof value !== 'number' || !Number.isFinite(value)) {\n throw new OptionError(`${name} must be a number of seconds, got ${describeValue(value)}.`);\n }\n /*\n A length below zero is refused for what it is, rather than for not being a number.\n\n `duration: -1` came back as \"duration must be a number of seconds, got -1\", which is\n not the reason and not true: -1 is a number of seconds, and this same call accepts it\n for `start` and for `end`, where a recording timed from before zero makes it an\n ordinary position. What is wrong is that a duration is a length, and no length is\n negative — which is what the command line says for the same value, and what the API\n reference has always said this check enforces.\n */\n if (name === 'duration' && value < 0) {\n throw new OptionError(`duration is a length of time, so it cannot be ${describeValue(value)}.`);\n }\n }\n\n /*\n Two words, and a caller who writes a third means something the tool cannot do.\n\n The command line has always rejected `--layout tall`. The library took it, put it in\n `plan.layout` for the caller to read back, and wrote the wide layout — so a programmatic\n caller with a typo got a conversion that was not the one they asked for, described by a\n plan that agreed with the typo. Every other option that has a shape is checked here; this\n one was added in 0.5.0 and never joined them.\n */\n const { layout } = options;\n if (layout !== undefined && layout !== 'wide' && layout !== 'long') {\n throw new OptionError(`layout must be \"wide\" or \"long\", got ${describeValue(layout)}.`);\n }\n\n /*\n A selection that names nothing is not the absence of a selection.\n\n `buildPlan` asks `options.channels.length > 0` before selecting, so an empty array fell\n through to the branch that means \"no channels option was given\" — and `convert(file, {\n channels: [] })` wrote every channel in the recording, resolved, and said nothing. That is\n the one shape of this the command line has always refused, in as many words: \"Returning\n undefined here would mean 'no --channels given' and convert everything, which is the\n opposite of what someone passing an empty list is asking for.\"\n\n A list of blanks is the same request written differently — it is what `''.split(',')`\n produces, which is how a caller building the array from user input arrives here — and it\n reached `selectChannels` and came back \"No channels were selected\", a sentence about the\n file rather than about the call. Both are the option being wrong, so both are refused\n here, before a directory exists.\n */\n /*\n An empty destination, refused here rather than by the filesystem.\n\n `convert(file, { outputDir: '' })` went the whole way to `mkdir('')` and came back a\n `ConversionError`: \"Cannot create \\\"\\\": part of the path does not exist. Check the path\n exists and that you have permission to write there.\" Advice about a path and a permission\n for a value that is neither, and a failure class that means the conversion went wrong\n where the option did.\n\n The command line refused this at 0.6.x, with the reasoning that `--out \"$DEST\"` and `DEST`\n unset is how it gets written by accident — and left the library, which a caller building\n the path in code reaches the same way. Not trimmed, for the reason given there: a\n directory whose name is a space is a strange thing to ask for, but it is a thing the\n filesystem has and a path is not a keyword.\n */\n /*\n And its shape, which was the one option with a value and no check on it.\n\n The empty string was refused and nothing else was, so `outputDir` failed two ways that the\n paragraph above describes for the flags. A value of the wrong type reached `path.join` and\n came back as a Node error about an argument this caller never passed:\n\n convert('rec.edf', { outputDir: 42 })\n TypeError: The \"path\" argument must be of type string. Received type number (42)\n\n And `null` — which is what `JSON.parse` of a config file gives for a field left unset, the\n same door `1` and `'true'` come through — was not an error at all. It is not `undefined`,\n so it never meant \"use the default\", but every read of it is `?? default` or a truthiness\n test, so that is what it did: the rows went to `<recording>_csv` beside the input, a\n directory the caller had not named, and the run reported success.\n */\n const { outputDir } = options;\n if (outputDir !== undefined && typeof outputDir !== 'string') {\n throw new OptionError(`outputDir must be a path, got ${describeValue(outputDir)}.`);\n }\n if (outputDir === '') {\n throw new OptionError('outputDir is empty. Give a directory, for example \"./converted\".');\n }\n\n /*\n The six flags, every one of which is read as `=== true` where it is read.\n\n Which means a value that is not a boolean is not merely tolerated: it is taken as the\n opposite of what it says. `convert(file, { annotationsOnly: 'true' })` wrote every signal\n the caller had asked to leave out; `{ gzip: 1 }` wrote plain CSVs under names ending\n `.csv`, so a caller who then opened `signals.csv.gz` found nothing there. No error, no\n warning, and output that looks like a deliberate choice — which is the sentence at the\n top of this file, describing the case it was written for.\n\n `1` and `'true'` are how a flag arrives from `JSON.parse` of a config file, from a query\n string, or from a CLI wrapper that did not coerce; none of them is a caller being\n careless in a way TypeScript would catch, since the callers this reaches are the ones not\n using it. The layout check above states the rule these were missing from: \"Every other\n option that has a shape is checked here.\"\n */\n for (const name of ['annotationsOnly', 'gzip', 'bom', 'force', 'checksum', 'toStdout'] as const) {\n const value = options[name];\n if (value !== undefined && typeof value !== 'boolean') {\n throw new OptionError(`${name} must be true or false, got ${describeValue(value)}.`);\n }\n }\n\n /*\n The one option that is called rather than read, and the only one that was not checked.\n\n `convert` invokes it as `options.onProgress?.(...)` once a record has been written, so a\n value that is not a function passes every check here, opens the destination, writes rows\n into it and then fails from inside the loop:\n\n convert('rec.edf', { outputDir: 'out', onProgress: 'every record' })\n ConversionError: The onProgress callback threw: options.onProgress is not a function\n\n A callback that threw is what that sentence reports, and no callback was given; the text\n after the colon names an expression inside this package. Worse is what it leaves: `out`\n exists with a half-written signals.csv in it, which is the case the paragraph at the top\n of this file describes — \"`start: NaN` created the output directory, wrote signals.csv,\n and then failed with a message about the input being unreadable — a partial conversion,\n blamed on the file\". Checked here, the same call writes nothing and says which argument\n is wrong.\n */\n const { onProgress } = options;\n if (onProgress !== undefined && typeof onProgress !== 'function') {\n throw new OptionError(`onProgress must be a function, got ${describeValue(onProgress)}.`);\n }\n\n const { channels } = options;\n if (channels !== undefined) {\n /*\n A list of strings, checked as one. `selectChannels` calls `.trim()` on every term, so a\n caller who passed the string `'ECG'` had it iterated character by character and was told\n `No channel named \"E\"`, and one who passed `[1]` — a position, reasonably enough — got\n `TypeError: rawTerm.trim is not a function` out of the middle of the selector, naming\n nothing they had written. Both are the option being the wrong shape, which is the case\n this function exists for.\n */\n if (!Array.isArray(channels) || channels.some((term) => typeof term !== 'string')) {\n throw new OptionError(`channels must be a list of channel names, got ${describeValue(channels)}.`);\n }\n if (channels.every((term) => term.trim() === '')) {\n throw new OptionError('channels was given but lists no channel names.');\n }\n }\n}\n\n/**\n * The channel list two exported functions take, checked the way their other argument is.\n *\n * `selectChannels(signals, terms)` has checked `terms` since 0.6.x — \"`'ECG'` was iterated\n * character by character and answered `No channel named \\\"E\\\"`… naming nothing the caller had\n * written\" — and never checked `signals`, which is the argument in front of it. Passing one\n * signal where the list goes, or a header where its `signals` goes, came back as\n * `TypeError: signals.filter is not a function`: a local of this package, over a value the\n * caller did write.\n *\n * `buildColumnNames` is worse off, because a string is iterable. `buildColumnNames('ECG')`\n * returned `Map { null => 'undefined_chundefined' }` and no error at all — a column name for a\n * channel that does not exist, keyed by a position that is not one.\n *\n * The bad entry is named by position rather than the whole list being printed back: a header\n * may declare hundreds of channels, and a message is not the place for all of them.\n */\nexport function assertSignals(signals: unknown): void {\n if (!Array.isArray(signals)) {\n throw new OptionError(\n `signals must be the channel list from a header, got ${describeValue(signals)}.`,\n );\n }\n /*\n The fields the callers read, not just the one that identifies a channel.\n\n This asked for `index` and stopped, and its callers go on to read `label` and\n `isAnnotations` off the same objects. So a list of channel-shaped objects without them\n passed the check that exists to say \"these are not channels from a header\" and failed one\n line further in, or did not fail at all:\n\n selectChannels([{ index: 0 }], ['ECG'])\n TypeError: Cannot read properties of undefined (reading 'toLowerCase')\n\n selectChannels([{ index: 0, label: 42 }], ['42'])\n TypeError: signal.label.toLowerCase is not a function\n\n buildColumnNames([{ index: 0 }])\n Map { 0 => null }\n\n The first two are the failure this function was written to remove, one level down — a\n local of this package named at a caller who wrote neither. The third is worse: a column\n name of `null`, where a channel with no label at all is named `signal_0`, so `null` is not\n a name this tool ever writes.\n */\n const FIELDS = [\n ['index', 'number'],\n ['label', 'string'],\n ['isAnnotations', 'boolean'],\n ] as const;\n for (const [at, signal] of signals.entries()) {\n if (typeof signal !== 'object' || signal === null) {\n throw new OptionError(\n `signals[${at}] is not a channel from a header, got ${describeValue(signal)}.`,\n );\n }\n const fields = signal as Record<string, unknown>;\n const wrong = FIELDS.find(([name, kind]) => typeof fields[name] !== kind);\n if (wrong === undefined) continue;\n const [name, kind] = wrong;\n throw new OptionError(\n `signals[${at}].${name} must be a ${kind}, got ${describeValue(fields[name])}. A channel ` +\n `from a header carries ${FIELDS.map(([f]) => f).join(', ')}.`,\n );\n }\n}\n\n/**\n * What `buildPlan` is told about the recording, checked the way what it is asked for is.\n *\n * `assertOptions` runs at the top of `buildPlan` and covers the second argument completely.\n * The first was not looked at, and it is the one carrying the numbers every figure in the plan\n * is derived from. Two of them missing produced a plan rather than an error:\n *\n * buildPlan({ signals, recordDuration: 1 }, {})\n * // groups: 3, estimate.rows: 0, range.endSeconds: null\n *\n * A plan saying the conversion writes nothing, handed back as an answer — which is the \"takes\n * the whole recording without saying so\" this checker exists to stop, one field over. A record\n * count below zero was worse: it came back as\n *\n * TimeRangeError: --start 0s is at or past the end of this -5s recording.\n *\n * a flag the caller never passed, about a recording that cannot exist, blaming the request for\n * the input. And `recordDuration: '1'` was coerced by the arithmetic and accepted, where the\n * same string is refused for `end` two functions down.\n *\n * A real header cannot produce any of them: the parser refuses a record duration that is not a\n * positive number, \"Infinity\" included.\n */\nexport function assertPlanInput(input: {\n signals?: unknown;\n recordDuration?: unknown;\n recordCount?: unknown;\n}): void {\n assertSignals(input.signals);\n assertRecordShape(input);\n assertPlannableSignals(input.signals as readonly Record<string, unknown>[]);\n}\n\n/**\n * The two numbers on a channel that `buildPlan` reads and `assertSignals` does not ask about.\n *\n * `assertSignals` asks for `index`, `label` and `isAnnotations`, which is what its other two\n * callers read — they name columns and match terms. `buildPlan` goes further: it groups the\n * channels by `samplingRate` and counts rows from `samplesPerRecord`, and asked nothing of\n * either. So a list of channel-shaped objects carrying the three fields it does check reached\n * the rate formatter and came back\n *\n * buildPlan({ signals: [{ index: 0, label: 'ECG', isAnnotations: false }], … }, {})\n * OptionError: hz must be a sampling rate in hertz, got undefined.\n *\n * naming `hz`, a parameter of a function three calls down, at a caller who passed `signals`.\n * That is the failure `assertSignals` exists to remove, and it is the same one its own\n * docstring quotes for `label`.\n *\n * The values it takes are the ones a header can really state, which is wider than it looks:\n * `samplesPerRecord` may be zero — that is what `NO_SAMPLES` reports — and a rate may be zero\n * or `Infinity`, because a record duration small enough to overflow the division is five\n * characters in an eight-character field. What a header cannot state is a fractional or\n * negative sample count, and those went through as arithmetic:\n *\n * samplesPerRecord: 2.5 // estimate.rows: 394.5, half a row\n * samplesPerRecord: -4 // estimate.rows falls, with nothing said\n *\n * Split out rather than folded into `assertSignals`, for the reason `assertRecordShape` gives\n * one function down: `selectChannels` and `buildColumnNames` never look at either field, and a\n * checker should not demand what its caller does not read.\n */\nfunction assertPlannableSignals(signals: readonly Record<string, unknown>[]): void {\n for (const [at, signal] of signals.entries()) {\n const rate = signal['samplingRate'];\n if (typeof rate !== 'number' || Number.isNaN(rate) || rate < 0) {\n throw new OptionError(\n `signals[${at}].samplingRate must be a sampling rate in hertz, got ` +\n `${describeValue(rate)}. It is samplesPerRecord over the record duration, and it is ` +\n `what the channels are grouped into output files by.`,\n );\n }\n const samples = signal['samplesPerRecord'];\n if (!Number.isInteger(samples) || (samples as number) < 0) {\n throw new OptionError(\n `signals[${at}].samplesPerRecord must be a whole number of samples, got ` +\n `${describeValue(samples)}. Every row this plan counts comes from it.`,\n );\n }\n }\n}\n\n/**\n * The two numbers a window is measured against, apart from the channel list.\n *\n * `resolveRange` is exported on its own and has its own signature block on the api page, and\n * `buildPlan` calls it — so it was covered only from above. Called directly it took both\n * numbers unexamined and answered with a range:\n *\n * resolveRange({ recordDuration: 1 }) // recordCount undefined\n * // { startSeconds: 0, endSeconds: null, startRecord: 0, endRecord: 0 }\n *\n * A range over no records, returned as a fact about a recording. Its own opening comment\n * already says why that is the wrong answer — \"no error, no warning, and a range read back as\n * `startSeconds: null, startRecord: null`, which is the 'takes the whole recording without\n * saying so'\" — about the three fields it does check. `resolveRange(42)` went the same way,\n * since reading `.start` off a number is `undefined` rather than a throw.\n *\n * Split out rather than calling `assertPlanInput`, which would demand a channel list this\n * function never looks at.\n */\nexport function assertRecordShape(input: {\n recordDuration?: unknown;\n recordCount?: unknown;\n recordStarts?: unknown;\n}): void {\n const { recordDuration, recordCount, recordStarts } = input ?? {};\n if (typeof recordDuration !== 'number' || !Number.isFinite(recordDuration) || recordDuration <= 0) {\n throw new OptionError(\n `recordDuration must be a positive number of seconds, got ${describeValue(recordDuration)}.`,\n );\n }\n if (!Number.isInteger(recordCount) || (recordCount as number) < 0) {\n throw new OptionError(\n `recordCount must be a whole number of data records, got ${describeValue(recordCount)}.`,\n );\n }\n /*\n And where the records really sit, which is what makes a discontinuous file's span longer\n than its duration. Nothing asked what it was, and the two ways of getting it wrong fail\n differently:\n\n resolveRange({ recordDuration: 1, recordCount: 3, recordStarts: 'x' })\n { startSeconds: 0, endSeconds: 3, startRecord: 0, endRecord: 0, isWholeRecording: true }\n\n A string is iterable, so it spreads to its characters and the span comes out over no\n records at all — a range that says it is the whole recording and covers none of it, which\n is a contradiction rather than an answer.\n\n resolveRange({ ..., recordStarts: 42 })\n TypeError: recordStarts is not iterable\n\n which names this function's own parameter at a caller holding the wrong thing. A list is\n what it takes: the `Float64Array` the reader builds, or an ordinary array — `readAnnotations`\n hands back `(number | null)[]`, and a null start is a record whose position is not known,\n which this already allows for.\n */\n if (\n recordStarts !== null &&\n recordStarts !== undefined &&\n !Array.isArray(recordStarts) &&\n !ArrayBuffer.isView(recordStarts)\n ) {\n throw new OptionError(\n `recordStarts must be a list of record start times, or null, got ` +\n `${describeValue(recordStarts)}. It is where the records really sit, which is what ` +\n `makes a discontinuous recording span more time than it holds.`,\n );\n }\n}\n\n/**\n * The recording to read, checked before it is opened.\n *\n * `EdfFile.open` hands whatever it is given to `fs`, and the refusal comes back as an\n * `EdfError` coded `UNREADABLE`, hinted \"Check the path is spelled the way it is on disk and\n * that you have permission to read it\" — advice about a path, over a value that is not one,\n * filed as a problem with the recording rather than with the call. `convert({ input: 'a.edf' })`,\n * which is the option-bag shape the second parameter has, answered `Cannot read \"[object\n * Object]\"`; `convert(['a.edf', 'b.edf'])` answered `Cannot read \"a.edf,b.edf\"`, a path the\n * caller never wrote, because `String` of an array joins it with commas.\n *\n * The empty string is left to `fs`, which has no such file and says so truthfully — the same\n * reasoning `outputDir` states for not trimming: a path is not a keyword.\n */\nexport function assertInputPath(input: unknown): void {\n if (typeof input !== 'string') {\n throw new OptionError(`input must be a path to a recording, got ${describeValue(input)}.`);\n }\n}\n\n/**\n * How a rejected value reads in the refusal: numbers bare, everything else quoted so its\n * type is visible.\n *\n * `JSON.stringify` has no text for a function or a symbol — it returns `undefined`, not a\n * string — so `layout: () => 'long'` came back as `layout must be \"wide\" or \"long\", got\n * undefined.`, which names the one value that does not raise this: every option here is\n * optional, and `undefined` is how a caller says they are not passing it. `input` was worse,\n * since `convert(undefined)` and `convert(someFunction)` then produced the same sentence, and\n * the first is a forgotten argument while the second is a wrong one.\n *\n * Exported because time-range.ts had the identical function, fixed there and not here — the\n * same two-copies-of-one-helper the derived precision and the pluraliser were each pulled\n * together for.\n */\nexport function describeValue(value: unknown): string {\n return typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value);\n}\n"]}
|
package/dist/edf/scale.js
CHANGED
|
@@ -52,6 +52,40 @@ function assertCalibration(signal) {
|
|
|
52
52
|
throw new OptionError(`signal.${missing} must be a number, got ${describeValue(signal[missing])}.`);
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The one argument a scaler takes, confirmed to be a number before it is added to one.
|
|
57
|
+
*
|
|
58
|
+
* The arithmetic below is `gain * (offset + digital)`, and `+` on a string concatenates. A
|
|
59
|
+
* sample that arrived as text — from `JSON.parse` of a stored record, a CSV read back, a form
|
|
60
|
+
* field, every door `assertOptions` names for the options — was pasted onto the offset instead
|
|
61
|
+
* of added to it:
|
|
62
|
+
*
|
|
63
|
+
* const scale = makeScaler(signal); // ±250 uV over a 12-bit range
|
|
64
|
+
* scale(42) // 5.1892551892551895
|
|
65
|
+
* scale('42') // 0.06617826617826618 offset 0.5, so 0.5 + '42' is '0.542'
|
|
66
|
+
* scale(null) // the value for digital 0
|
|
67
|
+
* scale(true) // the value for digital 1
|
|
68
|
+
*
|
|
69
|
+
* Every one of them is a physical value this channel could really have recorded: in range, in
|
|
70
|
+
* the right unit, printed to the right precision, and wrong by a factor of seventy-eight. This
|
|
71
|
+
* is the function the api page recommends for reading physical units out of a file, and "never
|
|
72
|
+
* invent a number" is the promise the whole tool is built on.
|
|
73
|
+
*
|
|
74
|
+
* The two branches did not even agree about it. The fallback arrangement below is
|
|
75
|
+
* `(digital - digitalMin) * gain + physicalMin`, and `-` coerces where `+` concatenates, so
|
|
76
|
+
* `'42'` came back correct there and wrong here — one function, two answers, decided by
|
|
77
|
+
* whether the header's offset overflowed.
|
|
78
|
+
*
|
|
79
|
+
* Only the two closures that read the argument ask this. The three that return a constant —
|
|
80
|
+
* a degenerate digital range, a flat physical one — give the same answer for every sample of
|
|
81
|
+
* such a channel, which is the right answer whatever they are handed.
|
|
82
|
+
*/
|
|
83
|
+
function assertDigital(digital) {
|
|
84
|
+
if (typeof digital !== 'number') {
|
|
85
|
+
throw new OptionError(`A scaler takes one digital sample, got ${describeValue(digital)}. It is the raw integer ` +
|
|
86
|
+
`out of the record — what EdfFile.sampleAt returns — not its text.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
55
89
|
export function makeScaler(signal) {
|
|
56
90
|
/*
|
|
57
91
|
The argument, checked like the arguments of the other exported functions.
|
|
@@ -113,9 +147,15 @@ export function makeScaler(signal) {
|
|
|
113
147
|
// arrangement rather than emitting Infinity.
|
|
114
148
|
const offset = physicalMax / gain - digitalMax;
|
|
115
149
|
if (!Number.isFinite(offset)) {
|
|
116
|
-
return (digital) =>
|
|
150
|
+
return (digital) => {
|
|
151
|
+
assertDigital(digital);
|
|
152
|
+
return (digital - digitalMin) * gain + physicalMin;
|
|
153
|
+
};
|
|
117
154
|
}
|
|
118
|
-
return (digital) =>
|
|
155
|
+
return (digital) => {
|
|
156
|
+
assertDigital(digital);
|
|
157
|
+
return gain * (offset + digital);
|
|
158
|
+
};
|
|
119
159
|
}
|
|
120
160
|
/**
|
|
121
161
|
* Smallest physical step this channel can express — one digital unit.
|
package/dist/edf/scale.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scale.js","sourceRoot":"","sources":["../../src/edf/scale.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Digital-to-physical conversion.\n *\n * EDF defines the mapping by two calibration points, (digitalMin -> physicalMin)\n * and (digitalMax -> physicalMax), which the specification writes as:\n *\n * gain = (physicalMax - physicalMin) / (digitalMax - digitalMin)\n * physical = (digital - digitalMin) * gain + physicalMin\n *\n * That form is evaluated here in EDFlib's algebraically equivalent arrangement:\n *\n * offset = physicalMax / gain - digitalMax\n * physical = gain * (offset + digital)\n *\n * The rearrangement is not cosmetic. Written the first way, a channel spanning\n * +/-800 uV computes a value near 800 and then subtracts 800, and the cancellation\n * throws away low-order bits: digital 0 yields 0.19536019536019467 when the exact\n * value is 0.19536019536019536. EDFlib's form keeps the intermediate small\n * (offset + digital = 0.5 here) and returns the correctly rounded result.\n *\n * Both properties matter. The values are as accurate as a double can express, and\n * they are bit-identical to pyEDFlib and EDFbrowser, which share EDFlib's arithmetic,\n * so the test suite can assert exact equality against a reference implementation\n * rather than settling for a tolerance.\n */\n\nimport type { EdfSignal } from './header.js';\nimport { OptionError, describeValue } from '../convert/options.js';\n\nexport type Scaler = (digital: number) => number;\n\n/** The four header fields this reads, in the order a message should name them. */\nconst CALIBRATION = ['digitalMin', 'digitalMax', 'physicalMin', 'physicalMax'] as const;\n\n/**\n * The four calibration numbers, confirmed to be on the thing that was passed.\n *\n * Written for `makeScaler` in 0.8.61 and shared since 0.8.76, when the two functions beside\n * it turned out to read the same four fields off the same argument and ask nothing of it.\n * Both answer rather than refuse:\n *\n * quantizationStep({}) // 0 — the step of a channel whose header contradicts itself\n * decimalsForSignal(42) // 3 — the precision an ordinary EEG channel gets\n *\n * `undefined - undefined` is `NaN`, `NaN === 0` is false, and the division that follows gives\n * `NaN`; `quantizationStep` returns it as a step, and `decimalsForSignal` reads a step that is\n * not a positive number as \"this channel has none to derive from\" and falls back to three\n * places. Both of those are real answers for real channels — a zero digital span is what\n * `DEGENERATE_DIGITAL_RANGE` reports, and three places is what most EEG gets — so a caller\n * holding the wrong object gets a number they have no way to doubt.\n */\nfunction assertCalibration(signal: EdfSignal): void {\n if (typeof signal !== 'object' || signal === null) {\n throw new OptionError(`signal must be a channel from a header, got ${describeValue(signal)}.`);\n }\n const missing = CALIBRATION.find((name) => typeof signal[name] !== 'number');\n if (missing !== undefined) {\n throw new OptionError(\n `signal.${missing} must be a number, got ${describeValue(signal[missing])}.`,\n );\n }\n}\n\nexport function makeScaler(signal: EdfSignal): Scaler {\n /*\n The argument, checked like the arguments of the other exported functions.\n\n Every branch below reads four numbers off `signal`, and the first of them — the one that\n catches a header contradicting itself — is `digitalMax === digitalMin`. On an object that\n has neither, that comparison is `undefined === undefined`, which is true. So\n `makeScaler({})` came back as a working function returning NaN for every sample, which is\n exactly what a real channel with a zero digital span returns.\n\n A caller cannot tell the two apart. The api page recommends this function for reading\n physical units out of a file, and the empty column it produces is documented as meaning\n \"the header contradicts itself\" — a sentence about the recording, over a call that passed\n the wrong object. The diagnostic that normally accompanies it, DEGENERATE_DIGITAL_RANGE,\n comes from the header parser and is not raised here at all.\n */\n assertCalibration(signal);\n const { digitalMin, digitalMax, physicalMin, physicalMax } = signal;\n\n // A zero digital span leaves the mapping undefined — the header contradicts itself,\n // so there is no physical value for any sample on this channel.\n //\n // NaN rather than a stand-in number. Writing the physical minimum produces a column\n // of plausible readings (\"-100.000\" repeated) that is indistinguishable from a real\n // flat recording once the CSV is opened somewhere else, which is exactly the kind of\n // invented data this tool exists to avoid. NaN carries through to an empty CSV cell\n // and reads back as NaN in pandas, matching how a missing annotation duration is\n // already written. DEGENERATE_DIGITAL_RANGE is raised alongside it.\n if (digitalMax === digitalMin) return () => NaN;\n\n const gain = (physicalMax - physicalMin) / (digitalMax - digitalMin);\n\n /*\n A flat physical range makes every sample the same value, and would divide by zero in the\n offset below. That mapping is defined, so its constant is written.\n\n A gain of zero does not always mean flat, and this could not tell the difference. A range\n of -1e-320 to 1e-320 is not flat — it is 65,536 distinct physical values — but the gain\n is 2e-320/65535, which is smaller than the smallest subnormal double and underflows to\n +0. Every distinct sample then took `physicalMin`, so eight codes spanning -16,000 to\n +12,000 came out as one repeated number, with no diagnostic anywhere and `--strict`\n exiting 0. At 1e-319, one power of ten away, the same file raises VALUE_RESOLUTION.\n\n That is the same situation as the overflow below it, which this codebase already reasoned\n about and answered: the span cannot be represented, so there is no mapping, so the cells\n are left empty rather than filled with a value the header cannot justify. Underflow only\n got the flat-range treatment because `gain === 0` is what both look like from here.\n */\n if (gain === 0) return physicalMax === physicalMin ? (): number => physicalMin : (): number => NaN;\n\n // A non-finite gain is a different thing: the physical span overflowed a double, so\n // there is no mapping at all. Returning physicalMin filled the column with one enormous\n // constant — every distinct sample rendered as the same 300-digit number — and raised\n // nothing. NaN takes the same route as a degenerate digital range: empty cells, plus\n // UNUSABLE_PHYSICAL_RANGE from the header parser.\n if (!Number.isFinite(gain)) return () => NaN;\n\n // Deriving the offset divides by the gain. For every realistic calibration that is\n // both safe and more accurate, but an absurd header (a huge physical range over a\n // near-zero gain) could overflow it, so fall back to the specification's own\n // arrangement rather than emitting Infinity.\n const offset = physicalMax / gain - digitalMax;\n if (!Number.isFinite(offset)) {\n return (digital: number): number => (digital - digitalMin) * gain + physicalMin;\n }\n\n return (digital: number): number => gain * (offset + digital);\n}\n\n/**\n * Smallest physical step this channel can express — one digital unit.\n * Used to choose a decimal precision that preserves every distinct sample value.\n */\nexport function quantizationStep(signal: EdfSignal): number {\n // See `assertCalibration`: without it `quantizationStep({})` answered 0, which is the step\n // of a channel whose header contradicts itself.\n assertCalibration(signal);\n const digitalSpan = signal.digitalMax - signal.digitalMin;\n if (digitalSpan === 0) return 0;\n return Math.abs((signal.physicalMax - signal.physicalMin) / digitalSpan);\n}\n\n/**\n * The most `toFixed` accepts. 101 is a RangeError, so this is the ceiling, not a taste.\n *\n * It used to be 20, on the stated grounds that 20 was what `toFixed` allowed. It is not,\n * and the gap was not academic: a magnetometer channel spanning ±1e-16 T over a 16-bit\n * converter has a step of 3.05e-21 and needs 23 places. Clamped to 20, every value landed\n * on a 1e-20 grid — about three digital codes to a printed value — so 69% of the samples\n * could not be recovered, the conversion exited 0, and nothing said a word. The channel\n * type the old comment named as the reason for the ceiling was the one it broke.\n */\nconst MAX_DERIVED_DECIMALS = 100;\n\n/**\n * Places this channel needs before any ceiling, or null when it has no step to derive one from.\n *\n * One expression, because two functions depend on agreeing about it. `decimalsForSignal`\n * computed `Math.ceil(-Math.log10(step)) + 2` and clamped it; `decimalsAreClamped` computed the\n * same thing again and compared it to the same ceiling. Two copies of one formula whose only\n * job is to give the same answer — change either `+ 2` and they part company at the boundary,\n * so a channel whose precision really was capped is reported as not capped, VALUE_RESOLUTION is\n * not raised, and its codes print indistinguishable in silence. Which is the exact thing that\n * warning exists to say.\n */\nfunction decimalsNeeded(signal: EdfSignal): number | null {\n const step = quantizationStep(signal);\n if (!(step > 0) || !Number.isFinite(step)) return null;\n return Math.ceil(-Math.log10(step)) + 2;\n}\n\n/**\n * Decimal places needed so that two adjacent digital codes never round to the same\n * string. Two places past the quantization step keep rounding error far below the\n * resolution the hardware actually recorded, without padding the file with digits\n * that carry no information.\n *\n * Ordinary channels land at three or four: a ±800 µV channel over 12 bits steps by\n * 0.39 µV and needs three. The ceiling is only reached by calibrations whose step is\n * below 1e-98, which an 8-character physical bound can still express — `1e-99` is five\n * characters. Those get VALUE_RESOLUTION rather than silence.\n */\nexport function decimalsForSignal(signal: EdfSignal, max = MAX_DERIVED_DECIMALS): number {\n /*\n The ceiling, which is the caller's to choose and was the caller's to get wrong.\n\n 0.8.76 checked the channel. The second argument went on being whatever was passed, and\n `Math.min` carries it straight out:\n\n decimalsForSignal(signal, -5) // -5\n decimalsForSignal(signal, 2.5) // 2.5\n decimalsForSignal(signal, 'x') // NaN\n\n A negative number of decimal places, a fractional one, and not a number — from the\n function whose whole answer is how many places a column needs. Each is a `RangeError` out\n of `toFixed` at the point the caller uses it, one call later and somewhere else.\n\n A whole number of places, and not bounded above: handing this a ceiling nothing can reach\n is how a caller asks what a channel would need without one, which is how\n `decimalsAreClamped` is checked against this function rather than against its own copy of\n the formula.\n */\n if (!Number.isInteger(max) || max < 0) {\n throw new OptionError(\n `max must be a whole number of decimal places, zero or more, got ${describeValue(max)}. ` +\n `It is a ceiling on the precision this derives; the default is ` +\n `${MAX_DERIVED_DECIMALS}, which is what toFixed accepts.`,\n );\n }\n const needed = decimalsNeeded(signal);\n // The ceiling applies to the fallback too. A channel with no step to derive from takes the\n // ordinary three places, and `decimalsForSignal(signal, 0)` returned them — a ceiling of\n // zero answered with three, on the one branch that does not measure anything.\n if (needed === null) return Math.min(max, 3);\n return Math.min(max, Math.max(0, needed));\n}\n\n/**\n * Whether this channel's step is finer than any precision the tool can print.\n *\n * Asked of the ceiling, not of the precision in use. `--decimals 2` on a channel needing 3\n * is a trade the caller made knowingly and is not this warning's business — 0.5.10 fixed a\n * version of this that fired on every ordinary EEG at `--decimals 2` and made\n * `--decimals 2 --strict` impossible. But it fixed it by asking \"did the caller choose the\n * precision\", which suppressed the real case too: at `--decimals 20` a channel stepping by\n * 1e-106 printed every one of its codes as `0.00000000000000000000`, in silence.\n *\n * The question is whether anything the tool can print would separate consecutive codes. When\n * the answer is no, that is a ceiling nobody chose, and it holds whatever `--decimals` says.\n */\nexport function decimalsAreClamped(signal: EdfSignal): boolean {\n const needed = decimalsNeeded(signal);\n return needed !== null && needed > MAX_DERIVED_DECIMALS;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"scale.js","sourceRoot":"","sources":["../../src/edf/scale.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Digital-to-physical conversion.\n *\n * EDF defines the mapping by two calibration points, (digitalMin -> physicalMin)\n * and (digitalMax -> physicalMax), which the specification writes as:\n *\n * gain = (physicalMax - physicalMin) / (digitalMax - digitalMin)\n * physical = (digital - digitalMin) * gain + physicalMin\n *\n * That form is evaluated here in EDFlib's algebraically equivalent arrangement:\n *\n * offset = physicalMax / gain - digitalMax\n * physical = gain * (offset + digital)\n *\n * The rearrangement is not cosmetic. Written the first way, a channel spanning\n * +/-800 uV computes a value near 800 and then subtracts 800, and the cancellation\n * throws away low-order bits: digital 0 yields 0.19536019536019467 when the exact\n * value is 0.19536019536019536. EDFlib's form keeps the intermediate small\n * (offset + digital = 0.5 here) and returns the correctly rounded result.\n *\n * Both properties matter. The values are as accurate as a double can express, and\n * they are bit-identical to pyEDFlib and EDFbrowser, which share EDFlib's arithmetic,\n * so the test suite can assert exact equality against a reference implementation\n * rather than settling for a tolerance.\n */\n\nimport type { EdfSignal } from './header.js';\nimport { OptionError, describeValue } from '../convert/options.js';\n\nexport type Scaler = (digital: number) => number;\n\n/** The four header fields this reads, in the order a message should name them. */\nconst CALIBRATION = ['digitalMin', 'digitalMax', 'physicalMin', 'physicalMax'] as const;\n\n/**\n * The four calibration numbers, confirmed to be on the thing that was passed.\n *\n * Written for `makeScaler` in 0.8.61 and shared since 0.8.76, when the two functions beside\n * it turned out to read the same four fields off the same argument and ask nothing of it.\n * Both answer rather than refuse:\n *\n * quantizationStep({}) // 0 — the step of a channel whose header contradicts itself\n * decimalsForSignal(42) // 3 — the precision an ordinary EEG channel gets\n *\n * `undefined - undefined` is `NaN`, `NaN === 0` is false, and the division that follows gives\n * `NaN`; `quantizationStep` returns it as a step, and `decimalsForSignal` reads a step that is\n * not a positive number as \"this channel has none to derive from\" and falls back to three\n * places. Both of those are real answers for real channels — a zero digital span is what\n * `DEGENERATE_DIGITAL_RANGE` reports, and three places is what most EEG gets — so a caller\n * holding the wrong object gets a number they have no way to doubt.\n */\nfunction assertCalibration(signal: EdfSignal): void {\n if (typeof signal !== 'object' || signal === null) {\n throw new OptionError(`signal must be a channel from a header, got ${describeValue(signal)}.`);\n }\n const missing = CALIBRATION.find((name) => typeof signal[name] !== 'number');\n if (missing !== undefined) {\n throw new OptionError(\n `signal.${missing} must be a number, got ${describeValue(signal[missing])}.`,\n );\n }\n}\n\n/**\n * The one argument a scaler takes, confirmed to be a number before it is added to one.\n *\n * The arithmetic below is `gain * (offset + digital)`, and `+` on a string concatenates. A\n * sample that arrived as text — from `JSON.parse` of a stored record, a CSV read back, a form\n * field, every door `assertOptions` names for the options — was pasted onto the offset instead\n * of added to it:\n *\n * const scale = makeScaler(signal); // ±250 uV over a 12-bit range\n * scale(42) // 5.1892551892551895\n * scale('42') // 0.06617826617826618 offset 0.5, so 0.5 + '42' is '0.542'\n * scale(null) // the value for digital 0\n * scale(true) // the value for digital 1\n *\n * Every one of them is a physical value this channel could really have recorded: in range, in\n * the right unit, printed to the right precision, and wrong by a factor of seventy-eight. This\n * is the function the api page recommends for reading physical units out of a file, and \"never\n * invent a number\" is the promise the whole tool is built on.\n *\n * The two branches did not even agree about it. The fallback arrangement below is\n * `(digital - digitalMin) * gain + physicalMin`, and `-` coerces where `+` concatenates, so\n * `'42'` came back correct there and wrong here — one function, two answers, decided by\n * whether the header's offset overflowed.\n *\n * Only the two closures that read the argument ask this. The three that return a constant —\n * a degenerate digital range, a flat physical one — give the same answer for every sample of\n * such a channel, which is the right answer whatever they are handed.\n */\nfunction assertDigital(digital: number): void {\n if (typeof digital !== 'number') {\n throw new OptionError(\n `A scaler takes one digital sample, got ${describeValue(digital)}. It is the raw integer ` +\n `out of the record — what EdfFile.sampleAt returns — not its text.`,\n );\n }\n}\n\nexport function makeScaler(signal: EdfSignal): Scaler {\n /*\n The argument, checked like the arguments of the other exported functions.\n\n Every branch below reads four numbers off `signal`, and the first of them — the one that\n catches a header contradicting itself — is `digitalMax === digitalMin`. On an object that\n has neither, that comparison is `undefined === undefined`, which is true. So\n `makeScaler({})` came back as a working function returning NaN for every sample, which is\n exactly what a real channel with a zero digital span returns.\n\n A caller cannot tell the two apart. The api page recommends this function for reading\n physical units out of a file, and the empty column it produces is documented as meaning\n \"the header contradicts itself\" — a sentence about the recording, over a call that passed\n the wrong object. The diagnostic that normally accompanies it, DEGENERATE_DIGITAL_RANGE,\n comes from the header parser and is not raised here at all.\n */\n assertCalibration(signal);\n const { digitalMin, digitalMax, physicalMin, physicalMax } = signal;\n\n // A zero digital span leaves the mapping undefined — the header contradicts itself,\n // so there is no physical value for any sample on this channel.\n //\n // NaN rather than a stand-in number. Writing the physical minimum produces a column\n // of plausible readings (\"-100.000\" repeated) that is indistinguishable from a real\n // flat recording once the CSV is opened somewhere else, which is exactly the kind of\n // invented data this tool exists to avoid. NaN carries through to an empty CSV cell\n // and reads back as NaN in pandas, matching how a missing annotation duration is\n // already written. DEGENERATE_DIGITAL_RANGE is raised alongside it.\n if (digitalMax === digitalMin) return () => NaN;\n\n const gain = (physicalMax - physicalMin) / (digitalMax - digitalMin);\n\n /*\n A flat physical range makes every sample the same value, and would divide by zero in the\n offset below. That mapping is defined, so its constant is written.\n\n A gain of zero does not always mean flat, and this could not tell the difference. A range\n of -1e-320 to 1e-320 is not flat — it is 65,536 distinct physical values — but the gain\n is 2e-320/65535, which is smaller than the smallest subnormal double and underflows to\n +0. Every distinct sample then took `physicalMin`, so eight codes spanning -16,000 to\n +12,000 came out as one repeated number, with no diagnostic anywhere and `--strict`\n exiting 0. At 1e-319, one power of ten away, the same file raises VALUE_RESOLUTION.\n\n That is the same situation as the overflow below it, which this codebase already reasoned\n about and answered: the span cannot be represented, so there is no mapping, so the cells\n are left empty rather than filled with a value the header cannot justify. Underflow only\n got the flat-range treatment because `gain === 0` is what both look like from here.\n */\n if (gain === 0) return physicalMax === physicalMin ? (): number => physicalMin : (): number => NaN;\n\n // A non-finite gain is a different thing: the physical span overflowed a double, so\n // there is no mapping at all. Returning physicalMin filled the column with one enormous\n // constant — every distinct sample rendered as the same 300-digit number — and raised\n // nothing. NaN takes the same route as a degenerate digital range: empty cells, plus\n // UNUSABLE_PHYSICAL_RANGE from the header parser.\n if (!Number.isFinite(gain)) return () => NaN;\n\n // Deriving the offset divides by the gain. For every realistic calibration that is\n // both safe and more accurate, but an absurd header (a huge physical range over a\n // near-zero gain) could overflow it, so fall back to the specification's own\n // arrangement rather than emitting Infinity.\n const offset = physicalMax / gain - digitalMax;\n if (!Number.isFinite(offset)) {\n return (digital: number): number => {\n assertDigital(digital);\n return (digital - digitalMin) * gain + physicalMin;\n };\n }\n\n return (digital: number): number => {\n assertDigital(digital);\n return gain * (offset + digital);\n };\n}\n\n/**\n * Smallest physical step this channel can express — one digital unit.\n * Used to choose a decimal precision that preserves every distinct sample value.\n */\nexport function quantizationStep(signal: EdfSignal): number {\n // See `assertCalibration`: without it `quantizationStep({})` answered 0, which is the step\n // of a channel whose header contradicts itself.\n assertCalibration(signal);\n const digitalSpan = signal.digitalMax - signal.digitalMin;\n if (digitalSpan === 0) return 0;\n return Math.abs((signal.physicalMax - signal.physicalMin) / digitalSpan);\n}\n\n/**\n * The most `toFixed` accepts. 101 is a RangeError, so this is the ceiling, not a taste.\n *\n * It used to be 20, on the stated grounds that 20 was what `toFixed` allowed. It is not,\n * and the gap was not academic: a magnetometer channel spanning ±1e-16 T over a 16-bit\n * converter has a step of 3.05e-21 and needs 23 places. Clamped to 20, every value landed\n * on a 1e-20 grid — about three digital codes to a printed value — so 69% of the samples\n * could not be recovered, the conversion exited 0, and nothing said a word. The channel\n * type the old comment named as the reason for the ceiling was the one it broke.\n */\nconst MAX_DERIVED_DECIMALS = 100;\n\n/**\n * Places this channel needs before any ceiling, or null when it has no step to derive one from.\n *\n * One expression, because two functions depend on agreeing about it. `decimalsForSignal`\n * computed `Math.ceil(-Math.log10(step)) + 2` and clamped it; `decimalsAreClamped` computed the\n * same thing again and compared it to the same ceiling. Two copies of one formula whose only\n * job is to give the same answer — change either `+ 2` and they part company at the boundary,\n * so a channel whose precision really was capped is reported as not capped, VALUE_RESOLUTION is\n * not raised, and its codes print indistinguishable in silence. Which is the exact thing that\n * warning exists to say.\n */\nfunction decimalsNeeded(signal: EdfSignal): number | null {\n const step = quantizationStep(signal);\n if (!(step > 0) || !Number.isFinite(step)) return null;\n return Math.ceil(-Math.log10(step)) + 2;\n}\n\n/**\n * Decimal places needed so that two adjacent digital codes never round to the same\n * string. Two places past the quantization step keep rounding error far below the\n * resolution the hardware actually recorded, without padding the file with digits\n * that carry no information.\n *\n * Ordinary channels land at three or four: a ±800 µV channel over 12 bits steps by\n * 0.39 µV and needs three. The ceiling is only reached by calibrations whose step is\n * below 1e-98, which an 8-character physical bound can still express — `1e-99` is five\n * characters. Those get VALUE_RESOLUTION rather than silence.\n */\nexport function decimalsForSignal(signal: EdfSignal, max = MAX_DERIVED_DECIMALS): number {\n /*\n The ceiling, which is the caller's to choose and was the caller's to get wrong.\n\n 0.8.76 checked the channel. The second argument went on being whatever was passed, and\n `Math.min` carries it straight out:\n\n decimalsForSignal(signal, -5) // -5\n decimalsForSignal(signal, 2.5) // 2.5\n decimalsForSignal(signal, 'x') // NaN\n\n A negative number of decimal places, a fractional one, and not a number — from the\n function whose whole answer is how many places a column needs. Each is a `RangeError` out\n of `toFixed` at the point the caller uses it, one call later and somewhere else.\n\n A whole number of places, and not bounded above: handing this a ceiling nothing can reach\n is how a caller asks what a channel would need without one, which is how\n `decimalsAreClamped` is checked against this function rather than against its own copy of\n the formula.\n */\n if (!Number.isInteger(max) || max < 0) {\n throw new OptionError(\n `max must be a whole number of decimal places, zero or more, got ${describeValue(max)}. ` +\n `It is a ceiling on the precision this derives; the default is ` +\n `${MAX_DERIVED_DECIMALS}, which is what toFixed accepts.`,\n );\n }\n const needed = decimalsNeeded(signal);\n // The ceiling applies to the fallback too. A channel with no step to derive from takes the\n // ordinary three places, and `decimalsForSignal(signal, 0)` returned them — a ceiling of\n // zero answered with three, on the one branch that does not measure anything.\n if (needed === null) return Math.min(max, 3);\n return Math.min(max, Math.max(0, needed));\n}\n\n/**\n * Whether this channel's step is finer than any precision the tool can print.\n *\n * Asked of the ceiling, not of the precision in use. `--decimals 2` on a channel needing 3\n * is a trade the caller made knowingly and is not this warning's business — 0.5.10 fixed a\n * version of this that fired on every ordinary EEG at `--decimals 2` and made\n * `--decimals 2 --strict` impossible. But it fixed it by asking \"did the caller choose the\n * precision\", which suppressed the real case too: at `--decimals 20` a channel stepping by\n * 1e-106 printed every one of its codes as `0.00000000000000000000`, in silence.\n *\n * The question is whether anything the tool can print would separate consecutive codes. When\n * the answer is no, that is a ceiling nobody chose, and it holds whatever `--decimals` says.\n */\nexport function decimalsAreClamped(signal: EdfSignal): boolean {\n const needed = decimalsNeeded(signal);\n return needed !== null && needed > MAX_DERIVED_DECIMALS;\n}\n"]}
|
package/package.json
CHANGED