edf2csv 0.7.148 → 0.7.166

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.
@@ -8,6 +8,7 @@
8
8
  * and only disambiguated when the file itself is ambiguous.
9
9
  */
10
10
  import { listed } from '../format/list.js';
11
+ import { assertOptions } from './options.js';
11
12
  /**
12
13
  * The name of the column the writer puts in front of the channels, which no channel may take.
13
14
  *
@@ -115,6 +116,17 @@ export function renamedByCollision(signals, columnNames) {
115
116
  * hand the user a CSV that is missing data they explicitly asked for.
116
117
  */
117
118
  export function selectChannels(signals, terms) {
119
+ /*
120
+ The same check `buildPlan` makes, made here too, because this is exported on its own.
121
+
122
+ `assertOptions` covers `convert`; a caller holding a header and calling this directly got
123
+ the shapes it names in full. `'ECG'` was iterated character by character and answered `No
124
+ channel named "E". Did you mean "ECG"?` — which reads like a real answer about the file —
125
+ and `[1]`, a position written the way a position is written, came back as `TypeError:
126
+ rawTerm.trim is not a function` from the loop below. Both are the argument being the wrong
127
+ shape, which is the case that checker exists for, and both now say so.
128
+ */
129
+ assertOptions({ channels: terms });
118
130
  const candidates = signals.filter((s) => !s.isAnnotations);
119
131
  const byLabel = new Map();
120
132
  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 { listed } from '../format/list.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 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 const positions =\n candidates.length === 0\n ? 'This file has no signal channels; it contains only annotations.'\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 if (asAnnotations) {\n throw new ChannelSelectionError(\n `\"${term}\" is this recording's annotation channel, not a signal: it 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 throw new ChannelSelectionError(\n `No channel named \"${term}\".${suggest(term, candidates)}\\n` +\n `Run with --info 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\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 const scored = unique\n .map((label) => ({ label, distance: editDistance(term.toLowerCase(), label.toLowerCase()) }))\n .filter((c) => c.label !== '' && c.distance <= Math.max(2, Math.floor(term.length / 3)))\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\n/** Levenshtein distance, two-row variant. */\nfunction editDistance(a: string, b: string): number {\n if (a === b) return 0;\n if (a.length === 0) return b.length;\n if (b.length === 0) return a.length;\n\n let previous = Array.from({ length: b.length + 1 }, (_, i) => i);\n let current = new Array<number>(b.length + 1).fill(0);\n\n for (let i = 1; i <= a.length; i++) {\n current[0] = i;\n for (let j = 1; j <= b.length; j++) {\n const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;\n current[j] = Math.min(\n (current[j - 1] ?? 0) + 1,\n (previous[j] ?? 0) + 1,\n (previous[j - 1] ?? 0) + cost,\n );\n }\n const swap = previous;\n previous = current;\n current = swap;\n }\n return previous[b.length] ?? 0;\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 { 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 const positions =\n candidates.length === 0\n ? 'This file has no signal channels; it contains only annotations.'\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 if (asAnnotations) {\n throw new ChannelSelectionError(\n `\"${term}\" is this recording's annotation channel, not a signal: it 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 throw new ChannelSelectionError(\n `No channel named \"${term}\".${suggest(term, candidates)}\\n` +\n `Run with --info 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\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 const scored = unique\n .map((label) => ({ label, distance: editDistance(term.toLowerCase(), label.toLowerCase()) }))\n .filter((c) => c.label !== '' && c.distance <= Math.max(2, Math.floor(term.length / 3)))\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\n/** Levenshtein distance, two-row variant. */\nfunction editDistance(a: string, b: string): number {\n if (a === b) return 0;\n if (a.length === 0) return b.length;\n if (b.length === 0) return a.length;\n\n let previous = Array.from({ length: b.length + 1 }, (_, i) => i);\n let current = new Array<number>(b.length + 1).fill(0);\n\n for (let i = 1; i <= a.length; i++) {\n current[0] = i;\n for (let j = 1; j <= b.length; j++) {\n const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;\n current[j] = Math.min(\n (current[j - 1] ?? 0) + 1,\n (previous[j] ?? 0) + 1,\n (previous[j - 1] ?? 0) + cost,\n );\n }\n const swap = previous;\n previous = current;\n current = swap;\n }\n return previous[b.length] ?? 0;\n}\n"]}
@@ -398,6 +398,8 @@ function valueWidthOf(channel) {
398
398
  return 0;
399
399
  return widthOf(Math.max(Math.abs(channel.signal.physicalMin), Math.abs(channel.signal.physicalMax)), channel.decimals, channel.signal.physicalMin < 0 || channel.signal.physicalMax < 0);
400
400
  }
401
+ /** Integer digits in `Number.MAX_VALUE`, which is the widest a finite double prints. */
402
+ const MAX_DOUBLE_DIGITS = 309;
401
403
  /** Characters a fixed-decimal number of this magnitude occupies, sign included. */
