edf2csv 0.8.59 → 0.8.61

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.
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { editDistance } from '../format/distance.js';
11
11
  import { listed } from '../format/list.js';
12
- import { assertOptions } from './options.js';
12
+ import { assertOptions, assertSignals } from './options.js';
13
13
  /**
14
14
  * The name of the column the writer puts in front of the channels, which no channel may take.
15
15
  *
@@ -32,6 +32,9 @@ export class ChannelSelectionError extends Error {
32
32
  * the file, which is the only thing that reliably tells them apart.
33
33
  */
34
34
  export function buildColumnNames(signals) {
35
+ // The same check `selectChannels` makes below, for the same reason: this is exported, and a
36
+ // string is iterable, so `buildColumnNames('ECG')` came back as a Map rather than an error.
37
+ assertSignals(signals);
35
38
  const counts = new Map();
36
39
  for (const signal of signals) {
37
40
  if (signal.isAnnotations)
@@ -128,6 +131,8 @@ export function selectChannels(signals, terms) {
128
131
  shape, which is the case that checker exists for, and both now say so.
129
132
  */
130
133
  assertOptions({ channels: terms });
134
+ // And the argument in front of it, which that call has never covered.
135
+ assertSignals(signals);
131
136
  const candidates = signals.filter((s) => !s.isAnnotations);
132
137
  const byLabel = new Map();
133
138
  for (const signal of candidates) {
@@ -1 +1 @@
1
- {"version":3,"file":"channels.js","sourceRoot":"","sources":["../../src/convert/channels.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Channel naming and selection.\n *\n * Two real-world facts drive this module. EDF labels are free text that routinely\n * contain spaces and punctuation (\"EEG Fpz-Cz\"), and they are not guaranteed to be\n * unique — CHB-MIT recordings ship two channels both labelled \"T8-P8\", and some\n * carry a channel labelled \"-\". Labels are therefore preserved verbatim in output,\n * and only disambiguated when the file itself is ambiguous.\n */\n\nimport type { EdfSignal } from '../edf/header.js';\nimport { editDistance } from '../format/distance.js';\nimport { listed } from '../format/list.js';\nimport { assertOptions } from './options.js';\n\n/**\n * The name of the column the writer puts in front of the channels, which no channel may take.\n *\n * Exported and used by the writer rather than repeated there, because the whole point of\n * reserving it here is that the two cannot drift apart.\n */\nexport const TIME_COLUMN = 'time_s';\n\nexport class ChannelSelectionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ChannelSelectionError';\n }\n}\n\n/**\n * Column name for a signal.\n *\n * Names are derived from the whole file, not from the current selection, so a given\n * channel always produces the same column regardless of which channels were asked\n * for. Duplicated labels get a `_ch<index>` suffix pointing at their position in\n * the file, which is the only thing that reliably tells them apart.\n */\nexport function buildColumnNames(signals: readonly EdfSignal[]): Map<number, string> {\n const counts = new Map<string, number>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n counts.set(base, (counts.get(base) ?? 0) + 1);\n }\n\n const names = new Map<number, string>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n const duplicated = (counts.get(base) ?? 0) > 1;\n names.set(signal.index, duplicated ? `${base}_ch${signal.index}` : base);\n }\n\n /*\n The suffix has to be checked against the file, not only against the label it disambiguates.\n\n Counting collisions on the raw label alone made the suffix a guess: `_ch<index>` is unique\n among the channels sharing that label, and nothing stopped it from landing on a label some\n other channel already had. A file labelled T8, T8, T8_ch0 — all three legal, since EDF\n labels are free text and nothing enforces uniqueness — produced\n\n time_s,T8_ch0,T8_ch1,T8_ch0\n\n two columns with one name, while the warning beside it said the suffix kept them\n \"distinguishable\". channels.csv listed the same name against two signal indices, so the\n join it exists for could not resolve it either, and `df[\"T8_ch0\"]` in pandas or R returns\n one of the two with nothing to say which.\n\n Anything still shared after the first pass takes its own position as well. That is unique\n by construction, so the loop settles immediately in practice; the bound is there because\n a second round could in principle land on yet another literal label.\n\n The time column counts as taken, for the same reason. It is a name this file does not\n supply and the writer does, and a channel labelled `time_s` collided with it in silence:\n the header came out `time_s,time_s,ECG`, channels.csv named the channel's column `time_s`,\n and every read-back the documentation gives — `index_col=\"time_s\"`, `pop(\"time_s\")`,\n `pivot(index=\"time_s\")` — resolves that to one of the two columns without saying which.\n pandas and Python's own `csv.DictReader` resolve it opposite ways round.\n */\n for (let round = 0; round < 8; round++) {\n const taken = new Map<string, number>([[TIME_COLUMN, 1]]);\n for (const name of names.values()) taken.set(name, (taken.get(name) ?? 0) + 1);\n\n const clashing = [...names].filter(([, name]) => (taken.get(name) ?? 0) > 1);\n if (clashing.length === 0) break;\n for (const [index, name] of clashing) names.set(index, `${name}_ch${index}`);\n }\n return names;\n}\n\nfunction baseName(signal: EdfSignal): string {\n return signal.label === '' ? `signal_${signal.index}` : signal.label;\n}\n\n/**\n * Channels whose column name was pushed off their own label to keep the header unique.\n *\n * A channel genuinely labelled `T8_ch0` loses that name when another label's disambiguating\n * suffix wants it, and the resulting column is the one thing in the output that no longer\n * matches the file. Silence there is what made the collision hard to see in the first place:\n * the only warning raised was about the *other* label.\n */\nexport function renamedByCollision(\n signals: readonly EdfSignal[],\n columnNames: ReadonlyMap<number, string>,\n): EdfSignal[] {\n const counts = new Map<string, number>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n counts.set(base, (counts.get(base) ?? 0) + 1);\n }\n return signals.filter(\n (signal) =>\n !signal.isAnnotations &&\n (counts.get(baseName(signal)) ?? 0) === 1 &&\n columnNames.get(signal.index) !== baseName(signal),\n );\n}\n\nexport interface ChannelSelection {\n signals: EdfSignal[];\n /** Labels that matched more than one channel, so the user knows why they got extras. */\n ambiguous: { term: string; matched: EdfSignal[] }[];\n}\n\n/**\n * Resolve a `--channels` specification against the file's signals.\n *\n * Matching is case-insensitive on the exact label, with `#<index>` available to\n * address a specific channel when labels collide. A term that matches nothing is an\n * error rather than a silent omission: quietly dropping a requested channel would\n * hand the user a CSV that is missing data they explicitly asked for.\n */\nexport function selectChannels(signals: readonly EdfSignal[], terms: readonly string[]): ChannelSelection {\n /*\n The same check `buildPlan` makes, made here too, because this is exported on its own.\n\n `assertOptions` covers `convert`; a caller holding a header and calling this directly got\n the shapes it names in full. `'ECG'` was iterated character by character and answered `No\n channel named \"E\". Did you mean \"ECG\"?` — which reads like a real answer about the file —\n and `[1]`, a position written the way a position is written, came back as `TypeError:\n rawTerm.trim is not a function` from the loop below. Both are the argument being the wrong\n shape, which is the case that checker exists for, and both now say so.\n */\n assertOptions({ channels: terms });\n\n const candidates = signals.filter((s) => !s.isAnnotations);\n const byLabel = new Map<string, EdfSignal[]>();\n for (const signal of candidates) {\n const key = signal.label.toLowerCase();\n const bucket = byLabel.get(key);\n if (bucket) bucket.push(signal);\n else byLabel.set(key, [signal]);\n }\n\n const chosen = new Map<number, EdfSignal>();\n const ambiguous: { term: string; matched: EdfSignal[] }[] = [];\n /*\n A file may have no signal channels at all, and both position errors below assumed it had\n some. `listed([])` is the empty string, so `--channels \"#0\"` on an annotations-only\n recording — one of this tool's own fixtures — was refused with\n\n No channel at position #0. This file has signal channels at .\n\n a sentence that states there are channels and then names none, ending mid-clause. The\n conversion already raises NO_SIGNAL_CHANNELS for this file and says what it is, so the\n fact was known one layer up; the message that a `--channels` user actually reaches was\n the one that did not have it.\n */\n /*\n And one channel is not \"channels\", which is the same sentence one item further on.\n\n The zero case above was given its own wording because \"signal channels at .\" states there\n are some and then names none. A file with exactly one got \"This file has signal channels\n at #0.\" — a plural over a list of one, on the line whose job is to say what may be typed\n instead. Single-channel recordings are ordinary; two of this repository's own fixtures are\n one.\n */\n const positions =\n candidates.length === 0\n ? 'This file has no signal channels; it contains only annotations.'\n : candidates.length === 1\n ? `This file has one signal channel, at #${candidates[0]?.index ?? 0}.`\n : `This file has signal channels at ${listed(candidates.map((s) => `#${s.index}`))}.`;\n\n for (const rawTerm of terms) {\n const term = rawTerm.trim();\n if (term === '') continue;\n\n // '#N' addresses a channel by position, but a label may literally be \"#5\".\n // A real label always wins, so no channel becomes unreachable.\n const literal = byLabel.get(term.toLowerCase());\n if (term.startsWith('#') && (!literal || literal.length === 0)) {\n /*\n A position must be written in plain digits.\n\n `Number()` was doing the parsing, and it accepts a great deal more than a position:\n `#0x2` reached channel 2 through hex, `#0b1` channel 1 through binary, `#1e0` and\n `#2.0` and `# 2` all landed somewhere, and `#` on its own became `Number('')`, which\n is 0. Every one of them selected a channel and exited 0, so a slip did not fail —\n it quietly converted a different channel than the one asked for, which for this tool\n is the worst way to be wrong.\n */\n const position = term.slice(1);\n if (!/^\\d+$/u.test(position)) {\n throw new ChannelSelectionError(\n `\"${term}\" is not a channel position: a position is #0, #1, #2 and so on.\\n` +\n positions,\n );\n }\n const index = Number(position);\n const signal = candidates.find((s) => s.index === index);\n if (!signal) {\n /*\n The list of positions is advice, and advice goes on the continuation line.\n\n It sat on the first line here and on the second in the refusal six lines up, which\n is the same sentence about the same thing. The first line is the one a log gets\n grepped for and the one `printableLines` leaves whole at any width, so on a\n 40-channel recording this refusal ran to 111 columns where its neighbour ran to 74.\n */\n throw new ChannelSelectionError(\n `No channel at position ${term}.\\n${positions}`,\n );\n }\n chosen.set(signal.index, signal);\n continue;\n }\n\n const matched = byLabel.get(term.toLowerCase());\n if (!matched || matched.length === 0) {\n /*\n The one term that is certainly not a typo: a column name.\n\n Matching is on the label, and where a label collides the column gains a `_ch<index>`\n suffix — so `T8-P8_ch1` is a name this tool invented, prints in the COLUMN column of\n --info, writes into channels.csv and puts at the head of signals.csv, and then\n rejects with \"No channel named \"T8-P8_ch1\". Run with --info to list the channels in\n this file\", which is where the user copied it from. The reference documents the trap;\n the message a user actually hits did not.\n\n Answered where it is asked instead, and with the thing that works: `#<index>` selects\n one channel of a colliding pair, which is exactly what someone reaching for the\n suffixed column name wants and the only way to get it.\n */\n const columns = buildColumnNames(candidates);\n const owner = candidates.find(\n (signal) => (columns.get(signal.index) ?? '').toLowerCase() === term.toLowerCase(),\n );\n if (owner) {\n // A channel with no label at all gets the column `signal_<index>`, and offering its\n // label back would be offering `\"\"` — there is nothing to type. Position is the only\n // way to reach it, and saying so is more use than quoting an empty string twice.\n throw new ChannelSelectionError(\n owner.label === ''\n ? `\"${term}\" is a column name, not a channel name: --channels matches the label, ` +\n `and this channel has none.\\n` +\n `Use \"#${owner.index}\" — a channel with no label can only be addressed by position.`\n : `\"${term}\" is a column name, not a channel name: --channels matches the label, ` +\n `which for this channel is \"${owner.label}\".\\n` +\n (typeable(owner.label) === null\n ? `Use \"#${owner.index}\" — ${untypeableBecause(owner.label)}, so position is ` +\n `the only way to reach it.`\n : `Use \"#${owner.index}\" to select just this one, or ${typeable(owner.label)} ` +\n `for every channel sharing that label.`),\n );\n }\n /*\n The annotation channel is a channel, and this said the file had none by that name.\n\n `EDF Annotations` is a label the file really carries — the spec reserves it, --info\n counts it on the \"Channels\" line, and it is the name anyone reading about EDF+ meets\n first. Asking for it got \"No channel named \"EDF Annotations\". Run with --info to list\n the channels in this file\", which is false about the file and points at a table that\n does not list it either, so following the advice returns the reader to the same\n message. What they were after is already being written: every conversion of a file\n with this channel writes annotations.csv from it.\n */\n const asAnnotations = signals.find(\n (signal) => signal.isAnnotations && signal.label.toLowerCase() === term.toLowerCase(),\n );\n /*\n And a term that is merely near it, which the paragraph above describes and did not\n cover.\n\n The exact spelling got the whole explanation; one character off got \"No channel named\n \"EDF Annotation\". Run with --info to list the channels in this file\" — which is the\n sentence that comment calls false about the file and a pointer at a table that does\n not list the channel either. `EDF Annotations` on a BDF+ recording is the same miss by\n the prefix, and `--channels annotations` by everything but the noun; all three are how\n somebody who has read about EDF+ actually types it.\n\n Only when no signal label is close, so a suggestion about a real column always wins,\n and by the same distance rule that suggestion uses rather than a second one.\n */\n const hint = suggest(term, candidates);\n const nearAnnotations =\n hint === ''\n ? signals.find(\n (signal) =>\n signal.isAnnotations &&\n signal.label !== '' &&\n nearness(term, signal.label) <= reach(term),\n )\n : undefined;\n const annotationChannel = asAnnotations ?? nearAnnotations;\n if (annotationChannel) {\n const exact = annotationChannel.label.toLowerCase() === term.toLowerCase();\n throw new ChannelSelectionError(\n (exact\n ? `\"${term}\" is this recording's annotation channel, not a signal: it holds event `\n : `There is no channel named \"${term}\"; the nearest thing to it is this ` +\n `recording's annotation channel \"${annotationChannel.label}\", which holds event `) +\n `text rather than samples, so it has no column to select.\\n` +\n `Its events are already written to annotations.csv by any conversion of this ` +\n `file — pass --annotations-only for those and no signal data.`,\n );\n }\n /*\n Advice that works in the mode it was given in.\n\n `--info` takes `--channels` too — it is how you check a selection before converting\n with it — and a term that matches nothing is refused there exactly as it is here. So\n `edf2csv rec.edf --info --channels nope` answered:\n\n error: No channel named \"nope\".\n Run with --info to list the channels in this file.\n\n which is the command that just printed it. The listing is what `--info` does; what\n stopped it was the `--channels` beside it. Naming both makes the sentence true of a\n conversion as well, where dropping `--channels` alone would convert the whole file\n rather than list anything.\n */\n throw new ChannelSelectionError(\n `No channel named \"${term}\".${hint}\\n` +\n `Run with --info and no --channels to list the channels in this file.`,\n );\n }\n if (matched.length > 1) ambiguous.push({ term, matched: [...matched] });\n for (const signal of matched) chosen.set(signal.index, signal);\n }\n\n const selected = [...chosen.values()].sort((a, b) => a.index - b.index);\n if (selected.length === 0) {\n throw new ChannelSelectionError('No channels were selected.');\n }\n return { signals: selected, ambiguous };\n}\n\n/**\n * How far a label is from what was typed, and how far the suggestion reaches.\n *\n * Two functions ask the same question — the \"did you mean\" list, and the annotation channel\n * one branch above it — and a rule with two copies is a rule that will get two answers.\n */\nfunction nearness(term: string, label: string): number {\n const needle = term.toLowerCase();\n const lower = label.toLowerCase();\n return needle !== '' && lower.includes(needle) ? 0 : editDistance(needle, lower);\n}\n\nfunction reach(term: string): number {\n return Math.max(2, Math.floor(term.length / 3));\n}\n\nfunction suggest(term: string, candidates: readonly EdfSignal[]): string {\n // Duplicated labels would otherwise be suggested twice, which reads like two\n // different options while naming the same thing.\n const unique = [...new Set(candidates.map((s) => s.label))];\n /*\n A label that contains the term is the answer, however many edits away it is.\n\n Edit distance charges one edit per character the label has and the term does not, so an\n abbreviation — the commonest way to get this wrong — scores worse the more of the label it\n leaves out. On a recording whose channels are `EEG Fpz-Cz` and `ECG`, `--channels EEG` is\n seven edits from the channel it names and one from the other, and the answer was `Did you\n mean \"ECG\"?`: a suggestion that is retypeable, close, and about the wrong signal. Taking it\n converts a heart trace under the belief it is an EEG, and the run succeeds.\n */\n const scored = unique\n .map((label) => ({ label, distance: nearness(term, label) }))\n .filter((c) => c.label !== '' && c.distance <= reach(term))\n .sort((a, b) => a.distance - b.distance);\n if (scored.length === 0) return '';\n /*\n A suggestion is something to retype, so it has to be retypeable.\n\n `Did you mean \"EEG \"A1\"\"?` collapses in a shell to `EEG A1`, which this then rejects with\n the same sentence and the same suggestion — a loop the reader cannot get out of by doing\n what it says. Same failure the header parser's `--channels` advice was fixed for in\n 0.7.18, and the branch above it here; a label carrying `$` or a backtick is the same thing\n again, since a shell expands both inside double quotes.\n\n Where nothing can be typed — a label with a comma, which --channels splits after the shell\n has finished with it, or one with a control byte in it — the position is offered instead.\n It is not the name they asked about, but it is the answer to what they wanted.\n */\n /*\n Cut to three, and the rest counted rather than dropped.\n\n `.slice(0, 3)` said nothing about what it left. On a recording with channels ECG1 to ECG5,\n `--channels ECG` is one edit from all five and the answer was `Did you mean \"ECG1\", \"ECG2\",\n \"ECG3\"?` — three of five equally good answers, with nothing to say the list was cut. A\n reader has no way to tell ECG4 from a channel that does not exist, and this sentence is the\n only place the tool offers to tell them what does.\n\n Through `listed`, which every other list in a sentence goes through: it counts what it\n leaves, and shows a fourth item rather than hiding it behind a phrase longer than the item.\n */\n const offered = scored.map((c) => typeable(c.label) ?? `\"#${positionOf(c.label, candidates)}\"`);\n return ` Did you mean ${listed(offered, 3)}?`;\n}\n\n/** The first channel carrying this label, for a suggestion that cannot be made by name. */\nfunction positionOf(label: string, candidates: readonly EdfSignal[]): number {\n return candidates.find((signal) => signal.label === label)?.index ?? 0;\n}\n\n/**\n * The label written so that typing it back selects this channel, or null when nothing does.\n *\n * Double quotes wherever they survive, since they also show where the label begins and ends\n * and every documented example is written that way. They do not survive a label containing a\n * quote of their own, and — less obviously — a shell still expands `$`, a backtick and a\n * backslash inside them, so `EEG $ref` would arrive as `EEG ` with nothing said. Those go in\n * single quotes, the one POSIX form with no escapes inside it, where a single quote in the\n * label closes, escapes and reopens.\n *\n * Two labels have no form at all. `--channels` splits its list on commas after the shell has\n * finished quoting, so no quoting reaches a label with one in it; and a control character\n * cannot be typed. Both take a position instead, which is what NONPRINTABLE_LABEL already\n * says for the same two reasons.\n */\nexport function typeable(label: string): string | null {\n if (label === '' || label.includes(',')) return null;\n if (/[\\u0000-\\u001f\\u007f-\\u009f]/u.test(label)) return null;\n if (!/[\"$`\\\\]/u.test(label)) return `\"${label}\"`;\n return `'${label.replaceAll(\"'\", \"'\\\\''\")}'`;\n}\n\n/** Why a label has no typeable form, for the sentence that offers a position instead. */\nfunction untypeableBecause(label: string): string {\n if (label.includes(',')) return 'a comma in the label would read as two names';\n return 'the label cannot be typed';\n}\n"]}
1
+ {"version":3,"file":"channels.js","sourceRoot":"","sources":["../../src/convert/channels.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Channel naming and selection.\n *\n * Two real-world facts drive this module. EDF labels are free text that routinely\n * contain spaces and punctuation (\"EEG Fpz-Cz\"), and they are not guaranteed to be\n * unique — CHB-MIT recordings ship two channels both labelled \"T8-P8\", and some\n * carry a channel labelled \"-\". Labels are therefore preserved verbatim in output,\n * and only disambiguated when the file itself is ambiguous.\n */\n\nimport type { EdfSignal } from '../edf/header.js';\nimport { editDistance } from '../format/distance.js';\nimport { listed } from '../format/list.js';\nimport { assertOptions, assertSignals } from './options.js';\n\n/**\n * The name of the column the writer puts in front of the channels, which no channel may take.\n *\n * Exported and used by the writer rather than repeated there, because the whole point of\n * reserving it here is that the two cannot drift apart.\n */\nexport const TIME_COLUMN = 'time_s';\n\nexport class ChannelSelectionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ChannelSelectionError';\n }\n}\n\n/**\n * Column name for a signal.\n *\n * Names are derived from the whole file, not from the current selection, so a given\n * channel always produces the same column regardless of which channels were asked\n * for. Duplicated labels get a `_ch<index>` suffix pointing at their position in\n * the file, which is the only thing that reliably tells them apart.\n */\nexport function buildColumnNames(signals: readonly EdfSignal[]): Map<number, string> {\n // The same check `selectChannels` makes below, for the same reason: this is exported, and a\n // string is iterable, so `buildColumnNames('ECG')` came back as a Map rather than an error.\n assertSignals(signals);\n const counts = new Map<string, number>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n counts.set(base, (counts.get(base) ?? 0) + 1);\n }\n\n const names = new Map<number, string>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n const duplicated = (counts.get(base) ?? 0) > 1;\n names.set(signal.index, duplicated ? `${base}_ch${signal.index}` : base);\n }\n\n /*\n The suffix has to be checked against the file, not only against the label it disambiguates.\n\n Counting collisions on the raw label alone made the suffix a guess: `_ch<index>` is unique\n among the channels sharing that label, and nothing stopped it from landing on a label some\n other channel already had. A file labelled T8, T8, T8_ch0 — all three legal, since EDF\n labels are free text and nothing enforces uniqueness — produced\n\n time_s,T8_ch0,T8_ch1,T8_ch0\n\n two columns with one name, while the warning beside it said the suffix kept them\n \"distinguishable\". channels.csv listed the same name against two signal indices, so the\n join it exists for could not resolve it either, and `df[\"T8_ch0\"]` in pandas or R returns\n one of the two with nothing to say which.\n\n Anything still shared after the first pass takes its own position as well. That is unique\n by construction, so the loop settles immediately in practice; the bound is there because\n a second round could in principle land on yet another literal label.\n\n The time column counts as taken, for the same reason. It is a name this file does not\n supply and the writer does, and a channel labelled `time_s` collided with it in silence:\n the header came out `time_s,time_s,ECG`, channels.csv named the channel's column `time_s`,\n and every read-back the documentation gives — `index_col=\"time_s\"`, `pop(\"time_s\")`,\n `pivot(index=\"time_s\")` — resolves that to one of the two columns without saying which.\n pandas and Python's own `csv.DictReader` resolve it opposite ways round.\n */\n for (let round = 0; round < 8; round++) {\n const taken = new Map<string, number>([[TIME_COLUMN, 1]]);\n for (const name of names.values()) taken.set(name, (taken.get(name) ?? 0) + 1);\n\n const clashing = [...names].filter(([, name]) => (taken.get(name) ?? 0) > 1);\n if (clashing.length === 0) break;\n for (const [index, name] of clashing) names.set(index, `${name}_ch${index}`);\n }\n return names;\n}\n\nfunction baseName(signal: EdfSignal): string {\n return signal.label === '' ? `signal_${signal.index}` : signal.label;\n}\n\n/**\n * Channels whose column name was pushed off their own label to keep the header unique.\n *\n * A channel genuinely labelled `T8_ch0` loses that name when another label's disambiguating\n * suffix wants it, and the resulting column is the one thing in the output that no longer\n * matches the file. Silence there is what made the collision hard to see in the first place:\n * the only warning raised was about the *other* label.\n */\nexport function renamedByCollision(\n signals: readonly EdfSignal[],\n columnNames: ReadonlyMap<number, string>,\n): EdfSignal[] {\n const counts = new Map<string, number>();\n for (const signal of signals) {\n if (signal.isAnnotations) continue;\n const base = baseName(signal);\n counts.set(base, (counts.get(base) ?? 0) + 1);\n }\n return signals.filter(\n (signal) =>\n !signal.isAnnotations &&\n (counts.get(baseName(signal)) ?? 0) === 1 &&\n columnNames.get(signal.index) !== baseName(signal),\n );\n}\n\nexport interface ChannelSelection {\n signals: EdfSignal[];\n /** Labels that matched more than one channel, so the user knows why they got extras. */\n ambiguous: { term: string; matched: EdfSignal[] }[];\n}\n\n/**\n * Resolve a `--channels` specification against the file's signals.\n *\n * Matching is case-insensitive on the exact label, with `#<index>` available to\n * address a specific channel when labels collide. A term that matches nothing is an\n * error rather than a silent omission: quietly dropping a requested channel would\n * hand the user a CSV that is missing data they explicitly asked for.\n */\nexport function selectChannels(signals: readonly EdfSignal[], terms: readonly string[]): ChannelSelection {\n /*\n The same check `buildPlan` makes, made here too, because this is exported on its own.\n\n `assertOptions` covers `convert`; a caller holding a header and calling this directly got\n the shapes it names in full. `'ECG'` was iterated character by character and answered `No\n channel named \"E\". Did you mean \"ECG\"?` — which reads like a real answer about the file —\n and `[1]`, a position written the way a position is written, came back as `TypeError:\n rawTerm.trim is not a function` from the loop below. Both are the argument being the wrong\n shape, which is the case that checker exists for, and both now say so.\n */\n assertOptions({ channels: terms });\n // And the argument in front of it, which that call has never covered.\n assertSignals(signals);\n\n const candidates = signals.filter((s) => !s.isAnnotations);\n const byLabel = new Map<string, EdfSignal[]>();\n for (const signal of candidates) {\n const key = signal.label.toLowerCase();\n const bucket = byLabel.get(key);\n if (bucket) bucket.push(signal);\n else byLabel.set(key, [signal]);\n }\n\n const chosen = new Map<number, EdfSignal>();\n const ambiguous: { term: string; matched: EdfSignal[] }[] = [];\n /*\n A file may have no signal channels at all, and both position errors below assumed it had\n some. `listed([])` is the empty string, so `--channels \"#0\"` on an annotations-only\n recording — one of this tool's own fixtures — was refused with\n\n No channel at position #0. This file has signal channels at .\n\n a sentence that states there are channels and then names none, ending mid-clause. The\n conversion already raises NO_SIGNAL_CHANNELS for this file and says what it is, so the\n fact was known one layer up; the message that a `--channels` user actually reaches was\n the one that did not have it.\n */\n /*\n And one channel is not \"channels\", which is the same sentence one item further on.\n\n The zero case above was given its own wording because \"signal channels at .\" states there\n are some and then names none. A file with exactly one got \"This file has signal channels\n at #0.\" — a plural over a list of one, on the line whose job is to say what may be typed\n instead. Single-channel recordings are ordinary; two of this repository's own fixtures are\n one.\n */\n const positions =\n candidates.length === 0\n ? 'This file has no signal channels; it contains only annotations.'\n : candidates.length === 1\n ? `This file has one signal channel, at #${candidates[0]?.index ?? 0}.`\n : `This file has signal channels at ${listed(candidates.map((s) => `#${s.index}`))}.`;\n\n for (const rawTerm of terms) {\n const term = rawTerm.trim();\n if (term === '') continue;\n\n // '#N' addresses a channel by position, but a label may literally be \"#5\".\n // A real label always wins, so no channel becomes unreachable.\n const literal = byLabel.get(term.toLowerCase());\n if (term.startsWith('#') && (!literal || literal.length === 0)) {\n /*\n A position must be written in plain digits.\n\n `Number()` was doing the parsing, and it accepts a great deal more than a position:\n `#0x2` reached channel 2 through hex, `#0b1` channel 1 through binary, `#1e0` and\n `#2.0` and `# 2` all landed somewhere, and `#` on its own became `Number('')`, which\n is 0. Every one of them selected a channel and exited 0, so a slip did not fail —\n it quietly converted a different channel than the one asked for, which for this tool\n is the worst way to be wrong.\n */\n const position = term.slice(1);\n if (!/^\\d+$/u.test(position)) {\n throw new ChannelSelectionError(\n `\"${term}\" is not a channel position: a position is #0, #1, #2 and so on.\\n` +\n positions,\n );\n }\n const index = Number(position);\n const signal = candidates.find((s) => s.index === index);\n if (!signal) {\n /*\n The list of positions is advice, and advice goes on the continuation line.\n\n It sat on the first line here and on the second in the refusal six lines up, which\n is the same sentence about the same thing. The first line is the one a log gets\n grepped for and the one `printableLines` leaves whole at any width, so on a\n 40-channel recording this refusal ran to 111 columns where its neighbour ran to 74.\n */\n throw new ChannelSelectionError(\n `No channel at position ${term}.\\n${positions}`,\n );\n }\n chosen.set(signal.index, signal);\n continue;\n }\n\n const matched = byLabel.get(term.toLowerCase());\n if (!matched || matched.length === 0) {\n /*\n The one term that is certainly not a typo: a column name.\n\n Matching is on the label, and where a label collides the column gains a `_ch<index>`\n suffix — so `T8-P8_ch1` is a name this tool invented, prints in the COLUMN column of\n --info, writes into channels.csv and puts at the head of signals.csv, and then\n rejects with \"No channel named \"T8-P8_ch1\". Run with --info to list the channels in\n this file\", which is where the user copied it from. The reference documents the trap;\n the message a user actually hits did not.\n\n Answered where it is asked instead, and with the thing that works: `#<index>` selects\n one channel of a colliding pair, which is exactly what someone reaching for the\n suffixed column name wants and the only way to get it.\n */\n const columns = buildColumnNames(candidates);\n const owner = candidates.find(\n (signal) => (columns.get(signal.index) ?? '').toLowerCase() === term.toLowerCase(),\n );\n if (owner) {\n // A channel with no label at all gets the column `signal_<index>`, and offering its\n // label back would be offering `\"\"` — there is nothing to type. Position is the only\n // way to reach it, and saying so is more use than quoting an empty string twice.\n throw new ChannelSelectionError(\n owner.label === ''\n ? `\"${term}\" is a column name, not a channel name: --channels matches the label, ` +\n `and this channel has none.\\n` +\n `Use \"#${owner.index}\" — a channel with no label can only be addressed by position.`\n : `\"${term}\" is a column name, not a channel name: --channels matches the label, ` +\n `which for this channel is \"${owner.label}\".\\n` +\n (typeable(owner.label) === null\n ? `Use \"#${owner.index}\" — ${untypeableBecause(owner.label)}, so position is ` +\n `the only way to reach it.`\n : `Use \"#${owner.index}\" to select just this one, or ${typeable(owner.label)} ` +\n `for every channel sharing that label.`),\n );\n }\n /*\n The annotation channel is a channel, and this said the file had none by that name.\n\n `EDF Annotations` is a label the file really carries — the spec reserves it, --info\n counts it on the \"Channels\" line, and it is the name anyone reading about EDF+ meets\n first. Asking for it got \"No channel named \"EDF Annotations\". Run with --info to list\n the channels in this file\", which is false about the file and points at a table that\n does not list it either, so following the advice returns the reader to the same\n message. What they were after is already being written: every conversion of a file\n with this channel writes annotations.csv from it.\n */\n const asAnnotations = signals.find(\n (signal) => signal.isAnnotations && signal.label.toLowerCase() === term.toLowerCase(),\n );\n /*\n And a term that is merely near it, which the paragraph above describes and did not\n cover.\n\n The exact spelling got the whole explanation; one character off got \"No channel named\n \"EDF Annotation\". Run with --info to list the channels in this file\" — which is the\n sentence that comment calls false about the file and a pointer at a table that does\n not list the channel either. `EDF Annotations` on a BDF+ recording is the same miss by\n the prefix, and `--channels annotations` by everything but the noun; all three are how\n somebody who has read about EDF+ actually types it.\n\n Only when no signal label is close, so a suggestion about a real column always wins,\n and by the same distance rule that suggestion uses rather than a second one.\n */\n const hint = suggest(term, candidates);\n const nearAnnotations =\n hint === ''\n ? signals.find(\n (signal) =>\n signal.isAnnotations &&\n signal.label !== '' &&\n nearness(term, signal.label) <= reach(term),\n )\n : undefined;\n const annotationChannel = asAnnotations ?? nearAnnotations;\n if (annotationChannel) {\n const exact = annotationChannel.label.toLowerCase() === term.toLowerCase();\n throw new ChannelSelectionError(\n (exact\n ? `\"${term}\" is this recording's annotation channel, not a signal: it holds event `\n : `There is no channel named \"${term}\"; the nearest thing to it is this ` +\n `recording's annotation channel \"${annotationChannel.label}\", which holds event `) +\n `text rather than samples, so it has no column to select.\\n` +\n `Its events are already written to annotations.csv by any conversion of this ` +\n `file — pass --annotations-only for those and no signal data.`,\n );\n }\n /*\n Advice that works in the mode it was given in.\n\n `--info` takes `--channels` too — it is how you check a selection before converting\n with it — and a term that matches nothing is refused there exactly as it is here. So\n `edf2csv rec.edf --info --channels nope` answered:\n\n error: No channel named \"nope\".\n Run with --info to list the channels in this file.\n\n which is the command that just printed it. The listing is what `--info` does; what\n stopped it was the `--channels` beside it. Naming both makes the sentence true of a\n conversion as well, where dropping `--channels` alone would convert the whole file\n rather than list anything.\n */\n throw new ChannelSelectionError(\n `No channel named \"${term}\".${hint}\\n` +\n `Run with --info and no --channels to list the channels in this file.`,\n );\n }\n if (matched.length > 1) ambiguous.push({ term, matched: [...matched] });\n for (const signal of matched) chosen.set(signal.index, signal);\n }\n\n const selected = [...chosen.values()].sort((a, b) => a.index - b.index);\n if (selected.length === 0) {\n throw new ChannelSelectionError('No channels were selected.');\n }\n return { signals: selected, ambiguous };\n}\n\n/**\n * How far a label is from what was typed, and how far the suggestion reaches.\n *\n * Two functions ask the same question — the \"did you mean\" list, and the annotation channel\n * one branch above it — and a rule with two copies is a rule that will get two answers.\n */\nfunction nearness(term: string, label: string): number {\n const needle = term.toLowerCase();\n const lower = label.toLowerCase();\n return needle !== '' && lower.includes(needle) ? 0 : editDistance(needle, lower);\n}\n\nfunction reach(term: string): number {\n return Math.max(2, Math.floor(term.length / 3));\n}\n\nfunction suggest(term: string, candidates: readonly EdfSignal[]): string {\n // Duplicated labels would otherwise be suggested twice, which reads like two\n // different options while naming the same thing.\n const unique = [...new Set(candidates.map((s) => s.label))];\n /*\n A label that contains the term is the answer, however many edits away it is.\n\n Edit distance charges one edit per character the label has and the term does not, so an\n abbreviation — the commonest way to get this wrong — scores worse the more of the label it\n leaves out. On a recording whose channels are `EEG Fpz-Cz` and `ECG`, `--channels EEG` is\n seven edits from the channel it names and one from the other, and the answer was `Did you\n mean \"ECG\"?`: a suggestion that is retypeable, close, and about the wrong signal. Taking it\n converts a heart trace under the belief it is an EEG, and the run succeeds.\n */\n const scored = unique\n .map((label) => ({ label, distance: nearness(term, label) }))\n .filter((c) => c.label !== '' && c.distance <= reach(term))\n .sort((a, b) => a.distance - b.distance);\n if (scored.length === 0) return '';\n /*\n A suggestion is something to retype, so it has to be retypeable.\n\n `Did you mean \"EEG \"A1\"\"?` collapses in a shell to `EEG A1`, which this then rejects with\n the same sentence and the same suggestion — a loop the reader cannot get out of by doing\n what it says. Same failure the header parser's `--channels` advice was fixed for in\n 0.7.18, and the branch above it here; a label carrying `$` or a backtick is the same thing\n again, since a shell expands both inside double quotes.\n\n Where nothing can be typed — a label with a comma, which --channels splits after the shell\n has finished with it, or one with a control byte in it — the position is offered instead.\n It is not the name they asked about, but it is the answer to what they wanted.\n */\n /*\n Cut to three, and the rest counted rather than dropped.\n\n `.slice(0, 3)` said nothing about what it left. On a recording with channels ECG1 to ECG5,\n `--channels ECG` is one edit from all five and the answer was `Did you mean \"ECG1\", \"ECG2\",\n \"ECG3\"?` — three of five equally good answers, with nothing to say the list was cut. A\n reader has no way to tell ECG4 from a channel that does not exist, and this sentence is the\n only place the tool offers to tell them what does.\n\n Through `listed`, which every other list in a sentence goes through: it counts what it\n leaves, and shows a fourth item rather than hiding it behind a phrase longer than the item.\n */\n const offered = scored.map((c) => typeable(c.label) ?? `\"#${positionOf(c.label, candidates)}\"`);\n return ` Did you mean ${listed(offered, 3)}?`;\n}\n\n/** The first channel carrying this label, for a suggestion that cannot be made by name. */\nfunction positionOf(label: string, candidates: readonly EdfSignal[]): number {\n return candidates.find((signal) => signal.label === label)?.index ?? 0;\n}\n\n/**\n * The label written so that typing it back selects this channel, or null when nothing does.\n *\n * Double quotes wherever they survive, since they also show where the label begins and ends\n * and every documented example is written that way. They do not survive a label containing a\n * quote of their own, and — less obviously — a shell still expands `$`, a backtick and a\n * backslash inside them, so `EEG $ref` would arrive as `EEG ` with nothing said. Those go in\n * single quotes, the one POSIX form with no escapes inside it, where a single quote in the\n * label closes, escapes and reopens.\n *\n * Two labels have no form at all. `--channels` splits its list on commas after the shell has\n * finished quoting, so no quoting reaches a label with one in it; and a control character\n * cannot be typed. Both take a position instead, which is what NONPRINTABLE_LABEL already\n * says for the same two reasons.\n */\nexport function typeable(label: string): string | null {\n if (label === '' || label.includes(',')) return null;\n if (/[\\u0000-\\u001f\\u007f-\\u009f]/u.test(label)) return null;\n if (!/[\"$`\\\\]/u.test(label)) return `\"${label}\"`;\n return `'${label.replaceAll(\"'\", \"'\\\\''\")}'`;\n}\n\n/** Why a label has no typeable form, for the sentence that offers a position instead. */\nfunction untypeableBecause(label: string): string {\n if (label.includes(',')) return 'a comma in the label would read as two names';\n return 'the label cannot be typed';\n}\n"]}
@@ -47,6 +47,24 @@ export declare function assertOptions(options: {
47
47
  toStdout?: boolean | undefined;
48
48
  onProgress?: unknown;
49
49
  }): void;
50
+ /**
51
+ * The channel list two exported functions take, checked the way their other argument is.
52
+ *
53
+ * `selectChannels(signals, terms)` has checked `terms` since 0.6.x — "`'ECG'` was iterated
54
+ * character by character and answered `No channel named \"E\"`… naming nothing the caller had
55
+ * written" — and never checked `signals`, which is the argument in front of it. Passing one
56
+ * signal where the list goes, or a header where its `signals` goes, came back as
57
+ * `TypeError: signals.filter is not a function`: a local of this package, over a value the
58
+ * caller did write.
59
+ *
60
+ * `buildColumnNames` is worse off, because a string is iterable. `buildColumnNames('ECG')`
61
+ * returned `Map { null => 'undefined_chundefined' }` and no error at all — a column name for a
62
+ * channel that does not exist, keyed by a position that is not one.
63
+ *
64
+ * The bad entry is named by position rather than the whole list being printed back: a header
65
+ * may declare hundreds of channels, and a message is not the place for all of them.
66
+ */
67
+ export declare function assertSignals(signals: unknown): void;
50
68
  /**
51
69
  * The recording to read, checked before it is opened.
52
70
  *
@@ -203,6 +203,34 @@ export function assertOptions(options) {
203
203
  }
204
204
  }
205
205
  }
206
+ /**
207
+ * The channel list two exported functions take, checked the way their other argument is.
208
+ *
209
+ * `selectChannels(signals, terms)` has checked `terms` since 0.6.x — "`'ECG'` was iterated
210
+ * character by character and answered `No channel named \"E\"`… naming nothing the caller had
211
+ * written" — and never checked `signals`, which is the argument in front of it. Passing one
212
+ * signal where the list goes, or a header where its `signals` goes, came back as
213
+ * `TypeError: signals.filter is not a function`: a local of this package, over a value the
214
+ * caller did write.
215
+ *
216
+ * `buildColumnNames` is worse off, because a string is iterable. `buildColumnNames('ECG')`
217
+ * returned `Map { null => 'undefined_chundefined' }` and no error at all — a column name for a
218
+ * channel that does not exist, keyed by a position that is not one.
219
+ *
220
+ * The bad entry is named by position rather than the whole list being printed back: a header
221
+ * may declare hundreds of channels, and a message is not the place for all of them.
222
+ */
223
+ export function assertSignals(signals) {
224
+ if (!Array.isArray(signals)) {
225
+ throw new OptionError(`signals must be the channel list from a header, got ${describeValue(signals)}.`);
226
+ }
227
+ const wrong = signals.findIndex((signal) => typeof signal !== 'object' ||
228
+ signal === null ||
229
+ typeof signal.index !== 'number');
230
+ if (wrong !== -1) {
231
+ throw new OptionError(`signals[${wrong}] is not a channel from a header, got ${describeValue(signals[wrong])}.`);
232
+ }
233
+ }
206
234
  /**
207
235
  * The recording to read, checked before it is opened.
208
236
  *
@@ -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}): void {\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 recording to read, checked before it is opened.\n *\n * `EdfFile.open` hands whatever it is given to `fs`, and the refusal comes back as an\n * `EdfError` coded `UNREADABLE`, hinted \"Check the path is spelled the way it is on disk and\n * that you have permission to read it\" — advice about a path, over a value that is not one,\n * filed as a problem with the recording rather than with the call. `convert({ input: 'a.edf' })`,\n * which is the option-bag shape the second parameter has, answered `Cannot read \"[object\n * Object]\"`; `convert(['a.edf', 'b.edf'])` answered `Cannot read \"a.edf,b.edf\"`, a path the\n * caller never wrote, because `String` of an array joins it with commas.\n *\n * The empty string is left to `fs`, which has no such file and says so truthfully — the same\n * reasoning `outputDir` states for not trimming: a path is not a keyword.\n */\nexport function assertInputPath(input: unknown): void {\n if (typeof input !== 'string') {\n throw new OptionError(`input must be a path to a recording, got ${describeValue(input)}.`);\n }\n}\n\n/**\n * How a rejected value reads in the refusal: numbers bare, everything else quoted so its\n * type is visible.\n *\n * `JSON.stringify` has no text for a function or a symbol — it returns `undefined`, not a\n * string — so `layout: () => 'long'` came back as `layout must be \"wide\" or \"long\", got\n * undefined.`, which names the one value that does not raise this: every option here is\n * optional, and `undefined` is how a caller says they are not passing it. `input` was worse,\n * since `convert(undefined)` and `convert(someFunction)` then produced the same sentence, and\n * the first is a forgotten argument while the second is a wrong one.\n *\n * Exported because time-range.ts had the identical function, fixed there and not here — the\n * same two-copies-of-one-helper the derived precision and the pluraliser were each pulled\n * together for.\n */\nexport function describeValue(value: unknown): string {\n return typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value);\n}\n"]}
1
+ {"version":3,"file":"options.js","sourceRoot":"","sources":["../../src/convert/options.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Checking the options a caller passed, before anything is written.\n *\n * The command line has always validated these — `--decimals 1.5` is a usage error and always\n * has been — and the library did not, so the same value behaved differently depending on how\n * it arrived. `convert(file, { decimals: NaN })` resolved successfully having written whole\n * numbers into a column the caller had asked for decimals in, which is the worst of the\n * three: no error, no warning, and output that looks like a deliberate choice. `decimals: -1`\n * reached `toFixed` and came back as a bare RangeError from deep inside the formatter, naming\n * nothing the caller had written. `start: NaN` created the output directory, wrote\n * signals.csv, and then failed with a message about the input being unreadable — a partial\n * conversion, blamed on the file.\n *\n * These run at the top of `buildPlan`, which every path goes through before a directory is\n * created or a stream is opened, so a rejected option leaves nothing behind.\n */\n\n/** A problem with the options a caller passed, as opposed to a problem with the file. */\nexport class OptionError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'OptionError';\n }\n}\n\n/**\n * The largest `--decimals` accepts, and what both documentation pages state.\n *\n * Not a limit of `toFixed`, whatever this comment used to say. `toFixed` takes 0 to 100 and\n * throws a RangeError at 101 — which is exactly the belief `MAX_DERIVED_DECIMALS` in\n * edf/scale.ts exists to correct, having once clamped the *derived* precision to 20 on the\n * same wrong grounds and rounded a magnetometer channel needing 23 places onto a grid three\n * digital codes wide, losing 69% of its samples in silence.\n *\n * Twenty is a bound on a number a person types by hand, not on what the format can express.\n * The derived precision, which nobody types, runs to 100 and says so.\n */\nexport const MAX_DECIMALS = 20;\n\nexport function assertOptions(options: {\n decimals?: number | undefined;\n start?: number | undefined;\n duration?: number | undefined;\n end?: number | undefined;\n layout?: string | undefined;\n channels?: readonly string[] | undefined;\n outputDir?: string | undefined;\n annotationsOnly?: boolean | undefined;\n gzip?: boolean | undefined;\n bom?: boolean | undefined;\n force?: boolean | undefined;\n checksum?: boolean | undefined;\n toStdout?: boolean | undefined;\n onProgress?: unknown;\n}): void {\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 const wrong = signals.findIndex(\n (signal) =>\n typeof signal !== 'object' ||\n signal === null ||\n typeof (signal as { index?: unknown }).index !== 'number',\n );\n if (wrong !== -1) {\n throw new OptionError(\n `signals[${wrong}] is not a channel from a header, got ${describeValue(signals[wrong])}.`,\n );\n }\n}\n\n/**\n * The recording to read, checked before it is opened.\n *\n * `EdfFile.open` hands whatever it is given to `fs`, and the refusal comes back as an\n * `EdfError` coded `UNREADABLE`, hinted \"Check the path is spelled the way it is on disk and\n * that you have permission to read it\" — advice about a path, over a value that is not one,\n * filed as a problem with the recording rather than with the call. `convert({ input: 'a.edf' })`,\n * which is the option-bag shape the second parameter has, answered `Cannot read \"[object\n * Object]\"`; `convert(['a.edf', 'b.edf'])` answered `Cannot read \"a.edf,b.edf\"`, a path the\n * caller never wrote, because `String` of an array joins it with commas.\n *\n * The empty string is left to `fs`, which has no such file and says so truthfully — the same\n * reasoning `outputDir` states for not trimming: a path is not a keyword.\n */\nexport function assertInputPath(input: unknown): void {\n if (typeof input !== 'string') {\n throw new OptionError(`input must be a path to a recording, got ${describeValue(input)}.`);\n }\n}\n\n/**\n * How a rejected value reads in the refusal: numbers bare, everything else quoted so its\n * type is visible.\n *\n * `JSON.stringify` has no text for a function or a symbol — it returns `undefined`, not a\n * string — so `layout: () => 'long'` came back as `layout must be \"wide\" or \"long\", got\n * undefined.`, which names the one value that does not raise this: every option here is\n * optional, and `undefined` is how a caller says they are not passing it. `input` was worse,\n * since `convert(undefined)` and `convert(someFunction)` then produced the same sentence, and\n * the first is a forgotten argument while the second is a wrong one.\n *\n * Exported because time-range.ts had the identical function, fixed there and not here — the\n * same two-copies-of-one-helper the derived precision and the pluraliser were each pulled\n * together for.\n */\nexport function describeValue(value: unknown): string {\n return typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value);\n}\n"]}
package/dist/edf/scale.js CHANGED
@@ -23,7 +23,32 @@
23
23
  * so the test suite can assert exact equality against a reference implementation
24
24
  * rather than settling for a tolerance.
25
25
  */
26
+ import { OptionError, describeValue } from '../convert/options.js';
27
+ /** The four header fields this reads, in the order a message should name them. */
28
+ const CALIBRATION = ['digitalMin', 'digitalMax', 'physicalMin', 'physicalMax'];
26
29
  export function makeScaler(signal) {
30
+ /*
31
+ The argument, checked like the arguments of the other exported functions.
32
+
33
+ Every branch below reads four numbers off `signal`, and the first of them — the one that
34
+ catches a header contradicting itself — is `digitalMax === digitalMin`. On an object that
35
+ has neither, that comparison is `undefined === undefined`, which is true. So
36
+ `makeScaler({})` came back as a working function returning NaN for every sample, which is
37
+ exactly what a real channel with a zero digital span returns.
38
+
39
+ A caller cannot tell the two apart. The api page recommends this function for reading
40
+ physical units out of a file, and the empty column it produces is documented as meaning
41
+ "the header contradicts itself" — a sentence about the recording, over a call that passed
42
+ the wrong object. The diagnostic that normally accompanies it, DEGENERATE_DIGITAL_RANGE,
43
+ comes from the header parser and is not raised here at all.
44
+ */
45
+ if (typeof signal !== 'object' || signal === null) {
46
+ throw new OptionError(`signal must be a channel from a header, got ${describeValue(signal)}.`);
47
+ }
48
+ const missing = CALIBRATION.find((name) => typeof signal[name] !== 'number');
49
+ if (missing !== undefined) {
50
+ throw new OptionError(`signal.${missing} must be a number, got ${describeValue(signal[missing])}.`);
51
+ }
27
52
  const { digitalMin, digitalMax, physicalMin, physicalMax } = signal;
28
53
  // A zero digital span leaves the mapping undefined — the header contradicts itself,
29
54
  // so there is no physical value for any sample on this channel.
@@ -1 +1 @@
1
- {"version":3,"file":"scale.js","sourceRoot":"","sources":["../../src/edf/scale.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Digital-to-physical conversion.\n *\n * EDF defines the mapping by two calibration points, (digitalMin -> physicalMin)\n * and (digitalMax -> physicalMax), which the specification writes as:\n *\n * gain = (physicalMax - physicalMin) / (digitalMax - digitalMin)\n * physical = (digital - digitalMin) * gain + physicalMin\n *\n * That form is evaluated here in EDFlib's algebraically equivalent arrangement:\n *\n * offset = physicalMax / gain - digitalMax\n * physical = gain * (offset + digital)\n *\n * The rearrangement is not cosmetic. Written the first way, a channel spanning\n * +/-800 uV computes a value near 800 and then subtracts 800, and the cancellation\n * throws away low-order bits: digital 0 yields 0.19536019536019467 when the exact\n * value is 0.19536019536019536. EDFlib's form keeps the intermediate small\n * (offset + digital = 0.5 here) and returns the correctly rounded result.\n *\n * Both properties matter. The values are as accurate as a double can express, and\n * they are bit-identical to pyEDFlib and EDFbrowser, which share EDFlib's arithmetic,\n * so the test suite can assert exact equality against a reference implementation\n * rather than settling for a tolerance.\n */\n\nimport type { EdfSignal } from './header.js';\n\nexport type Scaler = (digital: number) => number;\n\nexport function makeScaler(signal: EdfSignal): Scaler {\n const { digitalMin, digitalMax, physicalMin, physicalMax } = signal;\n\n // A zero digital span leaves the mapping undefined — the header contradicts itself,\n // so there is no physical value for any sample on this channel.\n //\n // NaN rather than a stand-in number. Writing the physical minimum produces a column\n // of plausible readings (\"-100.000\" repeated) that is indistinguishable from a real\n // flat recording once the CSV is opened somewhere else, which is exactly the kind of\n // invented data this tool exists to avoid. NaN carries through to an empty CSV cell\n // and reads back as NaN in pandas, matching how a missing annotation duration is\n // already written. DEGENERATE_DIGITAL_RANGE is raised alongside it.\n if (digitalMax === digitalMin) return () => NaN;\n\n const gain = (physicalMax - physicalMin) / (digitalMax - digitalMin);\n\n /*\n A flat physical range makes every sample the same value, and would divide by zero in the\n offset below. That mapping is defined, so its constant is written.\n\n A gain of zero does not always mean flat, and this could not tell the difference. A range\n of -1e-320 to 1e-320 is not flat — it is 65,536 distinct physical values — but the gain\n is 2e-320/65535, which is smaller than the smallest subnormal double and underflows to\n +0. Every distinct sample then took `physicalMin`, so eight codes spanning -16,000 to\n +12,000 came out as one repeated number, with no diagnostic anywhere and `--strict`\n exiting 0. At 1e-319, one power of ten away, the same file raises VALUE_RESOLUTION.\n\n That is the same situation as the overflow below it, which this codebase already reasoned\n about and answered: the span cannot be represented, so there is no mapping, so the cells\n are left empty rather than filled with a value the header cannot justify. Underflow only\n got the flat-range treatment because `gain === 0` is what both look like from here.\n */\n if (gain === 0) return physicalMax === physicalMin ? (): number => physicalMin : (): number => NaN;\n\n // A non-finite gain is a different thing: the physical span overflowed a double, so\n // there is no mapping at all. Returning physicalMin filled the column with one enormous\n // constant — every distinct sample rendered as the same 300-digit number — and raised\n // nothing. NaN takes the same route as a degenerate digital range: empty cells, plus\n // UNUSABLE_PHYSICAL_RANGE from the header parser.\n if (!Number.isFinite(gain)) return () => NaN;\n\n // Deriving the offset divides by the gain. For every realistic calibration that is\n // both safe and more accurate, but an absurd header (a huge physical range over a\n // near-zero gain) could overflow it, so fall back to the specification's own\n // arrangement rather than emitting Infinity.\n const offset = physicalMax / gain - digitalMax;\n if (!Number.isFinite(offset)) {\n return (digital: number): number => (digital - digitalMin) * gain + physicalMin;\n }\n\n return (digital: number): number => gain * (offset + digital);\n}\n\n/**\n * Smallest physical step this channel can express — one digital unit.\n * Used to choose a decimal precision that preserves every distinct sample value.\n */\nexport function quantizationStep(signal: EdfSignal): number {\n const digitalSpan = signal.digitalMax - signal.digitalMin;\n if (digitalSpan === 0) return 0;\n return Math.abs((signal.physicalMax - signal.physicalMin) / digitalSpan);\n}\n\n/**\n * The most `toFixed` accepts. 101 is a RangeError, so this is the ceiling, not a taste.\n *\n * It used to be 20, on the stated grounds that 20 was what `toFixed` allowed. It is not,\n * and the gap was not academic: a magnetometer channel spanning ±1e-16 T over a 16-bit\n * converter has a step of 3.05e-21 and needs 23 places. Clamped to 20, every value landed\n * on a 1e-20 grid — about three digital codes to a printed value — so 69% of the samples\n * could not be recovered, the conversion exited 0, and nothing said a word. The channel\n * type the old comment named as the reason for the ceiling was the one it broke.\n */\nconst MAX_DERIVED_DECIMALS = 100;\n\n/**\n * Places this channel needs before any ceiling, or null when it has no step to derive one from.\n *\n * One expression, because two functions depend on agreeing about it. `decimalsForSignal`\n * computed `Math.ceil(-Math.log10(step)) + 2` and clamped it; `decimalsAreClamped` computed the\n * same thing again and compared it to the same ceiling. Two copies of one formula whose only\n * job is to give the same answer — change either `+ 2` and they part company at the boundary,\n * so a channel whose precision really was capped is reported as not capped, VALUE_RESOLUTION is\n * not raised, and its codes print indistinguishable in silence. Which is the exact thing that\n * warning exists to say.\n */\nfunction decimalsNeeded(signal: EdfSignal): number | null {\n const step = quantizationStep(signal);\n if (!(step > 0) || !Number.isFinite(step)) return null;\n return Math.ceil(-Math.log10(step)) + 2;\n}\n\n/**\n * Decimal places needed so that two adjacent digital codes never round to the same\n * string. Two places past the quantization step keep rounding error far below the\n * resolution the hardware actually recorded, without padding the file with digits\n * that carry no information.\n *\n * Ordinary channels land at three or four: a ±800 µV channel over 12 bits steps by\n * 0.39 µV and needs three. The ceiling is only reached by calibrations whose step is\n * below 1e-98, which an 8-character physical bound can still express — `1e-99` is five\n * characters. Those get VALUE_RESOLUTION rather than silence.\n */\nexport function decimalsForSignal(signal: EdfSignal, max = MAX_DERIVED_DECIMALS): number {\n const needed = decimalsNeeded(signal);\n if (needed === null) return 3;\n return Math.min(max, Math.max(0, needed));\n}\n\n/**\n * Whether this channel's step is finer than any precision the tool can print.\n *\n * Asked of the ceiling, not of the precision in use. `--decimals 2` on a channel needing 3\n * is a trade the caller made knowingly and is not this warning's business — 0.5.10 fixed a\n * version of this that fired on every ordinary EEG at `--decimals 2` and made\n * `--decimals 2 --strict` impossible. But it fixed it by asking \"did the caller choose the\n * precision\", which suppressed the real case too: at `--decimals 20` a channel stepping by\n * 1e-106 printed every one of its codes as `0.00000000000000000000`, in silence.\n *\n * The question is whether anything the tool can print would separate consecutive codes. When\n * the answer is no, that is a ceiling nobody chose, and it holds whatever `--decimals` says.\n */\nexport function decimalsAreClamped(signal: EdfSignal): boolean {\n const needed = decimalsNeeded(signal);\n return needed !== null && needed > MAX_DERIVED_DECIMALS;\n}\n"]}
1
+ {"version":3,"file":"scale.js","sourceRoot":"","sources":["../../src/edf/scale.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Digital-to-physical conversion.\n *\n * EDF defines the mapping by two calibration points, (digitalMin -> physicalMin)\n * and (digitalMax -> physicalMax), which the specification writes as:\n *\n * gain = (physicalMax - physicalMin) / (digitalMax - digitalMin)\n * physical = (digital - digitalMin) * gain + physicalMin\n *\n * That form is evaluated here in EDFlib's algebraically equivalent arrangement:\n *\n * offset = physicalMax / gain - digitalMax\n * physical = gain * (offset + digital)\n *\n * The rearrangement is not cosmetic. Written the first way, a channel spanning\n * +/-800 uV computes a value near 800 and then subtracts 800, and the cancellation\n * throws away low-order bits: digital 0 yields 0.19536019536019467 when the exact\n * value is 0.19536019536019536. EDFlib's form keeps the intermediate small\n * (offset + digital = 0.5 here) and returns the correctly rounded result.\n *\n * Both properties matter. The values are as accurate as a double can express, and\n * they are bit-identical to pyEDFlib and EDFbrowser, which share EDFlib's arithmetic,\n * so the test suite can assert exact equality against a reference implementation\n * rather than settling for a tolerance.\n */\n\nimport type { EdfSignal } from './header.js';\nimport { OptionError, describeValue } from '../convert/options.js';\n\nexport type Scaler = (digital: number) => number;\n\n/** The four header fields this reads, in the order a message should name them. */\nconst CALIBRATION = ['digitalMin', 'digitalMax', 'physicalMin', 'physicalMax'] as const;\n\nexport function makeScaler(signal: EdfSignal): Scaler {\n /*\n The argument, checked like the arguments of the other exported functions.\n\n Every branch below reads four numbers off `signal`, and the first of them — the one that\n catches a header contradicting itself — is `digitalMax === digitalMin`. On an object that\n has neither, that comparison is `undefined === undefined`, which is true. So\n `makeScaler({})` came back as a working function returning NaN for every sample, which is\n exactly what a real channel with a zero digital span returns.\n\n A caller cannot tell the two apart. The api page recommends this function for reading\n physical units out of a file, and the empty column it produces is documented as meaning\n \"the header contradicts itself\" — a sentence about the recording, over a call that passed\n the wrong object. The diagnostic that normally accompanies it, DEGENERATE_DIGITAL_RANGE,\n comes from the header parser and is not raised here at all.\n */\n if (typeof signal !== 'object' || signal === null) {\n throw new OptionError(`signal must be a channel from a header, got ${describeValue(signal)}.`);\n }\n const missing = CALIBRATION.find((name) => typeof signal[name] !== 'number');\n if (missing !== undefined) {\n throw new OptionError(\n `signal.${missing} must be a number, got ${describeValue(signal[missing])}.`,\n );\n }\n const { digitalMin, digitalMax, physicalMin, physicalMax } = signal;\n\n // A zero digital span leaves the mapping undefined — the header contradicts itself,\n // so there is no physical value for any sample on this channel.\n //\n // NaN rather than a stand-in number. Writing the physical minimum produces a column\n // of plausible readings (\"-100.000\" repeated) that is indistinguishable from a real\n // flat recording once the CSV is opened somewhere else, which is exactly the kind of\n // invented data this tool exists to avoid. NaN carries through to an empty CSV cell\n // and reads back as NaN in pandas, matching how a missing annotation duration is\n // already written. DEGENERATE_DIGITAL_RANGE is raised alongside it.\n if (digitalMax === digitalMin) return () => NaN;\n\n const gain = (physicalMax - physicalMin) / (digitalMax - digitalMin);\n\n /*\n A flat physical range makes every sample the same value, and would divide by zero in the\n offset below. That mapping is defined, so its constant is written.\n\n A gain of zero does not always mean flat, and this could not tell the difference. A range\n of -1e-320 to 1e-320 is not flat — it is 65,536 distinct physical values — but the gain\n is 2e-320/65535, which is smaller than the smallest subnormal double and underflows to\n +0. Every distinct sample then took `physicalMin`, so eight codes spanning -16,000 to\n +12,000 came out as one repeated number, with no diagnostic anywhere and `--strict`\n exiting 0. At 1e-319, one power of ten away, the same file raises VALUE_RESOLUTION.\n\n That is the same situation as the overflow below it, which this codebase already reasoned\n about and answered: the span cannot be represented, so there is no mapping, so the cells\n are left empty rather than filled with a value the header cannot justify. Underflow only\n got the flat-range treatment because `gain === 0` is what both look like from here.\n */\n if (gain === 0) return physicalMax === physicalMin ? (): number => physicalMin : (): number => NaN;\n\n // A non-finite gain is a different thing: the physical span overflowed a double, so\n // there is no mapping at all. Returning physicalMin filled the column with one enormous\n // constant — every distinct sample rendered as the same 300-digit number — and raised\n // nothing. NaN takes the same route as a degenerate digital range: empty cells, plus\n // UNUSABLE_PHYSICAL_RANGE from the header parser.\n if (!Number.isFinite(gain)) return () => NaN;\n\n // Deriving the offset divides by the gain. For every realistic calibration that is\n // both safe and more accurate, but an absurd header (a huge physical range over a\n // near-zero gain) could overflow it, so fall back to the specification's own\n // arrangement rather than emitting Infinity.\n const offset = physicalMax / gain - digitalMax;\n if (!Number.isFinite(offset)) {\n return (digital: number): number => (digital - digitalMin) * gain + physicalMin;\n }\n\n return (digital: number): number => gain * (offset + digital);\n}\n\n/**\n * Smallest physical step this channel can express — one digital unit.\n * Used to choose a decimal precision that preserves every distinct sample value.\n */\nexport function quantizationStep(signal: EdfSignal): number {\n const digitalSpan = signal.digitalMax - signal.digitalMin;\n if (digitalSpan === 0) return 0;\n return Math.abs((signal.physicalMax - signal.physicalMin) / digitalSpan);\n}\n\n/**\n * The most `toFixed` accepts. 101 is a RangeError, so this is the ceiling, not a taste.\n *\n * It used to be 20, on the stated grounds that 20 was what `toFixed` allowed. It is not,\n * and the gap was not academic: a magnetometer channel spanning ±1e-16 T over a 16-bit\n * converter has a step of 3.05e-21 and needs 23 places. Clamped to 20, every value landed\n * on a 1e-20 grid — about three digital codes to a printed value — so 69% of the samples\n * could not be recovered, the conversion exited 0, and nothing said a word. The channel\n * type the old comment named as the reason for the ceiling was the one it broke.\n */\nconst MAX_DERIVED_DECIMALS = 100;\n\n/**\n * Places this channel needs before any ceiling, or null when it has no step to derive one from.\n *\n * One expression, because two functions depend on agreeing about it. `decimalsForSignal`\n * computed `Math.ceil(-Math.log10(step)) + 2` and clamped it; `decimalsAreClamped` computed the\n * same thing again and compared it to the same ceiling. Two copies of one formula whose only\n * job is to give the same answer — change either `+ 2` and they part company at the boundary,\n * so a channel whose precision really was capped is reported as not capped, VALUE_RESOLUTION is\n * not raised, and its codes print indistinguishable in silence. Which is the exact thing that\n * warning exists to say.\n */\nfunction decimalsNeeded(signal: EdfSignal): number | null {\n const step = quantizationStep(signal);\n if (!(step > 0) || !Number.isFinite(step)) return null;\n return Math.ceil(-Math.log10(step)) + 2;\n}\n\n/**\n * Decimal places needed so that two adjacent digital codes never round to the same\n * string. Two places past the quantization step keep rounding error far below the\n * resolution the hardware actually recorded, without padding the file with digits\n * that carry no information.\n *\n * Ordinary channels land at three or four: a ±800 µV channel over 12 bits steps by\n * 0.39 µV and needs three. The ceiling is only reached by calibrations whose step is\n * below 1e-98, which an 8-character physical bound can still express — `1e-99` is five\n * characters. Those get VALUE_RESOLUTION rather than silence.\n */\nexport function decimalsForSignal(signal: EdfSignal, max = MAX_DERIVED_DECIMALS): number {\n const needed = decimalsNeeded(signal);\n if (needed === null) return 3;\n return Math.min(max, Math.max(0, needed));\n}\n\n/**\n * Whether this channel's step is finer than any precision the tool can print.\n *\n * Asked of the ceiling, not of the precision in use. `--decimals 2` on a channel needing 3\n * is a trade the caller made knowingly and is not this warning's business — 0.5.10 fixed a\n * version of this that fired on every ordinary EEG at `--decimals 2` and made\n * `--decimals 2 --strict` impossible. But it fixed it by asking \"did the caller choose the\n * precision\", which suppressed the real case too: at `--decimals 20` a channel stepping by\n * 1e-106 printed every one of its codes as `0.00000000000000000000`, in silence.\n *\n * The question is whether anything the tool can print would separate consecutive codes. When\n * the answer is no, that is a ceiling nobody chose, and it holds whatever `--decimals` says.\n */\nexport function decimalsAreClamped(signal: EdfSignal): boolean {\n const needed = decimalsNeeded(signal);\n return needed !== null && needed > MAX_DERIVED_DECIMALS;\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edf2csv",
3
- "version": "0.8.59",
3
+ "version": "0.8.61",
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",