edf2csv 0.5.120 → 0.5.122

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,55 @@
3
3
  Notable changes to edf2csv. Versions follow [semantic versioning](https://semver.org); while the
4
4
  major version is 0, a minor bump may contain breaking changes.
5
5
 
6
+ ## 0.5.122
7
+
8
+ ### Fixed: asking for the annotation channel was answered by denying it exists
9
+
10
+ ```
11
+ $ edf2csv sleep-study.edf --channels "EDF Annotations"
12
+ error: No channel named "EDF Annotations".
13
+ Run with --info to list the channels in this file.
14
+ ```
15
+
16
+ Both halves are wrong for the same reason. `EDF Annotations` is the label the specification
17
+ reserves, the file really carries it, and `--info` counts it two lines above on the "Channels"
18
+ line — but the table `--info` prints lists signal channels only, so a reader who follows the
19
+ advice arrives back at the same message with nothing new to try. And what they were after is
20
+ already on disk: any conversion of a file with this channel writes `annotations.csv` out of it.
21
+
22
+ ```
23
+ error: "EDF Annotations" is this recording's annotation channel, not a signal: it holds event
24
+ text rather than samples, so it has no column to select.
25
+ Its events are already written to annotations.csv by any conversion of this file — pass
26
+ --annotations-only for those and no signal data.
27
+ ```
28
+
29
+ `BDF Annotations` gets the same answer on a BDF+ file, and matching is case-insensitive like
30
+ every other term. A recording that genuinely has no annotation channel keeps the old message,
31
+ because for that file the old message is true.
32
+
33
+ ## 0.5.121
34
+
35
+ ### Fixed: the first conversion on the page printed a line the tool does not print
36
+
37
+ getting-started's first conversion — the first output anyone reading the documentation sees:
38
+
39
+ ```text
40
+ Wrote recording_csv
41
+ signals.csv 300 rows
42
+ annotations.csv 3 rows
43
+ channels.csv 1 rows
44
+ ```
45
+
46
+ The last line is `1 row`. The summary has agreed its count with its noun since 0.5.98, and one
47
+ channel is the ordinary case for the small EDF+ file that block describes — so the page showed
48
+ the one run where the rule applies and printed it as though it did not. The seizure-window
49
+ recipe did it twice more, for its `annotations.csv` and its `channels.csv`.
50
+
51
+ Held now by a test that reads every quoted summary line on every page and checks the noun
52
+ against the number, since the recordings these blocks describe are examples rather than
53
+ fixtures: they need not match a run, but they cannot disagree with the rule the writer applies.
54
+
6
55
  ## 0.5.120
7
56
 
8
57
  ### Fixed: a recording timed from before zero had no window you were allowed to ask for
@@ -190,6 +190,24 @@ export function selectChannels(signals, terms) {
190
190
  `Use "#${owner.index}" to select just this one, or "${owner.label}" for every ` +
191
191
  `channel sharing that label.`);
192
192
  }
193
+ /*
194
+ The annotation channel is a channel, and this said the file had none by that name.
195
+
196
+ `EDF Annotations` is a label the file really carries — the spec reserves it, --info
197
+ counts it on the "Channels" line, and it is the name anyone reading about EDF+ meets
198
+ first. Asking for it got "No channel named "EDF Annotations". Run with --info to list
199
+ the channels in this file", which is false about the file and points at a table that
200
+ does not list it either, so following the advice returns the reader to the same
201
+ message. What they were after is already being written: every conversion of a file
202
+ with this channel writes annotations.csv from it.
203
+ */
204
+ const asAnnotations = signals.find((signal) => signal.isAnnotations && signal.label.toLowerCase() === term.toLowerCase());
205
+ if (asAnnotations) {
206
+ throw new ChannelSelectionError(`"${term}" is this recording's annotation channel, not a signal: it holds event ` +
207
+ `text rather than samples, so it has no column to select.\n` +
208
+ `Its events are already written to annotations.csv by any conversion of this ` +
209
+ `file — pass --annotations-only for those and no signal data.`);
210
+ }
193
211
  throw new ChannelSelectionError(`No channel named "${term}".${suggest(term, candidates)}\n` +
194
212
  `Run with --info to list the channels in this file.`);
195
213
  }
@@ -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 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 `This file has signal channels at ${listed(candidates.map((s) => `#${s.index}`))}.`,\n );\n }\n const index = Number(position);\n const signal = candidates.find((s) => s.index === index);\n if (!signal) {\n throw new ChannelSelectionError(\n `No channel at position ${term}. This file has signal channels at ` +\n `${listed(candidates.map((s) => `#${s.index}`))}.`,\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 `Use \"#${owner.index}\" to select just this one, or \"${owner.label}\" for every ` +\n `channel sharing that label.`,\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 .slice(0, 3);\n if (scored.length === 0) return '';\n return ` Did you mean ${scored.map((c) => `\"${c.label}\"`).join(', ')}?`;\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';\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 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 `This file has signal channels at ${listed(candidates.map((s) => `#${s.index}`))}.`,\n );\n }\n const index = Number(position);\n const signal = candidates.find((s) => s.index === index);\n if (!signal) {\n throw new ChannelSelectionError(\n `No channel at position ${term}. This file has signal channels at ` +\n `${listed(candidates.map((s) => `#${s.index}`))}.`,\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 `Use \"#${owner.index}\" to select just this one, or \"${owner.label}\" for every ` +\n `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 .slice(0, 3);\n if (scored.length === 0) return '';\n return ` Did you mean ${scored.map((c) => `\"${c.label}\"`).join(', ')}?`;\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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edf2csv",
3
- "version": "0.5.120",
3
+ "version": "0.5.122",
4
4
  "description": "Convert EDF, EDF+ and BDF biosignal recordings (European Data Format) to CSV from the command line. Local, streaming, and never resamples or alters units.",
5
5
  "keywords": [
6
6
  "edf",