edf2csv 0.9.13 → 0.9.15
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
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* These run at the top of `buildPlan`, which every path goes through before a directory is
|
|
15
15
|
* created or a stream is opened, so a rejected option leaves nothing behind.
|
|
16
16
|
*/
|
|
17
|
+
import { counted, grouped } from '../format/list.js';
|
|
17
18
|
/** A problem with the options a caller passed, as opposed to a problem with the file. */
|
|
18
19
|
export class OptionError extends Error {
|
|
19
20
|
constructor(message) {
|
|
@@ -466,6 +467,32 @@ export function assertRecordShape(input) {
|
|
|
466
467
|
*/
|
|
467
468
|
if (recordStarts !== null && recordStarts !== undefined) {
|
|
468
469
|
const starts = recordStarts;
|
|
470
|
+
/*
|
|
471
|
+
And one for every record, which is what the field is documented to be: "True start time
|
|
472
|
+
of each data record". A shorter list is not a partial answer, it is a shorter recording:
|
|
473
|
+
|
|
474
|
+
resolveRange({ recordDuration: 1, recordCount: 3, recordStarts: [0] })
|
|
475
|
+
{ startSeconds: 0, endSeconds: 1, startRecord: 0, endRecord: 1,
|
|
476
|
+
isWholeRecording: true }
|
|
477
|
+
|
|
478
|
+
One record of the three, called the whole recording — the same contradiction the check
|
|
479
|
+
below removes for a list of strings, reached by leaving entries out instead of filling
|
|
480
|
+
them wrongly. `span` reads the earliest and latest off whatever it is given and
|
|
481
|
+
`selectRecords` matches only the indexes it holds, so a list of one describes a file of
|
|
482
|
+
one however many records the caller said there were.
|
|
483
|
+
|
|
484
|
+
Empty is the exception, and it is not a short list: `[]` is how "no record times are
|
|
485
|
+
known" arrives, which the two functions below already answer by falling back to
|
|
486
|
+
contiguous positions. `null` says the same thing and is the form the reader hands over.
|
|
487
|
+
*/
|
|
488
|
+
if (starts.length !== 0 && starts.length !== recordCount) {
|
|
489
|
+
// Both counts through the helpers, like every other sentence that puts one number
|
|
490
|
+
// against another: the phrase exists to compare them.
|
|
491
|
+
throw new OptionError(`recordStarts has ${grouped(starts.length)} of the ` +
|
|
492
|
+
`${counted(recordCount, 'record start time')} this recording needs. It is ` +
|
|
493
|
+
`where each data record really sits, so a shorter list describes a shorter ` +
|
|
494
|
+
`recording; pass null, or an empty list, where none are known.`);
|
|
495
|
+
}
|
|
469
496
|
for (let at = 0; at < starts.length; at++) {
|
|
470
497
|
const start = starts[at];
|
|
471
498
|
if (start === null || start === undefined || Number.isFinite(start))
|
|
@@ -1 +1 @@
|
|
|
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 bag itself, before anything is read out of it.\n\n Every check below reads `options.decimals` and its neighbours, and both functions that run\n this declare the parameter with a default of `{}` — which covers `undefined` and nothing\n else. So a value that is not an object had its properties read off it, came back\n `undefined`, and meant what `undefined` means here: that option was not given.\n\n convert('rec.edf', 'out') // converts to rec_csv, and reports success\n buildPlan(input, 42) // the whole recording, wide, three decimals\n convert('rec.edf', null) // TypeError: Cannot read properties of null\n\n `convert(file, 'out')` is the one that costs something. The second parameter is an option\n bag and the string looks like a destination, which is a mistake worth making — and the\n rows went to `<recording>_csv` beside the input, a directory the caller had not named,\n with `result.outputDir` reporting where they really went to nobody who was reading it.\n That is the sentence `outputDir: null` has at the top of this file, arrived at through the\n argument in front of it.\n\n `resolveRange` was given this check on its own bag in 0.8.75 and `readRecords` on its in\n 0.9.2. These are the last two exported functions that take one.\n */\n // An array is an object and carries none of these properties, so it went the same way as a\n // string with none of the same visibility — `convert(file, ['a.edf', 'b.edf'])` is the\n // second-argument twin of the mistake `assertInputPath` was written for.\n if (typeof options !== 'object' || options === null || Array.isArray(options)) {\n throw new OptionError(\n `options must be an object of options, got ${describeValue(options)}. The destination ` +\n `goes in it as outputDir; omit it for the defaults.`,\n );\n }\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 And what is in the list, which is where the failure above actually lands.\n\n That check asks whether the argument is a list and stops, and the contradiction its own\n paragraph describes for a string comes straight back from a list of them:\n\n resolveRange({ recordDuration: 1, recordCount: 3, recordStarts: ['a', 'b', 'c'] })\n { startSeconds: 0, endSeconds: 3, startRecord: 0, endRecord: 0, isWholeRecording: true }\n\n A range that calls itself the whole recording and covers none of it. `span` reads each\n start to find the earliest and the latest, and every comparison against a string is false,\n so it falls back to the contiguous span — which is why `endSeconds` looks right. Then\n `selectRecords` compares the same strings again and matches no record at all. `NaN` takes\n the identical route, and `NaN` is what a list built by parsing text arrives as.\n\n A record whose position is unknown is `null`, which `readAnnotations` really does hand\n back and which the code below already places from its neighbours. That is the one\n non-number this takes.\n */\n if (recordStarts !== null && recordStarts !== undefined) {\n const starts = recordStarts as ArrayLike<unknown>;\n for (let at = 0; at < starts.length; at++) {\n const start = starts[at];\n if (start === null || start === undefined || Number.isFinite(start)) continue;\n throw new OptionError(\n `recordStarts[${at}] must be the second that record starts at, or null where it is ` +\n `not known, got ${describeValue(start)}. A start that is not a number matches no ` +\n `record, so the window comes back empty and calls itself the whole recording.`,\n );\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\nimport { counted, grouped } from '../format/list.js';\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 bag itself, before anything is read out of it.\n\n Every check below reads `options.decimals` and its neighbours, and both functions that run\n this declare the parameter with a default of `{}` — which covers `undefined` and nothing\n else. So a value that is not an object had its properties read off it, came back\n `undefined`, and meant what `undefined` means here: that option was not given.\n\n convert('rec.edf', 'out') // converts to rec_csv, and reports success\n buildPlan(input, 42) // the whole recording, wide, three decimals\n convert('rec.edf', null) // TypeError: Cannot read properties of null\n\n `convert(file, 'out')` is the one that costs something. The second parameter is an option\n bag and the string looks like a destination, which is a mistake worth making — and the\n rows went to `<recording>_csv` beside the input, a directory the caller had not named,\n with `result.outputDir` reporting where they really went to nobody who was reading it.\n That is the sentence `outputDir: null` has at the top of this file, arrived at through the\n argument in front of it.\n\n `resolveRange` was given this check on its own bag in 0.8.75 and `readRecords` on its in\n 0.9.2. These are the last two exported functions that take one.\n */\n // An array is an object and carries none of these properties, so it went the same way as a\n // string with none of the same visibility — `convert(file, ['a.edf', 'b.edf'])` is the\n // second-argument twin of the mistake `assertInputPath` was written for.\n if (typeof options !== 'object' || options === null || Array.isArray(options)) {\n throw new OptionError(\n `options must be an object of options, got ${describeValue(options)}. The destination ` +\n `goes in it as outputDir; omit it for the defaults.`,\n );\n }\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 And what is in the list, which is where the failure above actually lands.\n\n That check asks whether the argument is a list and stops, and the contradiction its own\n paragraph describes for a string comes straight back from a list of them:\n\n resolveRange({ recordDuration: 1, recordCount: 3, recordStarts: ['a', 'b', 'c'] })\n { startSeconds: 0, endSeconds: 3, startRecord: 0, endRecord: 0, isWholeRecording: true }\n\n A range that calls itself the whole recording and covers none of it. `span` reads each\n start to find the earliest and the latest, and every comparison against a string is false,\n so it falls back to the contiguous span — which is why `endSeconds` looks right. Then\n `selectRecords` compares the same strings again and matches no record at all. `NaN` takes\n the identical route, and `NaN` is what a list built by parsing text arrives as.\n\n A record whose position is unknown is `null`, which `readAnnotations` really does hand\n back and which the code below already places from its neighbours. That is the one\n non-number this takes.\n */\n if (recordStarts !== null && recordStarts !== undefined) {\n const starts = recordStarts as ArrayLike<unknown>;\n /*\n And one for every record, which is what the field is documented to be: \"True start time\n of each data record\". A shorter list is not a partial answer, it is a shorter recording:\n\n resolveRange({ recordDuration: 1, recordCount: 3, recordStarts: [0] })\n { startSeconds: 0, endSeconds: 1, startRecord: 0, endRecord: 1,\n isWholeRecording: true }\n\n One record of the three, called the whole recording — the same contradiction the check\n below removes for a list of strings, reached by leaving entries out instead of filling\n them wrongly. `span` reads the earliest and latest off whatever it is given and\n `selectRecords` matches only the indexes it holds, so a list of one describes a file of\n one however many records the caller said there were.\n\n Empty is the exception, and it is not a short list: `[]` is how \"no record times are\n known\" arrives, which the two functions below already answer by falling back to\n contiguous positions. `null` says the same thing and is the form the reader hands over.\n */\n if (starts.length !== 0 && starts.length !== recordCount) {\n // Both counts through the helpers, like every other sentence that puts one number\n // against another: the phrase exists to compare them.\n throw new OptionError(\n `recordStarts has ${grouped(starts.length)} of the ` +\n `${counted(recordCount as number, 'record start time')} this recording needs. It is ` +\n `where each data record really sits, so a shorter list describes a shorter ` +\n `recording; pass null, or an empty list, where none are known.`,\n );\n }\n for (let at = 0; at < starts.length; at++) {\n const start = starts[at];\n if (start === null || start === undefined || Number.isFinite(start)) continue;\n throw new OptionError(\n `recordStarts[${at}] must be the second that record starts at, or null where it is ` +\n `not known, got ${describeValue(start)}. A start that is not a number matches no ` +\n `record, so the window comes back empty and calls itself the whole recording.`,\n );\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/annotations.js
CHANGED
|
@@ -107,8 +107,19 @@ export function decodeRecordAnnotations(bytes, recordIndex, carriesTimekeeping =
|
|
|
107
107
|
A whole non-negative number, like the record bounds `readRecords` and `sampleAt` take:
|
|
108
108
|
records are counted from zero, and this one is a position in the file rather than in a
|
|
109
109
|
batch, so there is no upper bound to hold it to here.
|
|
110
|
+
|
|
111
|
+
Safe, though, which is a bound and not a guess. `Number.isInteger` is true of 1e300 and of
|
|
112
|
+
2^53 + 2, and neither is a position any file has: past 2^53 a double stops being able to
|
|
113
|
+
tell one whole number from the next, so the value cannot name a record even in principle.
|
|
114
|
+
It was written onto every event and into `record_index`, where a join reads it —
|
|
115
|
+
|
|
116
|
+
decodeRecordAnnotations(bytes, 1e300).annotations[0].recordIndex // 1e+300
|
|
117
|
+
|
|
118
|
+
— and `1e+300` is not even a number of the form that column holds, since every other row
|
|
119
|
+
of it is plain digits. A file of 2^53 records at the format's smallest record duration is
|
|
120
|
+
longer than the age of the universe; a value past it came from arithmetic, not from a file.
|
|
110
121
|
*/
|
|
111
|
-
if (!Number.
|
|
122
|
+
if (!Number.isSafeInteger(recordIndex) || recordIndex < 0) {
|
|
112
123
|
throw new OptionError(`recordIndex must be the record's whole-number position in the file, got ` +
|
|
113
124
|
`${describeValue(recordIndex)}. It is written onto every annotation this returns.`);
|
|
114
125
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"annotations.js","sourceRoot":"","sources":["../../src/edf/annotations.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * EDF+ annotation (TAL) decoding.\n *\n * The annotations channel stores UTF-8 text in place of samples. Its bytes are a\n * run of Time-stamped Annotation Lists, each terminated by a NUL, with the rest\n * of the channel NUL-padded:\n *\n * +<onset>[<0x15><duration>]<0x14><text><0x14>...<0x00>\n *\n * The first TAL of every data record carries that record's start time; that is how an EDF+D\n * file states where each record actually sits in time. It may carry event text after that,\n * which the specification allows and writers use, so an unreadable one costs a position and\n * whatever events went with it — see `malformedTimekeepingWithText`.\n *\n * +1.25<0x15>0.5<0x14>Seizure onset<0x14><0x00>\n */\n\nimport { decodeText } from './bytes.js';\n// Crossing into convert/ as header.ts and reader.ts already do: the check belongs to the call.\nimport { OptionError, describeValue } from '../convert/options.js';\n\nconst SEP_TEXT = 0x14; // separates onset/duration from text, and text from text\nconst SEP_DURATION = 0x15; // separates onset from duration\nconst TAL_END = 0x00;\n\nconst TEXT_SEP_CHAR = String.fromCharCode(SEP_TEXT);\nconst DURATION_SEP_CHAR = String.fromCharCode(SEP_DURATION);\n\n/**\n * What EDF+ allows a TAL duration to look like, which is less than `Number()` allows.\n *\n * The header parser's DECIMAL_FIELD makes this argument at length and lists where the same\n * mistake had already been fixed: `#0x2` reaching channel 2 through `--channels`,\n * `--decimals 0o5` writing five places, `--jobs 0x10` running sixteen, a physical maximum of\n * `0x64` setting a whole calibration to 100. The annotation duration is the one field it did\n * not reach. `+1<0x15>0x10<0x14>Seizure` was exported with a duration_s of 16 — sixteen\n * seconds no writer wrote, in a column the documentation defines as the length the file\n * stated, exit 0 and no diagnostic. `0b11` is three the same way.\n *\n * A duration this cannot read is already a thing the parser knows how to say: the field is\n * left empty, counted, and reported. Sending these there costs nothing and states the truth,\n * which is that the file wrote something that is not a number of seconds.\n */\nconst DECIMAL_DURATION = /^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$/u;\n\nexport interface Annotation {\n /** Seconds from the start of the recording. */\n onset: number;\n /**\n * Seconds, or null when the TAL stated no duration that could be read.\n *\n * Null covers two cases the file distinguishes and this field does not: a TAL that omitted\n * the duration, and a TAL that stated one which is not a number. They are told apart by\n * `unreadableDurations`, which is what raises the warning; the value itself has nowhere\n * honest to put \"the file said `abc`\".\n */\n duration: number | null;\n text: string;\n /** Index of the data record this annotation was stored in. */\n recordIndex: number;\n /**\n * True when the file stated a duration that could not be read.\n *\n * `duration` is null either way, which is the ambiguity the counts beside it exist to\n * flag — and those counts were of the whole file while `annotations.csv` is filtered to\n * the requested window. A conversion of one second of a recording warned that \"1\n * annotation states a duration that is not a number, so its duration_s cell is empty\"\n * about an event two seconds outside it, and failed `--strict` for it. Carrying the fact\n * on the event lets the count be taken where the window has already been applied.\n */\n durationUnreadable?: boolean;\n}\n\nexport interface DecodedRecordAnnotations {\n /** Record start time in seconds, from the leading timekeeping TAL. */\n recordStart: number | null;\n annotations: Annotation[];\n /** Non-empty chunks that were not valid TALs, so the caller can report them. */\n malformed: number;\n /** Unreadable TALs in first position, which carry a record's start time, not an event. */\n malformedTimekeeping: number;\n /**\n * How many of those also carried event text, and so lost events as well as a position.\n *\n * A TAL in first position holds the record's start time, and may hold events after it — the\n * specification allows both in the one entry, and writers use it. When such a TAL cannot be\n * parsed, both are gone, and counting it only as lost timekeeping let the warning beside it\n * say \"No event was lost\" over a conversion that had just dropped four of them.\n *\n * Counted rather than inferred, because the sentence has to be right in the ordinary case\n * too: a bare timekeeping TAL really does lose no event, and that is nearly all of them.\n */\n malformedTimekeepingWithText: number;\n /**\n * Events kept whose stated duration could not be read.\n *\n * Counted apart again, for the same reason the two above are: the entry was exported and\n * nothing about it is missing except the one field, so calling it an entry that \"could not\n * be exported\" describes a loss that did not happen and hides the one that did.\n */\n unreadableDurations: number;\n /**\n * Events kept whose stated duration is a readable number below zero.\n *\n * Separate from the count above because the value survives: it is written to the CSV as\n * the file gave it, and what is wrong with it is arithmetic rather than parsing.\n */\n negativeDurations: number;\n}\n\n/**\n * Decode one data record's annotation bytes.\n *\n * Malformed TALs are skipped rather than thrown, because a single bad annotation\n * should not cost the user an entire conversion. The count of skipped chunks is\n * returned so the caller can tell the user rather than losing them in silence.\n */\nexport function decodeRecordAnnotations(\n bytes: Uint8Array,\n recordIndex: number,\n carriesTimekeeping = true,\n): DecodedRecordAnnotations {\n /*\n The bytes, checked before anything is read out of them.\n\n The api page offers this function on its own — \"For decoding annotation bytes yourself,\n `decodeRecordAnnotations(bytes, recordIndex)` handles one record's worth of the channel\" —\n so it is reached by a caller holding a slice they cut themselves. Hand it anything but a\n typed array and the loop below reported the shape of its own local:\n\n decodeRecordAnnotations(Buffer.from(text).toString(), 0)\n TypeError: bytes.subarray is not a function\n\n which names a variable inside this function and a method the caller never called. The\n same `OptionError` the two path-taking exports raise, for the reason given there: the call\n is what is wrong, not the recording.\n */\n /*\n A view of *bytes*, which `ArrayBuffer.isView` does not mean.\n\n It is true of every typed array and of `DataView`, and both of those reach past this check\n into the loop below — which reads `bytes[i]` and calls `bytes.subarray`. A typed array of\n wider elements has both, so it does not fail; it decodes the wrong thing and says nothing:\n\n decodeRecordAnnotations(new Float64Array(2), 0)\n { recordStart: null, annotations: [], malformed: 0, ... }\n\n Sixteen bytes of annotation channel, read as two doubles, reported as a record with\n nothing malformed in it — which is what this function says about a record that really is\n empty. A `DataView` has no index accessor at all, so every byte reads `undefined`.\n\n `BYTES_PER_ELEMENT === 1` is the question being asked: true of `Uint8Array`, `Int8Array`,\n `Uint8ClampedArray` and the `Buffer` `annotationBytes` hands this, undefined on a\n `DataView`. The same check `parseHeader` makes of its own bytes.\n */\n if (!ArrayBuffer.isView(bytes) || (bytes as { BYTES_PER_ELEMENT?: number }).BYTES_PER_ELEMENT !== 1) {\n // A view is named rather than dumped, for the reason 0.8.84 gives: a hundred doubles\n // handed back is the caller's own data, not a description of what is wrong with it.\n throw new OptionError(\n ArrayBuffer.isView(bytes)\n ? `bytes is a ${(bytes as object).constructor?.name ?? 'view'}, which is a view of an ` +\n `ArrayBuffer but not of bytes. One record's annotation channel is a Uint8Array or a ` +\n `Buffer, one byte an element — annotationBytes returns one.`\n : `bytes must be one record's annotation channel, got ${describeValue(bytes)}.`,\n );\n }\n /*\n And the second argument, which this function does not read — it writes it.\n\n `recordIndex` is copied onto every `Annotation` this call produces, and `Annotation`\n declares it a number. Nothing checked that it was one, so whatever was passed came back\n out in the event list:\n\n decodeRecordAnnotations(bytes, 'x').annotations[0].recordIndex // 'x'\n decodeRecordAnnotations(bytes).annotations[0].recordIndex // absent\n\n The second is the ordinary mistake, since the page shows this called as\n `decodeRecordAnnotations(bytes, recordIndex)` beside `annotationBytes(batch, recordOffset,\n signal)` and the two take their record different ways. It produces events with the field\n missing altogether, which `record_index` in annotations.csv is written from, and which a\n caller joining events back to records reads as a record of `undefined`.\n\n A whole non-negative number, like the record bounds `readRecords` and `sampleAt` take:\n records are counted from zero, and this one is a position in the file rather than in a\n batch, so there is no upper bound to hold it to here.\n */\n if (!Number.isInteger(recordIndex) || recordIndex < 0) {\n throw new OptionError(\n `recordIndex must be the record's whole-number position in the file, got ` +\n `${describeValue(recordIndex)}. It is written onto every annotation this returns.`,\n );\n }\n const annotations: Annotation[] = [];\n let recordStart: number | null = null;\n let isFirstTal = true;\n let malformed = 0;\n let malformedTimekeeping = 0;\n let malformedTimekeepingWithText = 0;\n let unreadableDurations = 0;\n let negativeDurations = 0;\n\n let start = 0;\n for (let i = 0; i <= bytes.length; i++) {\n if (i !== bytes.length && bytes[i] !== TAL_END) continue;\n\n if (i > start) {\n const chunk = bytes.subarray(start, i);\n /*\n Padding is not a lost annotation.\n\n The spec pads the slot with NUL, which the loop above already skips because it is what\n separates one TAL from the next. Writers pad with spaces instead, and a run of spaces\n after the last TAL is a non-empty chunk — so a file holding one perfectly readable\n event, exported in full, was told \"2 annotation entries were unreadable and could not\n be exported\", one per record. Nothing was lost. Under --strict that is a failed run\n over the whitespace at the end of a slot.\n\n Only whitespace. A chunk of anything else that does not parse is a real loss and is\n still counted, which is the case this warning exists for.\n */\n if (chunk.every(isPaddingByte)) {\n start = i + 1;\n continue;\n }\n const parsed = parseTal(chunk, recordIndex);\n\n // The timekeeping TAL is the one in first POSITION, whether or not it decodes.\n // Clearing this flag only on a successful parse meant that an unreadable first TAL\n // promoted the next ordinary annotation to timekeeping, and its onset silently\n // became the record's start time — shifting every sample in that record. Leaving\n // recordStart null instead is what the caller already handles, with a fallback\n // timestamp and an ANNOTATION_DECODE_FAILED warning naming the record.\n /*\n Only one annotation channel carries a record's start time.\n\n This flagged the first TAL of *every* annotation channel as timekeeping. In a second\n channel the first TAL is an ordinary event — so when one failed to parse, the event\n was dropped and counted as a lost timekeeping entry, which produced the warning\n \"3 data records carry a timekeeping annotation that could not be read\" followed by\n \"No event was lost\". Three events had been lost, and the timekeeping in that file was\n perfectly readable. Both sentences false, about the same three records.\n */\n const isTimekeeping = isFirstTal && carriesTimekeeping;\n isFirstTal = false;\n\n if (parsed) {\n if (isTimekeeping) recordStart = parsed.onset;\n for (const annotation of parsed.annotations) annotations.push(annotation);\n unreadableDurations += parsed.unreadableDurations;\n negativeDurations += parsed.negativeDurations;\n } else {\n /*\n Counted apart from the events, because losing one is a different loss.\n\n A timekeeping TAL is never exported — it says where the record sits, not what\n happened — so counting it among the entries that \"could not be exported\" both\n overstated what was lost from annotations.csv and said nothing about the thing that\n actually went missing, which is a record's position in time. A file with one\n unreadable timekeeping TAL and three perfectly good events reported \"1 annotation\n entry was unreadable and could not be exported\" while exporting all three.\n\n The other direction is just as wrong. A first-position TAL may carry events after\n the start time, and when one of those cannot be parsed the events go with it — so\n counting it only as lost timekeeping produced the opposite false sentence: \"No event\n was lost\", printed over a run whose annotations.csv had gone from six rows to two.\n It is one entry that could not be exported and one record with no position, and it\n is counted as both.\n */\n if (isTimekeeping) {\n malformedTimekeeping++;\n if (carriesAnnotationText(chunk)) {\n malformedTimekeepingWithText++;\n malformed++;\n }\n } else malformed++;\n }\n }\n start = i + 1;\n }\n\n return {\n recordStart,\n annotations,\n malformed,\n malformedTimekeeping,\n malformedTimekeepingWithText,\n unreadableDurations,\n negativeDurations,\n };\n}\n\ninterface ParsedTal {\n onset: number;\n annotations: Annotation[];\n /** How many of those annotations carry a duration the file stated and this could not read. */\n unreadableDurations: number;\n /** How many carry a duration that read as a number below zero. */\n negativeDurations: number;\n}\n\n/**\n * Whether a TAL that could not be parsed still carried event text.\n *\n * A TAL is `onset[<0x15>duration]<0x14>text<0x14>...`, so anything other than padding after\n * the first 0x14 is a description the file meant to export. Read from the raw chunk, since by\n * the time this is asked the parse has already failed and there is no structure to consult.\n */\nfunction carriesAnnotationText(chunk: Uint8Array): boolean {\n let afterSeparator = false;\n for (const byte of chunk) {\n if (byte === 0x14) {\n afterSeparator = true;\n continue;\n }\n if (afterSeparator && !isPaddingByte(byte)) return true;\n }\n return false;\n}\n\n/** Space, tab, CR, LF or NUL — what a writer fills the rest of the slot with. */\nfunction isPaddingByte(byte: number): boolean {\n return byte === 0x20 || byte === 0x09 || byte === 0x0d || byte === 0x0a || byte === 0x00;\n}\n\nfunction parseTal(chunk: Uint8Array, recordIndex: number): ParsedTal | null {\n // The onset must be explicitly signed; anything else is not a TAL.\n const first = chunk[0];\n if (first !== 0x2b /* + */ && first !== 0x2d /* - */) return null;\n\n const text = decodeText(chunk);\n const parts = text.split(TEXT_SEP_CHAR);\n const head = parts[0] ?? '';\n\n let onsetText = head;\n let durationText: string | null = null;\n const durationSep = head.indexOf(DURATION_SEP_CHAR);\n if (durationSep >= 0) {\n onsetText = head.slice(0, durationSep);\n durationText = head.slice(durationSep + 1);\n }\n\n const onset = Number(onsetText);\n if (!Number.isFinite(onset)) return null;\n\n /*\n A duration the file stated and this could not read is not the same as no duration.\n\n Both came out as `null` and so as an empty `duration_s` cell, which the documentation\n defines as meaning the file gave no duration — so an event whose duration was written as\n `abc` was exported as an event with no duration, indistinguishable from one beside it\n that genuinely had none, and nothing anywhere said a field had been dropped. The onset is\n already held to this standard: one that is not a number costs the whole TAL and is\n reported. A duration is one field of an otherwise readable event, so the event is kept —\n but it is counted, and the run says so.\n */\n /*\n Fill in the field is an absent duration, not a zero.\n\n `Number` reads a run of whitespace as 0 — the rule that makes `Number('')` zero, one step\n along — so a TAL whose duration field held nothing but the writer's padding was exported\n with a `duration_s` of `0`, byte-identical to the event beside it whose file really did\n say `0`. An instantaneous event is a claim about the recording, and no writer made it;\n inventing it is the one thing this tool does not do, and it did so in silence, exit 0.\n\n The empty field this condition already declines is the same field without the fill in it,\n so padding takes the same answer: the file stated no duration. `trim` empties exactly the\n strings `Number` would otherwise have swallowed into a zero, so ` 2.5 ` still reads as\n 2.5 and `abc` is still counted as unreadable below.\n */\n let duration: number | null = null;\n let durationUnreadable = false;\n if (durationText !== null && durationText.trim() !== '') {\n const stated = durationText.trim();\n const d = DECIMAL_DURATION.test(stated) ? Number(stated) : NaN;\n if (Number.isFinite(d)) duration = d;\n else durationUnreadable = true;\n }\n\n /*\n A duration is a length of time, and a length below zero is not one.\n\n The value is kept and written as the file gave it — inventing a zero, or dropping it to\n an empty cell, would put a number in annotations.csv that no writer wrote, which is the\n one thing this tool does not do. But it is reported, because everything downstream\n quietly does the wrong thing with it: the recipe this documentation gives for the samples\n an event covers is `onset_s + duration_s`, which for a duration of -3 ends three seconds\n before the event starts and selects nothing at all, with no error anywhere.\n */\n const durationNegative = duration !== null && duration < 0;\n\n const annotations: Annotation[] = [];\n for (const raw of parts.slice(1)) {\n /*\n A trailing separator yields an empty segment; a timekeeping TAL is all empty.\n\n Whitespace counts as empty here, which it did not, and the padding at the end of the\n slot became an event. The chunk loop above already refuses to call a run of spaces a\n lost annotation — but it only sees chunks between NULs, and a writer that leaves its\n last TAL unterminated puts the fill *inside* the chunk, after the final 0x14. Split on\n that separator it is a text segment like any other, and \" \" is not \"\".\n\n A file holding two events exported four rows: `0.5,,Lights off,0` and `0.5,, ,0`,\n twice, sharing the real event's onset, with annotations_written and the run summary\n agreeing with the inflated number and nothing warned. An event whose description is\n genuinely nothing but spaces cannot be told from fill, and inventing rows out of fill\n is the worse of the two answers.\n */\n if ([...raw].every((c) => isPaddingByte(c.charCodeAt(0)))) continue;\n annotations.push({\n onset,\n duration,\n text: raw,\n recordIndex,\n ...(durationUnreadable ? { durationUnreadable: true } : {}),\n });\n }\n\n // Per event rather than per TAL: one TAL may carry several texts, and each becomes a row\n // of annotations.csv with the same cell in it.\n return {\n onset,\n annotations,\n unreadableDurations: durationUnreadable ? annotations.length : 0,\n negativeDurations: durationNegative ? annotations.length : 0,\n };\n}\n\nexport { SEP_TEXT, SEP_DURATION, TAL_END };\n"]}
|
|
1
|
+
{"version":3,"file":"annotations.js","sourceRoot":"","sources":["../../src/edf/annotations.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * EDF+ annotation (TAL) decoding.\n *\n * The annotations channel stores UTF-8 text in place of samples. Its bytes are a\n * run of Time-stamped Annotation Lists, each terminated by a NUL, with the rest\n * of the channel NUL-padded:\n *\n * +<onset>[<0x15><duration>]<0x14><text><0x14>...<0x00>\n *\n * The first TAL of every data record carries that record's start time; that is how an EDF+D\n * file states where each record actually sits in time. It may carry event text after that,\n * which the specification allows and writers use, so an unreadable one costs a position and\n * whatever events went with it — see `malformedTimekeepingWithText`.\n *\n * +1.25<0x15>0.5<0x14>Seizure onset<0x14><0x00>\n */\n\nimport { decodeText } from './bytes.js';\n// Crossing into convert/ as header.ts and reader.ts already do: the check belongs to the call.\nimport { OptionError, describeValue } from '../convert/options.js';\n\nconst SEP_TEXT = 0x14; // separates onset/duration from text, and text from text\nconst SEP_DURATION = 0x15; // separates onset from duration\nconst TAL_END = 0x00;\n\nconst TEXT_SEP_CHAR = String.fromCharCode(SEP_TEXT);\nconst DURATION_SEP_CHAR = String.fromCharCode(SEP_DURATION);\n\n/**\n * What EDF+ allows a TAL duration to look like, which is less than `Number()` allows.\n *\n * The header parser's DECIMAL_FIELD makes this argument at length and lists where the same\n * mistake had already been fixed: `#0x2` reaching channel 2 through `--channels`,\n * `--decimals 0o5` writing five places, `--jobs 0x10` running sixteen, a physical maximum of\n * `0x64` setting a whole calibration to 100. The annotation duration is the one field it did\n * not reach. `+1<0x15>0x10<0x14>Seizure` was exported with a duration_s of 16 — sixteen\n * seconds no writer wrote, in a column the documentation defines as the length the file\n * stated, exit 0 and no diagnostic. `0b11` is three the same way.\n *\n * A duration this cannot read is already a thing the parser knows how to say: the field is\n * left empty, counted, and reported. Sending these there costs nothing and states the truth,\n * which is that the file wrote something that is not a number of seconds.\n */\nconst DECIMAL_DURATION = /^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$/u;\n\nexport interface Annotation {\n /** Seconds from the start of the recording. */\n onset: number;\n /**\n * Seconds, or null when the TAL stated no duration that could be read.\n *\n * Null covers two cases the file distinguishes and this field does not: a TAL that omitted\n * the duration, and a TAL that stated one which is not a number. They are told apart by\n * `unreadableDurations`, which is what raises the warning; the value itself has nowhere\n * honest to put \"the file said `abc`\".\n */\n duration: number | null;\n text: string;\n /** Index of the data record this annotation was stored in. */\n recordIndex: number;\n /**\n * True when the file stated a duration that could not be read.\n *\n * `duration` is null either way, which is the ambiguity the counts beside it exist to\n * flag — and those counts were of the whole file while `annotations.csv` is filtered to\n * the requested window. A conversion of one second of a recording warned that \"1\n * annotation states a duration that is not a number, so its duration_s cell is empty\"\n * about an event two seconds outside it, and failed `--strict` for it. Carrying the fact\n * on the event lets the count be taken where the window has already been applied.\n */\n durationUnreadable?: boolean;\n}\n\nexport interface DecodedRecordAnnotations {\n /** Record start time in seconds, from the leading timekeeping TAL. */\n recordStart: number | null;\n annotations: Annotation[];\n /** Non-empty chunks that were not valid TALs, so the caller can report them. */\n malformed: number;\n /** Unreadable TALs in first position, which carry a record's start time, not an event. */\n malformedTimekeeping: number;\n /**\n * How many of those also carried event text, and so lost events as well as a position.\n *\n * A TAL in first position holds the record's start time, and may hold events after it — the\n * specification allows both in the one entry, and writers use it. When such a TAL cannot be\n * parsed, both are gone, and counting it only as lost timekeeping let the warning beside it\n * say \"No event was lost\" over a conversion that had just dropped four of them.\n *\n * Counted rather than inferred, because the sentence has to be right in the ordinary case\n * too: a bare timekeeping TAL really does lose no event, and that is nearly all of them.\n */\n malformedTimekeepingWithText: number;\n /**\n * Events kept whose stated duration could not be read.\n *\n * Counted apart again, for the same reason the two above are: the entry was exported and\n * nothing about it is missing except the one field, so calling it an entry that \"could not\n * be exported\" describes a loss that did not happen and hides the one that did.\n */\n unreadableDurations: number;\n /**\n * Events kept whose stated duration is a readable number below zero.\n *\n * Separate from the count above because the value survives: it is written to the CSV as\n * the file gave it, and what is wrong with it is arithmetic rather than parsing.\n */\n negativeDurations: number;\n}\n\n/**\n * Decode one data record's annotation bytes.\n *\n * Malformed TALs are skipped rather than thrown, because a single bad annotation\n * should not cost the user an entire conversion. The count of skipped chunks is\n * returned so the caller can tell the user rather than losing them in silence.\n */\nexport function decodeRecordAnnotations(\n bytes: Uint8Array,\n recordIndex: number,\n carriesTimekeeping = true,\n): DecodedRecordAnnotations {\n /*\n The bytes, checked before anything is read out of them.\n\n The api page offers this function on its own — \"For decoding annotation bytes yourself,\n `decodeRecordAnnotations(bytes, recordIndex)` handles one record's worth of the channel\" —\n so it is reached by a caller holding a slice they cut themselves. Hand it anything but a\n typed array and the loop below reported the shape of its own local:\n\n decodeRecordAnnotations(Buffer.from(text).toString(), 0)\n TypeError: bytes.subarray is not a function\n\n which names a variable inside this function and a method the caller never called. The\n same `OptionError` the two path-taking exports raise, for the reason given there: the call\n is what is wrong, not the recording.\n */\n /*\n A view of *bytes*, which `ArrayBuffer.isView` does not mean.\n\n It is true of every typed array and of `DataView`, and both of those reach past this check\n into the loop below — which reads `bytes[i]` and calls `bytes.subarray`. A typed array of\n wider elements has both, so it does not fail; it decodes the wrong thing and says nothing:\n\n decodeRecordAnnotations(new Float64Array(2), 0)\n { recordStart: null, annotations: [], malformed: 0, ... }\n\n Sixteen bytes of annotation channel, read as two doubles, reported as a record with\n nothing malformed in it — which is what this function says about a record that really is\n empty. A `DataView` has no index accessor at all, so every byte reads `undefined`.\n\n `BYTES_PER_ELEMENT === 1` is the question being asked: true of `Uint8Array`, `Int8Array`,\n `Uint8ClampedArray` and the `Buffer` `annotationBytes` hands this, undefined on a\n `DataView`. The same check `parseHeader` makes of its own bytes.\n */\n if (!ArrayBuffer.isView(bytes) || (bytes as { BYTES_PER_ELEMENT?: number }).BYTES_PER_ELEMENT !== 1) {\n // A view is named rather than dumped, for the reason 0.8.84 gives: a hundred doubles\n // handed back is the caller's own data, not a description of what is wrong with it.\n throw new OptionError(\n ArrayBuffer.isView(bytes)\n ? `bytes is a ${(bytes as object).constructor?.name ?? 'view'}, which is a view of an ` +\n `ArrayBuffer but not of bytes. One record's annotation channel is a Uint8Array or a ` +\n `Buffer, one byte an element — annotationBytes returns one.`\n : `bytes must be one record's annotation channel, got ${describeValue(bytes)}.`,\n );\n }\n /*\n And the second argument, which this function does not read — it writes it.\n\n `recordIndex` is copied onto every `Annotation` this call produces, and `Annotation`\n declares it a number. Nothing checked that it was one, so whatever was passed came back\n out in the event list:\n\n decodeRecordAnnotations(bytes, 'x').annotations[0].recordIndex // 'x'\n decodeRecordAnnotations(bytes).annotations[0].recordIndex // absent\n\n The second is the ordinary mistake, since the page shows this called as\n `decodeRecordAnnotations(bytes, recordIndex)` beside `annotationBytes(batch, recordOffset,\n signal)` and the two take their record different ways. It produces events with the field\n missing altogether, which `record_index` in annotations.csv is written from, and which a\n caller joining events back to records reads as a record of `undefined`.\n\n A whole non-negative number, like the record bounds `readRecords` and `sampleAt` take:\n records are counted from zero, and this one is a position in the file rather than in a\n batch, so there is no upper bound to hold it to here.\n\n Safe, though, which is a bound and not a guess. `Number.isInteger` is true of 1e300 and of\n 2^53 + 2, and neither is a position any file has: past 2^53 a double stops being able to\n tell one whole number from the next, so the value cannot name a record even in principle.\n It was written onto every event and into `record_index`, where a join reads it —\n\n decodeRecordAnnotations(bytes, 1e300).annotations[0].recordIndex // 1e+300\n\n — and `1e+300` is not even a number of the form that column holds, since every other row\n of it is plain digits. A file of 2^53 records at the format's smallest record duration is\n longer than the age of the universe; a value past it came from arithmetic, not from a file.\n */\n if (!Number.isSafeInteger(recordIndex) || recordIndex < 0) {\n throw new OptionError(\n `recordIndex must be the record's whole-number position in the file, got ` +\n `${describeValue(recordIndex)}. It is written onto every annotation this returns.`,\n );\n }\n const annotations: Annotation[] = [];\n let recordStart: number | null = null;\n let isFirstTal = true;\n let malformed = 0;\n let malformedTimekeeping = 0;\n let malformedTimekeepingWithText = 0;\n let unreadableDurations = 0;\n let negativeDurations = 0;\n\n let start = 0;\n for (let i = 0; i <= bytes.length; i++) {\n if (i !== bytes.length && bytes[i] !== TAL_END) continue;\n\n if (i > start) {\n const chunk = bytes.subarray(start, i);\n /*\n Padding is not a lost annotation.\n\n The spec pads the slot with NUL, which the loop above already skips because it is what\n separates one TAL from the next. Writers pad with spaces instead, and a run of spaces\n after the last TAL is a non-empty chunk — so a file holding one perfectly readable\n event, exported in full, was told \"2 annotation entries were unreadable and could not\n be exported\", one per record. Nothing was lost. Under --strict that is a failed run\n over the whitespace at the end of a slot.\n\n Only whitespace. A chunk of anything else that does not parse is a real loss and is\n still counted, which is the case this warning exists for.\n */\n if (chunk.every(isPaddingByte)) {\n start = i + 1;\n continue;\n }\n const parsed = parseTal(chunk, recordIndex);\n\n // The timekeeping TAL is the one in first POSITION, whether or not it decodes.\n // Clearing this flag only on a successful parse meant that an unreadable first TAL\n // promoted the next ordinary annotation to timekeeping, and its onset silently\n // became the record's start time — shifting every sample in that record. Leaving\n // recordStart null instead is what the caller already handles, with a fallback\n // timestamp and an ANNOTATION_DECODE_FAILED warning naming the record.\n /*\n Only one annotation channel carries a record's start time.\n\n This flagged the first TAL of *every* annotation channel as timekeeping. In a second\n channel the first TAL is an ordinary event — so when one failed to parse, the event\n was dropped and counted as a lost timekeeping entry, which produced the warning\n \"3 data records carry a timekeeping annotation that could not be read\" followed by\n \"No event was lost\". Three events had been lost, and the timekeeping in that file was\n perfectly readable. Both sentences false, about the same three records.\n */\n const isTimekeeping = isFirstTal && carriesTimekeeping;\n isFirstTal = false;\n\n if (parsed) {\n if (isTimekeeping) recordStart = parsed.onset;\n for (const annotation of parsed.annotations) annotations.push(annotation);\n unreadableDurations += parsed.unreadableDurations;\n negativeDurations += parsed.negativeDurations;\n } else {\n /*\n Counted apart from the events, because losing one is a different loss.\n\n A timekeeping TAL is never exported — it says where the record sits, not what\n happened — so counting it among the entries that \"could not be exported\" both\n overstated what was lost from annotations.csv and said nothing about the thing that\n actually went missing, which is a record's position in time. A file with one\n unreadable timekeeping TAL and three perfectly good events reported \"1 annotation\n entry was unreadable and could not be exported\" while exporting all three.\n\n The other direction is just as wrong. A first-position TAL may carry events after\n the start time, and when one of those cannot be parsed the events go with it — so\n counting it only as lost timekeeping produced the opposite false sentence: \"No event\n was lost\", printed over a run whose annotations.csv had gone from six rows to two.\n It is one entry that could not be exported and one record with no position, and it\n is counted as both.\n */\n if (isTimekeeping) {\n malformedTimekeeping++;\n if (carriesAnnotationText(chunk)) {\n malformedTimekeepingWithText++;\n malformed++;\n }\n } else malformed++;\n }\n }\n start = i + 1;\n }\n\n return {\n recordStart,\n annotations,\n malformed,\n malformedTimekeeping,\n malformedTimekeepingWithText,\n unreadableDurations,\n negativeDurations,\n };\n}\n\ninterface ParsedTal {\n onset: number;\n annotations: Annotation[];\n /** How many of those annotations carry a duration the file stated and this could not read. */\n unreadableDurations: number;\n /** How many carry a duration that read as a number below zero. */\n negativeDurations: number;\n}\n\n/**\n * Whether a TAL that could not be parsed still carried event text.\n *\n * A TAL is `onset[<0x15>duration]<0x14>text<0x14>...`, so anything other than padding after\n * the first 0x14 is a description the file meant to export. Read from the raw chunk, since by\n * the time this is asked the parse has already failed and there is no structure to consult.\n */\nfunction carriesAnnotationText(chunk: Uint8Array): boolean {\n let afterSeparator = false;\n for (const byte of chunk) {\n if (byte === 0x14) {\n afterSeparator = true;\n continue;\n }\n if (afterSeparator && !isPaddingByte(byte)) return true;\n }\n return false;\n}\n\n/** Space, tab, CR, LF or NUL — what a writer fills the rest of the slot with. */\nfunction isPaddingByte(byte: number): boolean {\n return byte === 0x20 || byte === 0x09 || byte === 0x0d || byte === 0x0a || byte === 0x00;\n}\n\nfunction parseTal(chunk: Uint8Array, recordIndex: number): ParsedTal | null {\n // The onset must be explicitly signed; anything else is not a TAL.\n const first = chunk[0];\n if (first !== 0x2b /* + */ && first !== 0x2d /* - */) return null;\n\n const text = decodeText(chunk);\n const parts = text.split(TEXT_SEP_CHAR);\n const head = parts[0] ?? '';\n\n let onsetText = head;\n let durationText: string | null = null;\n const durationSep = head.indexOf(DURATION_SEP_CHAR);\n if (durationSep >= 0) {\n onsetText = head.slice(0, durationSep);\n durationText = head.slice(durationSep + 1);\n }\n\n const onset = Number(onsetText);\n if (!Number.isFinite(onset)) return null;\n\n /*\n A duration the file stated and this could not read is not the same as no duration.\n\n Both came out as `null` and so as an empty `duration_s` cell, which the documentation\n defines as meaning the file gave no duration — so an event whose duration was written as\n `abc` was exported as an event with no duration, indistinguishable from one beside it\n that genuinely had none, and nothing anywhere said a field had been dropped. The onset is\n already held to this standard: one that is not a number costs the whole TAL and is\n reported. A duration is one field of an otherwise readable event, so the event is kept —\n but it is counted, and the run says so.\n */\n /*\n Fill in the field is an absent duration, not a zero.\n\n `Number` reads a run of whitespace as 0 — the rule that makes `Number('')` zero, one step\n along — so a TAL whose duration field held nothing but the writer's padding was exported\n with a `duration_s` of `0`, byte-identical to the event beside it whose file really did\n say `0`. An instantaneous event is a claim about the recording, and no writer made it;\n inventing it is the one thing this tool does not do, and it did so in silence, exit 0.\n\n The empty field this condition already declines is the same field without the fill in it,\n so padding takes the same answer: the file stated no duration. `trim` empties exactly the\n strings `Number` would otherwise have swallowed into a zero, so ` 2.5 ` still reads as\n 2.5 and `abc` is still counted as unreadable below.\n */\n let duration: number | null = null;\n let durationUnreadable = false;\n if (durationText !== null && durationText.trim() !== '') {\n const stated = durationText.trim();\n const d = DECIMAL_DURATION.test(stated) ? Number(stated) : NaN;\n if (Number.isFinite(d)) duration = d;\n else durationUnreadable = true;\n }\n\n /*\n A duration is a length of time, and a length below zero is not one.\n\n The value is kept and written as the file gave it — inventing a zero, or dropping it to\n an empty cell, would put a number in annotations.csv that no writer wrote, which is the\n one thing this tool does not do. But it is reported, because everything downstream\n quietly does the wrong thing with it: the recipe this documentation gives for the samples\n an event covers is `onset_s + duration_s`, which for a duration of -3 ends three seconds\n before the event starts and selects nothing at all, with no error anywhere.\n */\n const durationNegative = duration !== null && duration < 0;\n\n const annotations: Annotation[] = [];\n for (const raw of parts.slice(1)) {\n /*\n A trailing separator yields an empty segment; a timekeeping TAL is all empty.\n\n Whitespace counts as empty here, which it did not, and the padding at the end of the\n slot became an event. The chunk loop above already refuses to call a run of spaces a\n lost annotation — but it only sees chunks between NULs, and a writer that leaves its\n last TAL unterminated puts the fill *inside* the chunk, after the final 0x14. Split on\n that separator it is a text segment like any other, and \" \" is not \"\".\n\n A file holding two events exported four rows: `0.5,,Lights off,0` and `0.5,, ,0`,\n twice, sharing the real event's onset, with annotations_written and the run summary\n agreeing with the inflated number and nothing warned. An event whose description is\n genuinely nothing but spaces cannot be told from fill, and inventing rows out of fill\n is the worse of the two answers.\n */\n if ([...raw].every((c) => isPaddingByte(c.charCodeAt(0)))) continue;\n annotations.push({\n onset,\n duration,\n text: raw,\n recordIndex,\n ...(durationUnreadable ? { durationUnreadable: true } : {}),\n });\n }\n\n // Per event rather than per TAL: one TAL may carry several texts, and each becomes a row\n // of annotations.csv with the same cell in it.\n return {\n onset,\n annotations,\n unreadableDurations: durationUnreadable ? annotations.length : 0,\n negativeDurations: durationNegative ? annotations.length : 0,\n };\n}\n\nexport { SEP_TEXT, SEP_DURATION, TAL_END };\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "edf2csv",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.15",
|
|
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",
|