402
404
  function widthOf(magnitude, decimals, signed = false) {
403
405
  const size = Math.abs(magnitude);
@@ -413,8 +415,22 @@ function widthOf(magnitude, decimals, signed = false) {
413
415
  Measuring the bound as rendered removes that. toFixed switches to exponential notation
414
416
  past 1e21, so the arithmetic form still covers magnitudes beyond it.
415
417
  */
418
+ /*
419
+ A bound that is not a number bounds nothing, so the widest cell it can produce is taken
420
+ instead: 309 digits, which is `Number.MAX_VALUE` written out.
421
+
422
+ One digit was budgeted, and the estimate read low — the one direction the correctness page
423
+ says it never goes. `latest` is `recordCount * recordDuration`, so a header stating a
424
+ record duration near the top of a double overflows it while every sample time under it
425
+ stays finite and prints in full. Three records of 1e308, eight samples:
426
+
427
+ Would write 8 rows, roughly 115 B. signals.csv is 2,244 bytes.
428
+
429
+ Each of those rows carries a 313-character time cell. `fixed` writes an empty cell for a
430
+ value that is itself non-finite, so nothing wider than this is ever printed.
431
+ */
416
432
  if (!Number.isFinite(size))
417
- return sign + 1 + fraction;
433
+ return sign + MAX_DOUBLE_DIGITS + fraction;
418
434
  if (size < 1e21)
419
435
  return sign + size.toFixed(Math.min(decimals, 100)).length;
420
436
  return sign + (Math.floor(Math.log10(size)) + 1) + fraction;
@@ -1 +1 @@
1
- {"version":3,"file":"plan.js","sourceRoot":"","sources":["../../src/convert/plan.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Turning a request into a concrete conversion plan.\n *\n * The plan is where the tool's central promise is enforced: channels recorded at\n * different sampling rates are never merged into one table. A single wide CSV can\n * only hold mixed rates by inventing samples for the slow channels — MNE, for\n * instance, expands three genuine 1 Hz temperature readings into 768 interpolated\n * values without warning. Instead each distinct rate gets its own file, so every\n * number in every output file is a number that was actually recorded.\n */\n\nimport type { Diagnostic } from '../edf/errors.js';\nimport type { EdfSignal } from '../edf/header.js';\nimport { formatRate, formatRates } from '../edf/header.js';\nimport { decimalsAreClamped, decimalsForSignal, makeScaler } from '../edf/scale.js';\nimport { UTF8_BOM, csvRow, escapeCsvField } from '../format/csv.js';\nimport { listed } from '../format/list.js';\nimport { fixed, timeDecimals } from '../format/number.js';\nimport { TIME_COLUMN, buildColumnNames, renamedByCollision, selectChannels } from './channels.js';\nimport { assertOptions } from './options.js';\nimport { countSamplesInRange, resolveRange } from './time-range.js';\nimport type { ResolvedRange } from './time-range.js';\n\nexport interface PlannedChannel {\n signal: EdfSignal;\n column: string;\n decimals: number;\n}\n\nexport interface RateGroup {\n /** Sampling rate in Hz shared by every channel in this group. */\n rate: number;\n samplesPerRecord: number;\n fileName: string;\n timeDecimals: number;\n channels: PlannedChannel[];\n}\n\nexport interface PlanInput {\n signals: readonly EdfSignal[];\n recordDuration: number;\n recordCount: number;\n hasAnnotationChannel: boolean;\n /**\n * True start time of each data record, supplied for discontinuous files. The\n * requested time window is resolved against these rather than against\n * `recordCount * recordDuration`, which for a file with gaps is the amount of\n * data rather than the span of time it covers.\n */\n recordStarts?: Float64Array | null | undefined;\n}\n\nexport interface PlanOptions {\n channels?: readonly string[] | undefined;\n start?: number | undefined;\n /** The `--start` value exactly as typed, for error messages. */\n startText?: string | undefined;\n duration?: number | undefined;\n end?: number | undefined;\n /** The `--end` value exactly as typed, for error messages. */\n endText?: string | undefined;\n annotationsOnly?: boolean | undefined;\n /** Force a fixed number of decimals instead of deriving it per channel. */\n decimals?: number | undefined;\n /** Compress each CSV with gzip, giving every one of them a `.gz` name. */\n gzip?: boolean | undefined;\n /** Start each CSV with a UTF-8 byte order mark, so Excel reads it as UTF-8. */\n bom?: boolean | undefined;\n /**\n * How the samples are arranged in the CSV.\n *\n * `'wide'`, the default, gives one column per channel and one file per sampling rate.\n * `'long'` gives one file, three columns — `time_s`, `channel`, `value` — and one row per\n * sample. See ConversionPlan.layout for why that is the only way to put channels recorded\n * at different rates in one table without inventing samples.\n */\n layout?: 'wide' | 'long' | undefined;\n}\n\nexport interface ConversionPlan {\n groups: RateGroup[];\n /**\n * How the samples are arranged. `'wide'` is a column per channel and a file per rate;\n * `'long'` is `time_s,channel,value`, one row per sample, all rates in one file.\n *\n * The wide layout has to split a mixed-rate recording across files: a 100 Hz channel and\n * a 1 Hz channel share no rows, and putting them in one wide table means either 99 empty\n * cells out of every hundred or inventing the samples that would fill them. In the long\n * layout each sample carries its own time, so nothing has to line up and nothing is\n * invented — which also makes it the one layout `--stdout` can stream for such a file.\n */\n layout: 'wide' | 'long';\n\n /**\n * Whether the CSVs will be compressed.\n *\n * Recorded rather than inferred from the group file names. Under `--annotations-only`\n * there are no groups to read it off, and `--info` named `annotations.csv` for a run that\n * wrote `annotations.csv.gz`.\n */\n gzip: boolean;\n range: ResolvedRange;\n columnNames: Map<number, string>;\n writeSignals: boolean;\n diagnostics: Diagnostic[];\n estimate: OutputEstimate;\n}\n\nexport interface OutputEstimate {\n /** Total data rows across every signal file. */\n rows: number;\n /** Approximate size of the signal CSVs on disk. */\n bytes: number;\n /** True when any single file would exceed Excel's row limit. */\n exceedsSpreadsheetLimit: boolean;\n}\n\nconst BOM_BYTES = Buffer.byteLength(UTF8_BOM);\n\n/** Excel and most spreadsheet tools stop at 1,048,576 rows including the header. */\nexport const SPREADSHEET_ROW_LIMIT = 1_048_576;\n\nexport function buildPlan(input: PlanInput, options: PlanOptions = {}): ConversionPlan {\n // First, and before a directory is created or a stream opened, so a rejected option\n // leaves nothing behind. See assertOptions for what used to get through.\n assertOptions(options);\n\n const diagnostics: Diagnostic[] = [];\n const columnNames = buildColumnNames(input.signals);\n\n // A channel whose own label was taken by something else in the header. Usually that is\n // another channel's disambiguating suffix — the duplicate-label warning is about the labels\n // that collided, this is about the channel that lost its name to them. The other way is a\n // channel labelled `time_s`, where what took the name is the time column itself, which every\n // signals.csv begins with and no file supplies.\n /*\n \"so its column is\" was a sentence about the wide layout, printed in both.\n\n A long signals.csv has three columns — time_s, channel, value — and none of them is a\n label: a channel appears there as a value in the `channel` column. So a `--layout long`\n run was told that a column had been renamed to avoid colliding with a column neither of\n them has, under a hint promising that \"column names are unique\" about a set of three\n fixed strings the file never got from the header.\n\n The rename is right in both layouts, which is why only the noun moves. The names have to\n agree between the `channel` cells and channels.csv and across runs, and the `pivot` the\n documentation gives for turning a long table back into a wide one would otherwise put a\n `time_s` column against a `time_s` index — the collision one step later.\n */\n const inLongLayout = (options.layout ?? 'wide') === 'long';\n for (const signal of renamedByCollision(input.signals, columnNames)) {\n const taker =\n signal.label === TIME_COLUMN\n ? 'the name of the time column every signals.csv starts with'\n : `also the column name another channel's \"_ch\" suffix produces`;\n diagnostics.push({\n code: 'DUPLICATE_LABEL',\n severity: 'warning',\n message:\n `Signal ${signal.index} is labelled \"${signal.label}\", which is ${taker}, ` +\n `so ${inLongLayout ? 'it is named' : 'its column is'} \"${columnNames.get(signal.index)}\"` +\n `${inLongLayout ? ' in the channel column' : ''}.`,\n hint: inLongLayout\n ? 'Channel names are unique; look this channel up in channels.csv by its signal_index.'\n : 'Column names are unique; look this channel up in channels.csv by its signal_index.',\n });\n }\n\n const range = resolveRange({\n start: options.start,\n startText: options.startText,\n duration: options.duration,\n end: options.end,\n endText: options.endText,\n recordDuration: input.recordDuration,\n recordCount: input.recordCount,\n recordStarts: input.recordStarts,\n });\n\n const writeSignals = options.annotationsOnly !== true;\n\n let chosen: EdfSignal[] = input.signals.filter((s) => !s.isAnnotations);\n\n /*\n Channel names are checked even under --annotations-only, where the selection is not\n otherwise used.\n\n Skipping the check meant `--channels TYPO --annotations-only` exited 0 in silence while\n the same typo without the flag was a usage error, and `--channels \"\"` stayed an error\n in both — so a mistyped name was the one form of bad input the tool accepted quietly.\n Everywhere else a term matching nothing is reported rather than ignored; a flag that\n happens not to apply is a poor reason to make an exception.\n */\n if (options.channels && options.channels.length > 0) {\n const selection = selectChannels(input.signals, options.channels);\n if (writeSignals) chosen = selection.signals;\n for (const { term, matched } of selection.ambiguous) {\n diagnostics.push({\n code: 'DUPLICATE_LABEL',\n severity: 'warning',\n message:\n `\"${term}\" matches ${matched.length} channels (positions ` +\n `${listed(matched.map((s) => `#${s.index}`))}); all of them were selected.`,\n hint: `Use --channels \"#${matched[0]?.index ?? 0}\" to pick just one.`,\n });\n }\n }\n\n const layout = options.layout ?? 'wide';\n const groups = writeSignals\n ? groupByRate(chosen, columnNames, options.decimals, options.gzip === true, layout)\n : [];\n const estimate = estimateOutput(\n groups,\n range,\n input.recordDuration,\n input.recordStarts,\n options.bom === true,\n layout,\n );\n\n /*\n The mixed-rate warning describes what this conversion does, not what the file holds.\n\n The header parser raises its own, which is right for `parseHeader` — but it sees every\n channel and knows nothing about `--channels`. Converting one channel out of a three-rate\n recording therefore announced \"3 different sampling rates ... written to one file per\n rate\" over a run that wrote one file, in the same output where `--info` had already\n marked the other two \"(not selected)\". Selecting two of the three was wrong the other\n way: still \"3\".\n\n Callers combining these with a file's own diagnostics drop that copy in favour of this\n one; see `withoutFileRateWarning`.\n */\n if (groups.length > 1) {\n diagnostics.push({\n code: 'MIXED_SAMPLING_RATES',\n severity: 'warning',\n message:\n `Channels use ${groups.length} different sampling rates ` +\n `(${listed(formatRates(groups.map((g) => g.rate)).map((r) => `${r} Hz`))}).`,\n hint:\n layout === 'long'\n ? 'They share one table, each row carrying its own time, so no channel is resampled.'\n : 'They are written to one file per rate so no channel is resampled.',\n });\n }\n\n /*\n A time column that cannot tell two samples apart.\n\n Sample times are written to at most fifteen decimal places, which separates everything a\n terminating rate can reach — every power of two through 32768 Hz and far past it. Below\n that interval the column repeats: at 3e15 Hz, whose reciprocal never terminates, the rows\n of one record carry the same time_s, so joining or plotting on it silently collapses\n samples that are genuinely distinct. Nothing is lost from the file — every sample is\n there, in order — but the column stops being an identifier, and that is worth saying\n rather than leaving to be discovered.\n\n This said nine places and a gigahertz, and illustrated it with 1 ns records — all three of\n which stopped being true when 0.4.55 raised the search bound. Fifteen places resolve\n 1e-15 s, so a nanosecond interval is written exactly and that recording raises nothing at\n all. warnings-and-errors.md was corrected then and carries a note saying so; the comment\n it was written from was not, and the hint printed four lines below here has said \"the\n fifteen places a double can hold exactly\" ever since.\n */\n for (const group of groups) {\n const step = group.rate > 0 ? 1 / group.rate : 0;\n /*\n The limit of the same failure, which read as the absence of it.\n\n `samplesPerRecord / recordDuration` is a double, and a record duration of 1e-308 with\n four samples in it is Infinity. `1 / Infinity` is 0, so `step > 0` was false and this\n said nothing — while every sample was dropped, the run exited 0, and the only warning\n printed was EMPTY_WINDOW's \"This recording's 2 data records carry no samples in range\",\n which is untrue twice over: the records carry eight samples and no range was asked for.\n\n One power of ten away, at 1e-300, the rate is 4e300 and the file converts with the\n warning below. Same guard `decimalsAreClamped` had before 0.5.83, in the column next\n door: a step of exactly zero means no resolution at all, not nothing to report.\n\n Its own branch because the hint below is false here — no rows are written at all, so\n \"Every sample is written, in order\" would be the third untrue sentence.\n */\n if (!Number.isFinite(group.rate)) {\n diagnostics.push({\n code: 'TIME_RESOLUTION',\n severity: 'warning',\n message:\n `Channels in ${group.fileName} work out to a sampling rate of ${formatRate(group.rate)} Hz ` +\n `— their samples per record over a record duration too small to divide into — so ` +\n `their samples cannot be placed in time and no rows are written for them.`,\n hint:\n 'Check the record duration in the header. One power of ten larger and the same ' +\n 'file converts, with consecutive rows carrying the same time_s.',\n });\n } else if (step > 0 && step < 10 ** -group.timeDecimals) {\n diagnostics.push({\n code: 'TIME_RESOLUTION',\n severity: 'warning',\n message:\n `Channels at ${formatRate(group.rate)} Hz sample faster than the time column can ` +\n `distinguish, so consecutive rows in ${group.fileName} carry the same time_s value.`,\n /*\n \"or convert one rate at a time with --channels\" was advice that does nothing.\n\n It parses, it runs, it exits 0, and the warning comes back word for word — so\n somebody who followed it had every reason to think the column had been fixed. In\n the wide layout each rate already has its own file and its own precision, and\n `timeDecimals` is a function of the rate alone, so a narrowed conversion writes the\n same column it wrote before. In the long layout the shared column takes the finest\n precision *in the conversion*, and dropping rates can only make it coarser — never\n fine enough to separate samples that were already inseparable.\n\n Nor is there another option that would: --decimals sets the value precision and\n says so, and every rate that reaches this warning has already been given the\n fifteen places that are the ceiling. The first sentence was the whole of the\n answer, so it is the whole of the hint.\n */\n hint:\n 'Every sample is written, in order. Use the row number rather than time_s to tell ' +\n 'them apart: the column already carries the fifteen places a double can hold ' +\n 'exactly, so no option or selection separates them.',\n });\n }\n }\n\n /*\n The same failure as TIME_RESOLUTION, one column over.\n\n A channel whose quantization step is below 1e-98 needs more decimals than `toFixed` can\n print, so consecutive digital codes round to the same text and the arithmetic the FAQ\n gives for recovering them stops working. That used to happen at 1e-20 and silently — see\n MAX_DERIVED_DECIMALS. It is rare now, but \"rare\" is the reason to say so rather than the\n reason not to.\n\n Asked of the ceiling, not of the precision in use, and so asked whatever `--decimals`\n says. `--decimals 2` on a channel needing 3 is a trade the caller made knowingly, and\n reporting it was reporting the flag back at the person who typed it — every channel of an\n ordinary EEG raised this, and since --strict turns any diagnostic into exit 1,\n `--decimals 2 --strict` could not succeed on any recording at all.\n\n 0.5.10 fixed that by skipping the check whenever `--decimals` was given, which suppressed\n the real case along with the false one: at `--decimals 20` a channel stepping by 1e-106\n printed every code it had as `0.00000000000000000000`, and said nothing. The question is\n not who chose the precision. It is whether any precision the tool can print would\n separate consecutive codes.\n */\n for (const group of groups) {\n const short = group.channels.filter((c) => decimalsAreClamped(c.signal));\n if (short.length === 0) continue;\n diagnostics.push({\n code: 'VALUE_RESOLUTION',\n severity: 'warning',\n message:\n `${listed(short.map((c) => c.column))} ${short.length === 1 ? 'steps' : 'step'} by less ` +\n `than any number of decimals this can print, so some consecutive samples round to ` +\n `the same value in ${group.fileName}.`,\n hint:\n 'Every sample is written, in order, and the physical values are computed at full ' +\n 'precision either way. What is lost is only in the printed text.',\n });\n }\n\n /*\n A window that selects nothing is a fact about the plan, so the plan is where it is raised.\n\n It was pushed by `convert()` from the rows actually written, which meant `--info` never\n said it: `--info --start 0.31 --end 0.39` on a 10 Hz recording printed \"Would write 0\n rows\" with no warning and exited 0 under `--strict`, while converting the same window\n warned and exited 1. The hint says \"Run with --info to see where the records actually\n sit\" — advising the reader into the one mode that would not tell them.\n\n The estimate's row count is exact, which `npm run estimate` checks across every fixture\n crossed with every option set, so raising it from the plan says the same thing the rows\n would have.\n */\n /*\n Not when a rate above already explained it. EMPTY_WINDOW says the records \"carry no\n samples in range\", and on a recording whose rate overflowed to Infinity that is untrue\n twice: the records carry their samples, and no range was asked for. The rate warning is\n the accurate account of the same zero.\n */\n const untimeable = groups.some((group) => !Number.isFinite(group.rate));\n if (writeSignals && groups.length > 0 && estimate.rows === 0 && !untimeable) {\n diagnostics.push(emptyWindow(range, input.recordCount));\n }\n\n if (estimate.exceedsSpreadsheetLimit) {\n diagnostics.push({\n code: 'LARGE_OUTPUT',\n severity: 'warning',\n message:\n `At least one output file will have more than ${SPREADSHEET_ROW_LIMIT.toLocaleString('en-US')} ` +\n `rows, which is more than Excel or Numbers can open.`,\n hint: 'Use --start and --duration to convert a section, or read the file with pandas or R.',\n });\n }\n\n return { groups, layout, gzip: options.gzip === true, range, columnNames, writeSignals, diagnostics, estimate };\n}\n\n/**\n * Partition channels by sampling rate, largest first.\n *\n * The common case — every channel at one rate — collapses to a single group and a\n * single `signals.csv`, so the honest behaviour costs nothing when there is nothing\n * to be honest about.\n */\nfunction groupByRate(\n signals: readonly EdfSignal[],\n columnNames: Map<number, string>,\n forcedDecimals: number | undefined,\n gzip: boolean,\n layout: 'wide' | 'long',\n): RateGroup[] {\n const byRate = new Map<number, EdfSignal[]>();\n for (const signal of signals) {\n // A channel with no samples has no sampling rate to group by, and would\n // otherwise produce an empty \"0hz\" file. The header parser already warned.\n if (signal.samplesPerRecord === 0) continue;\n const bucket = byRate.get(signal.samplingRate);\n if (bucket) bucket.push(signal);\n else byRate.set(signal.samplingRate, [signal]);\n }\n\n const rates = [...byRate.keys()].sort((a, b) => b - a);\n // The long layout writes one table whatever the rates are, so every group names it.\n const single = rates.length === 1 || layout === 'long';\n\n /*\n Two distinct rates can produce the same slug, because the slug rounds to six decimal\n places. Rates come from samplesPerRecord / recordDuration and every channel shares the\n record duration, so the closest two rates can be is 1 / recordDuration — which drops\n below 1e-6 once a record is longer than about eleven days. Absurd, but the header\n permits it, and the failure was silent and destructive: both groups opened a write\n stream on the same path, so the file ended up holding interleaved rows from both\n channels under a header naming only one of them.\n\n Distinct rates therefore get distinct files, always. The suffix is only ever reached by\n a collision, so ordinary recordings keep the names they have always had.\n\n Naming from the whole set of rates at once removes most of those collisions before the\n suffix has to. Rounding each rate on its own gave 1e-6 Hz and 1.25e-6 Hz the same slug,\n and the numbering below then produced signals_0_000001hz.csv and signals_0_000001hz_2.csv\n — two files that no longer overwrite each other, but of which only one is named for the\n rate it holds. The suffix stays as the backstop for anything this still cannot separate.\n */\n const suffix = gzip ? '.csv.gz' : '.csv';\n const slugs = formatRates(rates).map((text) => `${text.replace('.', '_')}hz`);\n const used = new Set<string>();\n const uniqueName = (index: number): string => {\n const base = `signals_${slugs[index]}`;\n let name = `${base}${suffix}`;\n for (let n = 2; used.has(name); n++) name = `${base}_${n}${suffix}`;\n used.add(name);\n return name;\n };\n\n return rates.map((rate, index) => {\n const members = byRate.get(rate) ?? [];\n const first = members[0];\n return {\n rate,\n samplesPerRecord: first ? first.samplesPerRecord : 0,\n fileName: single ? `signals${suffix}` : uniqueName(index),\n /*\n In the long layout every rate shares a `time_s` column, so they share its precision:\n the finest any of them needs. Writing 100 Hz at three places and 256 Hz at eight in\n the same column would make the column's meaning depend on the row.\n */\n timeDecimals: layout === 'long' ? Math.max(...rates.map(timeDecimals)) : timeDecimals(rate),\n channels: members.map((signal) => ({\n signal,\n column: columnNames.get(signal.index) ?? `signal_${signal.index}`,\n decimals: forcedDecimals ?? decimalsForSignal(signal),\n })),\n };\n });\n}\n\n/**\n * A file's diagnostics with the header's mixed-rate warning removed.\n *\n * `buildPlan` raises that warning for the channels actually being converted, so keeping both\n * would either duplicate it or contradict it. The header parser's copy stays where it is, for\n * callers reading a header without planning a conversion.\n */\nexport function withoutFileRateWarning(diagnostics: readonly Diagnostic[]): Diagnostic[] {\n return diagnostics.filter((d) => d.code !== 'MIXED_SAMPLING_RATES');\n}\n\n/** `256hz`, `12_5hz` — safe in a filename on every platform. */\nexport function rateSlug(rate: number): string {\n return `${formatRate(rate).replace('.', '_')}hz`;\n}\n\n/**\n * Characters the time column occupies, measured over both ends of the window and signed.\n *\n * Both estimates measured the column against `range.endSeconds` alone, unsigned — while the\n * value column two lines below already allowed for a sign when either bound is negative. A\n * recording timed from before zero prints `-100.000` where that budgeted for `100.000`, so\n * every row came out a byte short: 203 predicted against 216 written, and 131 against 159 on\n * a shorter one. An estimate reading low is the one direction the correctness page says it\n * never goes — \"no byte count under the truth\" is what the estimate sweep asserts over every\n * fixture, and no fixture began before zero.\n *\n * The far end is not always the widest: from -100s to -97s it is the start.\n */\nfunction timeWidthFor(range: ResolvedRange, decimals: number): number {\n return widthOf(\n Math.max(Math.abs(range.startSeconds), Math.abs(range.endSeconds)),\n decimals,\n range.startSeconds < 0 || range.endSeconds < 0,\n );\n}\n\n/**\n * How wide a value cell can print, from the channel's own calibration.\n *\n * Zero when that calibration holds no mapping — a digital range of one point, a physical span\n * that overflows or underflows a double — because such a channel writes an empty cell for\n * every sample rather than a number, which is the whole point of `makeScaler` returning NaN.\n *\n * Budgeting a full-width number for one broke the bound this estimate states beside never\n * reading low. A single-channel recording whose digital minimum equals its maximum, at\n * `--decimals 20`, was predicted at 651 bytes and wrote 151 — 4.31x, against a documented wall\n * of three. No fixture reaches it because `degenerate-range.edf` has two ordinary channels\n * beside its flat one, whose real cells pad the total back under the wall.\n *\n * Asked of `makeScaler` rather than restated here, for the reason `csvRow` measures the header\n * row: the function that decides whether a cell gets a number is the one that can still be\n * right when the rule changes. Both ends of the declared range are probed, so a calibration\n * that is finite anywhere keeps its full width and the estimate cannot start reading low.\n */\nfunction valueWidthOf(channel: PlannedChannel): number {\n const scale = makeScaler(channel.signal);\n const blank =\n !Number.isFinite(scale(channel.signal.digitalMin)) &&\n !Number.isFinite(scale(channel.signal.digitalMax));\n if (blank) return 0;\n return widthOf(\n Math.max(Math.abs(channel.signal.physicalMin), Math.abs(channel.signal.physicalMax)),\n channel.decimals,\n channel.signal.physicalMin < 0 || channel.signal.physicalMax < 0,\n );\n}\n\n/** Characters a fixed-decimal number of this magnitude occupies, sign included. */\nfunction widthOf(magnitude: number, decimals: number, signed = false): number {\n const size = Math.abs(magnitude);\n const sign = signed ? 1 : 0;\n const fraction = decimals > 0 ? 1 + decimals : 0;\n\n /*\n Cells are written with toFixed, which rounds. Taking the integer digits from the floor of\n the bound therefore under-counted whenever rounding carried into a new digit: a channel\n bounded at 9999.999 and written to zero decimals produces \"10000\", five characters where\n the floor of 9999.999 suggests four. Every cell on such a channel was a byte short, and\n `--info` reported 127 KB for a file that came out 131 KB.\n\n Measuring the bound as rendered removes that. toFixed switches to exponential notation\n past 1e21, so the arithmetic form still covers magnitudes beyond it.\n */\n if (!Number.isFinite(size)) return sign + 1 + fraction;\n if (size < 1e21) return sign + size.toFixed(Math.min(decimals, 100)).length;\n return sign + (Math.floor(Math.log10(size)) + 1) + fraction;\n}\n\n/**\n * Raised when the conversion had signal tables to fill and put no data rows in any of them.\n *\n * A window can land where there are no samples without being past the end of the recording:\n * between the last sample and the nominal end of the last record, or — on a discontinuous\n * file — inside a gap. `--start 2 --end 10` on a recording whose records sit at 0s, 1s and\n * 10s asks for eight seconds that contain no data at all.\n *\n * What came out was a signals.csv holding its header and nothing else, exit 0, no warning,\n * and `--strict` passing. The closing summary does say \"signals.csv 0 rows\" and --json\n * carries `rows: 0`, so it was not quite invisible — but a header-only file is exactly what\n * a successful extraction of an empty range looks like, and everywhere else that a request\n * produces nothing this tool says so: a --channels term matching nothing is an error, and\n * --annotations-only on a file with no events raises NO_ANNOTATIONS. A warning rather than\n * an error because a batch of five hundred recordings should not stop for one whose gap\n * happens to line up with the window; --strict turns it into a failure for those who want\n * that.\n */\nfunction emptyWindow(range: ResolvedRange, recordCount: number): Diagnostic {\n const asked = !range.isWholeRecording;\n return {\n code: 'EMPTY_WINDOW',\n severity: 'warning',\n message: asked\n ? `No samples fall inside the requested window (${fixed(range.startSeconds, 3)}s to ` +\n `${fixed(range.endSeconds, 3)}s), so the signal files hold their headers and no data.`\n : `This recording's ${recordCount} data records carry no samples in range, so the ` +\n `signal files hold their headers and no data.`,\n hint: asked\n ? /*\n Which of the two it was, rather than the second one always.\n\n A recording does not have to start at zero: its first record's timekeeping TAL is\n what it is timed from, so a file whose records begin at 1000s is asked for with\n `--start 1000`. `--start 0 --end 1` on that file was told \"The window is inside the\n recording but lands where there is no data — past the last sample, or inside a gap\n in a discontinuous file\", when the window sits entirely before the recording and\n neither offered explanation applies to it. A start at or past the *end* is already\n an error, so the window being outside can only mean it is before the beginning.\n */\n range.endSeconds <= range.recordingStartSeconds\n ? `This recording starts at ${fixed(range.recordingStartSeconds, 3)}s, so the whole ` +\n 'window sits before it. --start and --end are read on the recording\\'s own clock, ' +\n 'which --info prints as \"Timed from\".'\n : 'The window is inside the recording but lands where there is no data — past the ' +\n 'last sample, or inside a gap in a discontinuous file. Run with --info to see where ' +\n 'the records actually sit.'\n : 'Run with --info to see what the header declares.',\n };\n}\n\nfunction estimateOutput(\n groups: readonly RateGroup[],\n range: ResolvedRange,\n recordDuration: number,\n recordStarts: Float64Array | null | undefined,\n bom: boolean,\n layout: 'wide' | 'long',\n): OutputEstimate {\n let rows = 0;\n let bytes = 0;\n let exceeds = false;\n // One table in the long layout, so the row limit applies to the sum rather than the\n // largest group, and the header and mark are counted once rather than once per group.\n let longRows = 0;\n\n for (const group of groups) {\n let groupRows = 0;\n for (let record = range.startRecord; record < range.endRecord; record++) {\n const recordStart = recordStarts\n ? (recordStarts[record] ?? record * recordDuration)\n : record * recordDuration;\n groupRows += countSamplesInRange({\n recordStart,\n rate: group.rate,\n samplesPerRecord: group.samplesPerRecord,\n startSeconds: range.startSeconds,\n endSeconds: range.endSeconds,\n });\n }\n if (layout === 'long') {\n // A row per sample per channel rather than a row per sample time.\n const groupCells = groupRows * group.channels.length;\n rows += groupCells;\n longRows += groupCells;\n /*\n `time_s,channel,value`: the time, the channel name as it will be escaped into the\n cell, and the widest the value can print. Same over-counting rule as the wide\n layout — the declared physical range bounds a cell, and most samples sit under it.\n */\n const timeWidth = timeWidthFor(range, group.timeDecimals);\n for (const channel of group.channels) {\n const valueWidth = valueWidthOf(channel);\n const nameWidth = Buffer.byteLength(escapeCsvField(channel.column));\n // Two commas and the newline.\n bytes += groupRows * (timeWidth + nameWidth + valueWidth + 3);\n }\n continue;\n }\n\n rows += groupRows;\n if (groupRows + 1 > SPREADSHEET_ROW_LIMIT) exceeds = true;\n\n /*\n Width per cell, from the channel's own calibration rather than a flat allowance.\n\n The old `decimals + 6` budgeted six characters for the sign, integer part and decimal\n point on every channel, whatever it actually held. That over-counted a millivolt\n channel spanning ±5 by four characters a cell and ran 30-55% high across the fixture\n set — on a number people use to decide whether a conversion is worth starting.\n\n The channel's declared physical range is what bounds a cell, so that bound is what is\n used. Most samples sit below it, so this still reads high, which is the direction a\n size estimate should err in.\n\n One case is outside the bound rather than under it: nothing obliges a recording to keep\n its samples inside the digital range it declares, and one that does not maps outside the\n physical range too. Such a file can convert larger than the estimate. Clamping the data\n to make the estimate true is not a trade worth making — the samples are what they are.\n */\n const timeWidth = timeWidthFor(range, group.timeDecimals);\n const cellWidth = group.channels.reduce((sum, c) => sum + valueWidthOf(c), 0);\n // One comma per channel, plus the newline.\n bytes += groupRows * (timeWidth + cellWidth + group.channels.length + 1);\n /*\n The header row, measured as it will be written rather than as the labels are stored.\n\n A column name is quoted when it contains a comma, a quote, a carriage return or a line\n feed, and every quote inside it is doubled. Counting the raw label under-counted\n that row: three channels labelled `a,b,c,d,e`, `x\"y` and `plain` write a 32-byte header\n and were budgeted 27. EDF labels are free text, so commas in them are ordinary — a montage\n written as `EEG Fpz-Cz, ref` is exactly the kind of thing this is for.\n\n csvRow is the function that writes it, so it is the function that measures it. Nothing\n else is in a position to stay correct when the quoting rules change.\n */\n bytes += Buffer.byteLength(csvRow(['time_s', ...group.channels.map((c) => c.column)])) + 1;\n // Three bytes per file under --bom. Small, but the estimate promises never to read\n // under what gets written, and a one-row conversion is small enough for it to matter.\n if (bom) bytes += BOM_BYTES;\n }\n\n if (layout === 'long' && groups.length > 0) {\n if (longRows + 1 > SPREADSHEET_ROW_LIMIT) exceeds = true;\n bytes += Buffer.byteLength(csvRow(['time_s', 'channel', 'value'])) + 1;\n if (bom) bytes += BOM_BYTES;\n }\n\n return { rows, bytes, exceedsSpreadsheetLimit: exceeds };\n}\n"]}
1
+ {"version":3,"file":"plan.js","sourceRoot":"","sources":["../../src/convert/plan.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Turning a request into a concrete conversion plan.\n *\n * The plan is where the tool's central promise is enforced: channels recorded at\n * different sampling rates are never merged into one table. A single wide CSV can\n * only hold mixed rates by inventing samples for the slow channels — MNE, for\n * instance, expands three genuine 1 Hz temperature readings into 768 interpolated\n * values without warning. Instead each distinct rate gets its own file, so every\n * number in every output file is a number that was actually recorded.\n */\n\nimport type { Diagnostic } from '../edf/errors.js';\nimport type { EdfSignal } from '../edf/header.js';\nimport { formatRate, formatRates } from '../edf/header.js';\nimport { decimalsAreClamped, decimalsForSignal, makeScaler } from '../edf/scale.js';\nimport { UTF8_BOM, csvRow, escapeCsvField } from '../format/csv.js';\nimport { listed } from '../format/list.js';\nimport { fixed, timeDecimals } from '../format/number.js';\nimport { TIME_COLUMN, buildColumnNames, renamedByCollision, selectChannels } from './channels.js';\nimport { assertOptions } from './options.js';\nimport { countSamplesInRange, resolveRange } from './time-range.js';\nimport type { ResolvedRange } from './time-range.js';\n\nexport interface PlannedChannel {\n signal: EdfSignal;\n column: string;\n decimals: number;\n}\n\nexport interface RateGroup {\n /** Sampling rate in Hz shared by every channel in this group. */\n rate: number;\n samplesPerRecord: number;\n fileName: string;\n timeDecimals: number;\n channels: PlannedChannel[];\n}\n\nexport interface PlanInput {\n signals: readonly EdfSignal[];\n recordDuration: number;\n recordCount: number;\n hasAnnotationChannel: boolean;\n /**\n * True start time of each data record, supplied for discontinuous files. The\n * requested time window is resolved against these rather than against\n * `recordCount * recordDuration`, which for a file with gaps is the amount of\n * data rather than the span of time it covers.\n */\n recordStarts?: Float64Array | null | undefined;\n}\n\nexport interface PlanOptions {\n channels?: readonly string[] | undefined;\n start?: number | undefined;\n /** The `--start` value exactly as typed, for error messages. */\n startText?: string | undefined;\n duration?: number | undefined;\n end?: number | undefined;\n /** The `--end` value exactly as typed, for error messages. */\n endText?: string | undefined;\n annotationsOnly?: boolean | undefined;\n /** Force a fixed number of decimals instead of deriving it per channel. */\n decimals?: number | undefined;\n /** Compress each CSV with gzip, giving every one of them a `.gz` name. */\n gzip?: boolean | undefined;\n /** Start each CSV with a UTF-8 byte order mark, so Excel reads it as UTF-8. */\n bom?: boolean | undefined;\n /**\n * How the samples are arranged in the CSV.\n *\n * `'wide'`, the default, gives one column per channel and one file per sampling rate.\n * `'long'` gives one file, three columns — `time_s`, `channel`, `value` — and one row per\n * sample. See ConversionPlan.layout for why that is the only way to put channels recorded\n * at different rates in one table without inventing samples.\n */\n layout?: 'wide' | 'long' | undefined;\n}\n\nexport interface ConversionPlan {\n groups: RateGroup[];\n /**\n * How the samples are arranged. `'wide'` is a column per channel and a file per rate;\n * `'long'` is `time_s,channel,value`, one row per sample, all rates in one file.\n *\n * The wide layout has to split a mixed-rate recording across files: a 100 Hz channel and\n * a 1 Hz channel share no rows, and putting them in one wide table means either 99 empty\n * cells out of every hundred or inventing the samples that would fill them. In the long\n * layout each sample carries its own time, so nothing has to line up and nothing is\n * invented — which also makes it the one layout `--stdout` can stream for such a file.\n */\n layout: 'wide' | 'long';\n\n /**\n * Whether the CSVs will be compressed.\n *\n * Recorded rather than inferred from the group file names. Under `--annotations-only`\n * there are no groups to read it off, and `--info` named `annotations.csv` for a run that\n * wrote `annotations.csv.gz`.\n */\n gzip: boolean;\n range: ResolvedRange;\n columnNames: Map<number, string>;\n writeSignals: boolean;\n diagnostics: Diagnostic[];\n estimate: OutputEstimate;\n}\n\nexport interface OutputEstimate {\n /** Total data rows across every signal file. */\n rows: number;\n /** Approximate size of the signal CSVs on disk. */\n bytes: number;\n /** True when any single file would exceed Excel's row limit. */\n exceedsSpreadsheetLimit: boolean;\n}\n\nconst BOM_BYTES = Buffer.byteLength(UTF8_BOM);\n\n/** Excel and most spreadsheet tools stop at 1,048,576 rows including the header. */\nexport const SPREADSHEET_ROW_LIMIT = 1_048_576;\n\nexport function buildPlan(input: PlanInput, options: PlanOptions = {}): ConversionPlan {\n // First, and before a directory is created or a stream opened, so a rejected option\n // leaves nothing behind. See assertOptions for what used to get through.\n assertOptions(options);\n\n const diagnostics: Diagnostic[] = [];\n const columnNames = buildColumnNames(input.signals);\n\n // A channel whose own label was taken by something else in the header. Usually that is\n // another channel's disambiguating suffix — the duplicate-label warning is about the labels\n // that collided, this is about the channel that lost its name to them. The other way is a\n // channel labelled `time_s`, where what took the name is the time column itself, which every\n // signals.csv begins with and no file supplies.\n /*\n \"so its column is\" was a sentence about the wide layout, printed in both.\n\n A long signals.csv has three columns — time_s, channel, value — and none of them is a\n label: a channel appears there as a value in the `channel` column. So a `--layout long`\n run was told that a column had been renamed to avoid colliding with a column neither of\n them has, under a hint promising that \"column names are unique\" about a set of three\n fixed strings the file never got from the header.\n\n The rename is right in both layouts, which is why only the noun moves. The names have to\n agree between the `channel` cells and channels.csv and across runs, and the `pivot` the\n documentation gives for turning a long table back into a wide one would otherwise put a\n `time_s` column against a `time_s` index — the collision one step later.\n */\n const inLongLayout = (options.layout ?? 'wide') === 'long';\n for (const signal of renamedByCollision(input.signals, columnNames)) {\n const taker =\n signal.label === TIME_COLUMN\n ? 'the name of the time column every signals.csv starts with'\n : `also the column name another channel's \"_ch\" suffix produces`;\n diagnostics.push({\n code: 'DUPLICATE_LABEL',\n severity: 'warning',\n message:\n `Signal ${signal.index} is labelled \"${signal.label}\", which is ${taker}, ` +\n `so ${inLongLayout ? 'it is named' : 'its column is'} \"${columnNames.get(signal.index)}\"` +\n `${inLongLayout ? ' in the channel column' : ''}.`,\n hint: inLongLayout\n ? 'Channel names are unique; look this channel up in channels.csv by its signal_index.'\n : 'Column names are unique; look this channel up in channels.csv by its signal_index.',\n });\n }\n\n const range = resolveRange({\n start: options.start,\n startText: options.startText,\n duration: options.duration,\n end: options.end,\n endText: options.endText,\n recordDuration: input.recordDuration,\n recordCount: input.recordCount,\n recordStarts: input.recordStarts,\n });\n\n const writeSignals = options.annotationsOnly !== true;\n\n let chosen: EdfSignal[] = input.signals.filter((s) => !s.isAnnotations);\n\n /*\n Channel names are checked even under --annotations-only, where the selection is not\n otherwise used.\n\n Skipping the check meant `--channels TYPO --annotations-only` exited 0 in silence while\n the same typo without the flag was a usage error, and `--channels \"\"` stayed an error\n in both — so a mistyped name was the one form of bad input the tool accepted quietly.\n Everywhere else a term matching nothing is reported rather than ignored; a flag that\n happens not to apply is a poor reason to make an exception.\n */\n if (options.channels && options.channels.length > 0) {\n const selection = selectChannels(input.signals, options.channels);\n if (writeSignals) chosen = selection.signals;\n for (const { term, matched } of selection.ambiguous) {\n diagnostics.push({\n code: 'DUPLICATE_LABEL',\n severity: 'warning',\n message:\n `\"${term}\" matches ${matched.length} channels (positions ` +\n `${listed(matched.map((s) => `#${s.index}`))}); all of them were selected.`,\n hint: `Use --channels \"#${matched[0]?.index ?? 0}\" to pick just one.`,\n });\n }\n }\n\n const layout = options.layout ?? 'wide';\n const groups = writeSignals\n ? groupByRate(chosen, columnNames, options.decimals, options.gzip === true, layout)\n : [];\n const estimate = estimateOutput(\n groups,\n range,\n input.recordDuration,\n input.recordStarts,\n options.bom === true,\n layout,\n );\n\n /*\n The mixed-rate warning describes what this conversion does, not what the file holds.\n\n The header parser raises its own, which is right for `parseHeader` — but it sees every\n channel and knows nothing about `--channels`. Converting one channel out of a three-rate\n recording therefore announced \"3 different sampling rates ... written to one file per\n rate\" over a run that wrote one file, in the same output where `--info` had already\n marked the other two \"(not selected)\". Selecting two of the three was wrong the other\n way: still \"3\".\n\n Callers combining these with a file's own diagnostics drop that copy in favour of this\n one; see `withoutFileRateWarning`.\n */\n if (groups.length > 1) {\n diagnostics.push({\n code: 'MIXED_SAMPLING_RATES',\n severity: 'warning',\n message:\n `Channels use ${groups.length} different sampling rates ` +\n `(${listed(formatRates(groups.map((g) => g.rate)).map((r) => `${r} Hz`))}).`,\n hint:\n layout === 'long'\n ? 'They share one table, each row carrying its own time, so no channel is resampled.'\n : 'They are written to one file per rate so no channel is resampled.',\n });\n }\n\n /*\n A time column that cannot tell two samples apart.\n\n Sample times are written to at most fifteen decimal places, which separates everything a\n terminating rate can reach — every power of two through 32768 Hz and far past it. Below\n that interval the column repeats: at 3e15 Hz, whose reciprocal never terminates, the rows\n of one record carry the same time_s, so joining or plotting on it silently collapses\n samples that are genuinely distinct. Nothing is lost from the file — every sample is\n there, in order — but the column stops being an identifier, and that is worth saying\n rather than leaving to be discovered.\n\n This said nine places and a gigahertz, and illustrated it with 1 ns records — all three of\n which stopped being true when 0.4.55 raised the search bound. Fifteen places resolve\n 1e-15 s, so a nanosecond interval is written exactly and that recording raises nothing at\n all. warnings-and-errors.md was corrected then and carries a note saying so; the comment\n it was written from was not, and the hint printed four lines below here has said \"the\n fifteen places a double can hold exactly\" ever since.\n */\n for (const group of groups) {\n const step = group.rate > 0 ? 1 / group.rate : 0;\n /*\n The limit of the same failure, which read as the absence of it.\n\n `samplesPerRecord / recordDuration` is a double, and a record duration of 1e-308 with\n four samples in it is Infinity. `1 / Infinity` is 0, so `step > 0` was false and this\n said nothing — while every sample was dropped, the run exited 0, and the only warning\n printed was EMPTY_WINDOW's \"This recording's 2 data records carry no samples in range\",\n which is untrue twice over: the records carry eight samples and no range was asked for.\n\n One power of ten away, at 1e-300, the rate is 4e300 and the file converts with the\n warning below. Same guard `decimalsAreClamped` had before 0.5.83, in the column next\n door: a step of exactly zero means no resolution at all, not nothing to report.\n\n Its own branch because the hint below is false here — no rows are written at all, so\n \"Every sample is written, in order\" would be the third untrue sentence.\n */\n if (!Number.isFinite(group.rate)) {\n diagnostics.push({\n code: 'TIME_RESOLUTION',\n severity: 'warning',\n message:\n `Channels in ${group.fileName} work out to a sampling rate of ${formatRate(group.rate)} Hz ` +\n `— their samples per record over a record duration too small to divide into — so ` +\n `their samples cannot be placed in time and no rows are written for them.`,\n hint:\n 'Check the record duration in the header. One power of ten larger and the same ' +\n 'file converts, with consecutive rows carrying the same time_s.',\n });\n } else if (step > 0 && step < 10 ** -group.timeDecimals) {\n diagnostics.push({\n code: 'TIME_RESOLUTION',\n severity: 'warning',\n message:\n `Channels at ${formatRate(group.rate)} Hz sample faster than the time column can ` +\n `distinguish, so consecutive rows in ${group.fileName} carry the same time_s value.`,\n /*\n \"or convert one rate at a time with --channels\" was advice that does nothing.\n\n It parses, it runs, it exits 0, and the warning comes back word for word — so\n somebody who followed it had every reason to think the column had been fixed. In\n the wide layout each rate already has its own file and its own precision, and\n `timeDecimals` is a function of the rate alone, so a narrowed conversion writes the\n same column it wrote before. In the long layout the shared column takes the finest\n precision *in the conversion*, and dropping rates can only make it coarser — never\n fine enough to separate samples that were already inseparable.\n\n Nor is there another option that would: --decimals sets the value precision and\n says so, and every rate that reaches this warning has already been given the\n fifteen places that are the ceiling. The first sentence was the whole of the\n answer, so it is the whole of the hint.\n */\n hint:\n 'Every sample is written, in order. Use the row number rather than time_s to tell ' +\n 'them apart: the column already carries the fifteen places a double can hold ' +\n 'exactly, so no option or selection separates them.',\n });\n }\n }\n\n /*\n The same failure as TIME_RESOLUTION, one column over.\n\n A channel whose quantization step is below 1e-98 needs more decimals than `toFixed` can\n print, so consecutive digital codes round to the same text and the arithmetic the FAQ\n gives for recovering them stops working. That used to happen at 1e-20 and silently — see\n MAX_DERIVED_DECIMALS. It is rare now, but \"rare\" is the reason to say so rather than the\n reason not to.\n\n Asked of the ceiling, not of the precision in use, and so asked whatever `--decimals`\n says. `--decimals 2` on a channel needing 3 is a trade the caller made knowingly, and\n reporting it was reporting the flag back at the person who typed it — every channel of an\n ordinary EEG raised this, and since --strict turns any diagnostic into exit 1,\n `--decimals 2 --strict` could not succeed on any recording at all.\n\n 0.5.10 fixed that by skipping the check whenever `--decimals` was given, which suppressed\n the real case along with the false one: at `--decimals 20` a channel stepping by 1e-106\n printed every code it had as `0.00000000000000000000`, and said nothing. The question is\n not who chose the precision. It is whether any precision the tool can print would\n separate consecutive codes.\n */\n for (const group of groups) {\n const short = group.channels.filter((c) => decimalsAreClamped(c.signal));\n if (short.length === 0) continue;\n diagnostics.push({\n code: 'VALUE_RESOLUTION',\n severity: 'warning',\n message:\n `${listed(short.map((c) => c.column))} ${short.length === 1 ? 'steps' : 'step'} by less ` +\n `than any number of decimals this can print, so some consecutive samples round to ` +\n `the same value in ${group.fileName}.`,\n hint:\n 'Every sample is written, in order, and the physical values are computed at full ' +\n 'precision either way. What is lost is only in the printed text.',\n });\n }\n\n /*\n A window that selects nothing is a fact about the plan, so the plan is where it is raised.\n\n It was pushed by `convert()` from the rows actually written, which meant `--info` never\n said it: `--info --start 0.31 --end 0.39` on a 10 Hz recording printed \"Would write 0\n rows\" with no warning and exited 0 under `--strict`, while converting the same window\n warned and exited 1. The hint says \"Run with --info to see where the records actually\n sit\" — advising the reader into the one mode that would not tell them.\n\n The estimate's row count is exact, which `npm run estimate` checks across every fixture\n crossed with every option set, so raising it from the plan says the same thing the rows\n would have.\n */\n /*\n Not when a rate above already explained it. EMPTY_WINDOW says the records \"carry no\n samples in range\", and on a recording whose rate overflowed to Infinity that is untrue\n twice: the records carry their samples, and no range was asked for. The rate warning is\n the accurate account of the same zero.\n */\n const untimeable = groups.some((group) => !Number.isFinite(group.rate));\n if (writeSignals && groups.length > 0 && estimate.rows === 0 && !untimeable) {\n diagnostics.push(emptyWindow(range, input.recordCount));\n }\n\n if (estimate.exceedsSpreadsheetLimit) {\n diagnostics.push({\n code: 'LARGE_OUTPUT',\n severity: 'warning',\n message:\n `At least one output file will have more than ${SPREADSHEET_ROW_LIMIT.toLocaleString('en-US')} ` +\n `rows, which is more than Excel or Numbers can open.`,\n hint: 'Use --start and --duration to convert a section, or read the file with pandas or R.',\n });\n }\n\n return { groups, layout, gzip: options.gzip === true, range, columnNames, writeSignals, diagnostics, estimate };\n}\n\n/**\n * Partition channels by sampling rate, largest first.\n *\n * The common case — every channel at one rate — collapses to a single group and a\n * single `signals.csv`, so the honest behaviour costs nothing when there is nothing\n * to be honest about.\n */\nfunction groupByRate(\n signals: readonly EdfSignal[],\n columnNames: Map<number, string>,\n forcedDecimals: number | undefined,\n gzip: boolean,\n layout: 'wide' | 'long',\n): RateGroup[] {\n const byRate = new Map<number, EdfSignal[]>();\n for (const signal of signals) {\n // A channel with no samples has no sampling rate to group by, and would\n // otherwise produce an empty \"0hz\" file. The header parser already warned.\n if (signal.samplesPerRecord === 0) continue;\n const bucket = byRate.get(signal.samplingRate);\n if (bucket) bucket.push(signal);\n else byRate.set(signal.samplingRate, [signal]);\n }\n\n const rates = [...byRate.keys()].sort((a, b) => b - a);\n // The long layout writes one table whatever the rates are, so every group names it.\n const single = rates.length === 1 || layout === 'long';\n\n /*\n Two distinct rates can produce the same slug, because the slug rounds to six decimal\n places. Rates come from samplesPerRecord / recordDuration and every channel shares the\n record duration, so the closest two rates can be is 1 / recordDuration — which drops\n below 1e-6 once a record is longer than about eleven days. Absurd, but the header\n permits it, and the failure was silent and destructive: both groups opened a write\n stream on the same path, so the file ended up holding interleaved rows from both\n channels under a header naming only one of them.\n\n Distinct rates therefore get distinct files, always. The suffix is only ever reached by\n a collision, so ordinary recordings keep the names they have always had.\n\n Naming from the whole set of rates at once removes most of those collisions before the\n suffix has to. Rounding each rate on its own gave 1e-6 Hz and 1.25e-6 Hz the same slug,\n and the numbering below then produced signals_0_000001hz.csv and signals_0_000001hz_2.csv\n — two files that no longer overwrite each other, but of which only one is named for the\n rate it holds. The suffix stays as the backstop for anything this still cannot separate.\n */\n const suffix = gzip ? '.csv.gz' : '.csv';\n const slugs = formatRates(rates).map((text) => `${text.replace('.', '_')}hz`);\n const used = new Set<string>();\n const uniqueName = (index: number): string => {\n const base = `signals_${slugs[index]}`;\n let name = `${base}${suffix}`;\n for (let n = 2; used.has(name); n++) name = `${base}_${n}${suffix}`;\n used.add(name);\n return name;\n };\n\n return rates.map((rate, index) => {\n const members = byRate.get(rate) ?? [];\n const first = members[0];\n return {\n rate,\n samplesPerRecord: first ? first.samplesPerRecord : 0,\n fileName: single ? `signals${suffix}` : uniqueName(index),\n /*\n In the long layout every rate shares a `time_s` column, so they share its precision:\n the finest any of them needs. Writing 100 Hz at three places and 256 Hz at eight in\n the same column would make the column's meaning depend on the row.\n */\n timeDecimals: layout === 'long' ? Math.max(...rates.map(timeDecimals)) : timeDecimals(rate),\n channels: members.map((signal) => ({\n signal,\n column: columnNames.get(signal.index) ?? `signal_${signal.index}`,\n decimals: forcedDecimals ?? decimalsForSignal(signal),\n })),\n };\n });\n}\n\n/**\n * A file's diagnostics with the header's mixed-rate warning removed.\n *\n * `buildPlan` raises that warning for the channels actually being converted, so keeping both\n * would either duplicate it or contradict it. The header parser's copy stays where it is, for\n * callers reading a header without planning a conversion.\n */\nexport function withoutFileRateWarning(diagnostics: readonly Diagnostic[]): Diagnostic[] {\n return diagnostics.filter((d) => d.code !== 'MIXED_SAMPLING_RATES');\n}\n\n/** `256hz`, `12_5hz` — safe in a filename on every platform. */\nexport function rateSlug(rate: number): string {\n return `${formatRate(rate).replace('.', '_')}hz`;\n}\n\n/**\n * Characters the time column occupies, measured over both ends of the window and signed.\n *\n * Both estimates measured the column against `range.endSeconds` alone, unsigned — while the\n * value column two lines below already allowed for a sign when either bound is negative. A\n * recording timed from before zero prints `-100.000` where that budgeted for `100.000`, so\n * every row came out a byte short: 203 predicted against 216 written, and 131 against 159 on\n * a shorter one. An estimate reading low is the one direction the correctness page says it\n * never goes — \"no byte count under the truth\" is what the estimate sweep asserts over every\n * fixture, and no fixture began before zero.\n *\n * The far end is not always the widest: from -100s to -97s it is the start.\n */\nfunction timeWidthFor(range: ResolvedRange, decimals: number): number {\n return widthOf(\n Math.max(Math.abs(range.startSeconds), Math.abs(range.endSeconds)),\n decimals,\n range.startSeconds < 0 || range.endSeconds < 0,\n );\n}\n\n/**\n * How wide a value cell can print, from the channel's own calibration.\n *\n * Zero when that calibration holds no mapping — a digital range of one point, a physical span\n * that overflows or underflows a double — because such a channel writes an empty cell for\n * every sample rather than a number, which is the whole point of `makeScaler` returning NaN.\n *\n * Budgeting a full-width number for one broke the bound this estimate states beside never\n * reading low. A single-channel recording whose digital minimum equals its maximum, at\n * `--decimals 20`, was predicted at 651 bytes and wrote 151 — 4.31x, against a documented wall\n * of three. No fixture reaches it because `degenerate-range.edf` has two ordinary channels\n * beside its flat one, whose real cells pad the total back under the wall.\n *\n * Asked of `makeScaler` rather than restated here, for the reason `csvRow` measures the header\n * row: the function that decides whether a cell gets a number is the one that can still be\n * right when the rule changes. Both ends of the declared range are probed, so a calibration\n * that is finite anywhere keeps its full width and the estimate cannot start reading low.\n */\nfunction valueWidthOf(channel: PlannedChannel): number {\n const scale = makeScaler(channel.signal);\n const blank =\n !Number.isFinite(scale(channel.signal.digitalMin)) &&\n !Number.isFinite(scale(channel.signal.digitalMax));\n if (blank) return 0;\n return widthOf(\n Math.max(Math.abs(channel.signal.physicalMin), Math.abs(channel.signal.physicalMax)),\n channel.decimals,\n channel.signal.physicalMin < 0 || channel.signal.physicalMax < 0,\n );\n}\n\n/** Integer digits in `Number.MAX_VALUE`, which is the widest a finite double prints. */\nconst MAX_DOUBLE_DIGITS = 309;\n\n/** Characters a fixed-decimal number of this magnitude occupies, sign included. */\nfunction widthOf(magnitude: number, decimals: number, signed = false): number {\n const size = Math.abs(magnitude);\n const sign = signed ? 1 : 0;\n const fraction = decimals > 0 ? 1 + decimals : 0;\n\n /*\n Cells are written with toFixed, which rounds. Taking the integer digits from the floor of\n the bound therefore under-counted whenever rounding carried into a new digit: a channel\n bounded at 9999.999 and written to zero decimals produces \"10000\", five characters where\n the floor of 9999.999 suggests four. Every cell on such a channel was a byte short, and\n `--info` reported 127 KB for a file that came out 131 KB.\n\n Measuring the bound as rendered removes that. toFixed switches to exponential notation\n past 1e21, so the arithmetic form still covers magnitudes beyond it.\n */\n /*\n A bound that is not a number bounds nothing, so the widest cell it can produce is taken\n instead: 309 digits, which is `Number.MAX_VALUE` written out.\n\n One digit was budgeted, and the estimate read low — the one direction the correctness page\n says it never goes. `latest` is `recordCount * recordDuration`, so a header stating a\n record duration near the top of a double overflows it while every sample time under it\n stays finite and prints in full. Three records of 1e308, eight samples:\n\n Would write 8 rows, roughly 115 B. signals.csv is 2,244 bytes.\n\n Each of those rows carries a 313-character time cell. `fixed` writes an empty cell for a\n value that is itself non-finite, so nothing wider than this is ever printed.\n */\n if (!Number.isFinite(size)) return sign + MAX_DOUBLE_DIGITS + fraction;\n if (size < 1e21) return sign + size.toFixed(Math.min(decimals, 100)).length;\n return sign + (Math.floor(Math.log10(size)) + 1) + fraction;\n}\n\n/**\n * Raised when the conversion had signal tables to fill and put no data rows in any of them.\n *\n * A window can land where there are no samples without being past the end of the recording:\n * between the last sample and the nominal end of the last record, or — on a discontinuous\n * file — inside a gap. `--start 2 --end 10` on a recording whose records sit at 0s, 1s and\n * 10s asks for eight seconds that contain no data at all.\n *\n * What came out was a signals.csv holding its header and nothing else, exit 0, no warning,\n * and `--strict` passing. The closing summary does say \"signals.csv 0 rows\" and --json\n * carries `rows: 0`, so it was not quite invisible — but a header-only file is exactly what\n * a successful extraction of an empty range looks like, and everywhere else that a request\n * produces nothing this tool says so: a --channels term matching nothing is an error, and\n * --annotations-only on a file with no events raises NO_ANNOTATIONS. A warning rather than\n * an error because a batch of five hundred recordings should not stop for one whose gap\n * happens to line up with the window; --strict turns it into a failure for those who want\n * that.\n */\nfunction emptyWindow(range: ResolvedRange, recordCount: number): Diagnostic {\n const asked = !range.isWholeRecording;\n return {\n code: 'EMPTY_WINDOW',\n severity: 'warning',\n message: asked\n ? `No samples fall inside the requested window (${fixed(range.startSeconds, 3)}s to ` +\n `${fixed(range.endSeconds, 3)}s), so the signal files hold their headers and no data.`\n : `This recording's ${recordCount} data records carry no samples in range, so the ` +\n `signal files hold their headers and no data.`,\n hint: asked\n ? /*\n Which of the two it was, rather than the second one always.\n\n A recording does not have to start at zero: its first record's timekeeping TAL is\n what it is timed from, so a file whose records begin at 1000s is asked for with\n `--start 1000`. `--start 0 --end 1` on that file was told \"The window is inside the\n recording but lands where there is no data — past the last sample, or inside a gap\n in a discontinuous file\", when the window sits entirely before the recording and\n neither offered explanation applies to it. A start at or past the *end* is already\n an error, so the window being outside can only mean it is before the beginning.\n */\n range.endSeconds <= range.recordingStartSeconds\n ? `This recording starts at ${fixed(range.recordingStartSeconds, 3)}s, so the whole ` +\n 'window sits before it. --start and --end are read on the recording\\'s own clock, ' +\n 'which --info prints as \"Timed from\".'\n : 'The window is inside the recording but lands where there is no data — past the ' +\n 'last sample, or inside a gap in a discontinuous file. Run with --info to see where ' +\n 'the records actually sit.'\n : 'Run with --info to see what the header declares.',\n };\n}\n\nfunction estimateOutput(\n groups: readonly RateGroup[],\n range: ResolvedRange,\n recordDuration: number,\n recordStarts: Float64Array | null | undefined,\n bom: boolean,\n layout: 'wide' | 'long',\n): OutputEstimate {\n let rows = 0;\n let bytes = 0;\n let exceeds = false;\n // One table in the long layout, so the row limit applies to the sum rather than the\n // largest group, and the header and mark are counted once rather than once per group.\n let longRows = 0;\n\n for (const group of groups) {\n let groupRows = 0;\n for (let record = range.startRecord; record < range.endRecord; record++) {\n const recordStart = recordStarts\n ? (recordStarts[record] ?? record * recordDuration)\n : record * recordDuration;\n groupRows += countSamplesInRange({\n recordStart,\n rate: group.rate,\n samplesPerRecord: group.samplesPerRecord,\n startSeconds: range.startSeconds,\n endSeconds: range.endSeconds,\n });\n }\n if (layout === 'long') {\n // A row per sample per channel rather than a row per sample time.\n const groupCells = groupRows * group.channels.length;\n rows += groupCells;\n longRows += groupCells;\n /*\n `time_s,channel,value`: the time, the channel name as it will be escaped into the\n cell, and the widest the value can print. Same over-counting rule as the wide\n layout — the declared physical range bounds a cell, and most samples sit under it.\n */\n const timeWidth = timeWidthFor(range, group.timeDecimals);\n for (const channel of group.channels) {\n const valueWidth = valueWidthOf(channel);\n const nameWidth = Buffer.byteLength(escapeCsvField(channel.column));\n // Two commas and the newline.\n bytes += groupRows * (timeWidth + nameWidth + valueWidth + 3);\n }\n continue;\n }\n\n rows += groupRows;\n if (groupRows + 1 > SPREADSHEET_ROW_LIMIT) exceeds = true;\n\n /*\n Width per cell, from the channel's own calibration rather than a flat allowance.\n\n The old `decimals + 6` budgeted six characters for the sign, integer part and decimal\n point on every channel, whatever it actually held. That over-counted a millivolt\n channel spanning ±5 by four characters a cell and ran 30-55% high across the fixture\n set — on a number people use to decide whether a conversion is worth starting.\n\n The channel's declared physical range is what bounds a cell, so that bound is what is\n used. Most samples sit below it, so this still reads high, which is the direction a\n size estimate should err in.\n\n One case is outside the bound rather than under it: nothing obliges a recording to keep\n its samples inside the digital range it declares, and one that does not maps outside the\n physical range too. Such a file can convert larger than the estimate. Clamping the data\n to make the estimate true is not a trade worth making — the samples are what they are.\n */\n const timeWidth = timeWidthFor(range, group.timeDecimals);\n const cellWidth = group.channels.reduce((sum, c) => sum + valueWidthOf(c), 0);\n // One comma per channel, plus the newline.\n bytes += groupRows * (timeWidth + cellWidth + group.channels.length + 1);\n /*\n The header row, measured as it will be written rather than as the labels are stored.\n\n A column name is quoted when it contains a comma, a quote, a carriage return or a line\n feed, and every quote inside it is doubled. Counting the raw label under-counted\n that row: three channels labelled `a,b,c,d,e`, `x\"y` and `plain` write a 32-byte header\n and were budgeted 27. EDF labels are free text, so commas in them are ordinary — a montage\n written as `EEG Fpz-Cz, ref` is exactly the kind of thing this is for.\n\n csvRow is the function that writes it, so it is the function that measures it. Nothing\n else is in a position to stay correct when the quoting rules change.\n */\n bytes += Buffer.byteLength(csvRow(['time_s', ...group.channels.map((c) => c.column)])) + 1;\n // Three bytes per file under --bom. Small, but the estimate promises never to read\n // under what gets written, and a one-row conversion is small enough for it to matter.\n if (bom) bytes += BOM_BYTES;\n }\n\n if (layout === 'long' && groups.length > 0) {\n if (longRows + 1 > SPREADSHEET_ROW_LIMIT) exceeds = true;\n bytes += Buffer.byteLength(csvRow(['time_s', 'channel', 'value'])) + 1;\n if (bom) bytes += BOM_BYTES;\n }\n\n return { rows, bytes, exceedsSpreadsheetLimit: exceeds };\n}\n"]}
@@ -7,6 +7,7 @@
7
7
  * a message that shows the forms that work.
8
8
  */
9
9
  import { fixed, formatDuration } from '../format/number.js';
10
+ import { assertOptions } from './options.js';
10
11
  export class TimeRangeError extends Error {
11
12
  constructor(message) {
12
13
  super(message);
@@ -41,6 +42,10 @@ export const UNIT_SECONDS = {
41
42
  };
42
43
  const CLOCK = /^(?:(\d+):)?(\d{1,2}):(\d{1,2}(?:\.\d+)?)$/u;
43
44
  const UNIT_TOKEN = /(\d+(?:\.\d+)?)\s*([a-z]+)/giu;
45
+ /** How the value reads in a refusal: numbers bare, everything else quoted so its type shows. */
46
+ function describeValue(value) {
47
+ return typeof value === 'number' ? String(value) : JSON.stringify(value) ?? String(value);
48
+ }
44
49
  /**
45
50
  * Parse a duration or offset into seconds.
46
51
  *
@@ -62,6 +67,21 @@ const UNIT_TOKEN = /(\d+(?:\.\d+)?)\s*([a-z]+)/giu;
62
67
  * A length below zero is still a different thing, and `--duration` still refuses one.
63
68
  */
64
69
  export function parseTimeSpec(input, optionName, allowNegative = false) {
70
+ /*
71
+ Text, because this is the function the API page points other people's users at: "Use
72
+ `parseTimeSpec` if you want to accept those forms from your own users."
73
+
74
+ A value arriving from a JSON config or a form field is a number as often as a string, and
75
+ `{ "start": 30 }` came back as `TypeError: input.trim is not a function` — a variable name
76
+ from inside this file, thrown past a caller who had done exactly what the page told them
77
+ to. Every other refusal here is a TimeRangeError quoting the value and the option. The
78
+ number is not coerced, for the reason `channels: 'ECG'` is not iterated: a shape that
79
+ happens to work for 30 also swallows NaN, and the CLI already writes `String(raw)`.
80
+ */
81
+ if (typeof input !== 'string') {
82
+ throw new TimeRangeError(`${optionName} must be given as text, not ${describeValue(input)}. ` +
83
+ `Seconds are written "30", and the other forms are "30s", "5m", "1h30m", "00:30:00".`);
84
+ }
65
85
  const trimmed = input.trim().toLowerCase();
66
86
  if (trimmed === '') {
67
87
  throw new TimeRangeError(`${optionName} is empty. Try a value like 30s, 5m, or 00:30:00.`);
@@ -260,6 +280,18 @@ function sameInstant(a, b) {
260
280
  * decides which samples inside those records are actually written.
261
281
  */
262
282
  export function resolveRange(options) {
283
+ /*
284
+ The same check `buildPlan` makes, made here too, because this is exported on its own.
285
+
286
+ `assertOptions` sits at the top of `buildPlan`, so `convert` was covered and the function
287
+ underneath it was not — and this one has its own signature block on the API page.
288
+ `resolveRange({ start: NaN, ... })` resolved: no error, no warning, and a range read back
289
+ as `startSeconds: null, startRecord: null`, which is the "takes the whole recording
290
+ without saying so" that comment names. `{ end: '2' }` was coerced by the arithmetic and
291
+ accepted; `{ start: '30' }` reached the past-the-end error and printed it with the value
292
+ missing — `--start s is at or past the end of this 3s recording`.
293
+ */
294
+ assertOptions({ start: options.start, duration: options.duration, end: options.end });
263
295
  // For a continuous file the recording spans recordCount * recordDuration. A
264
296
  // discontinuous one does not: a 10-second recording with a 95-second gap in the
265
297
  // middle still ends at 105 seconds. Deriving the span from the records' real