edf2csv 0.5.121 → 0.5.123
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 +57 -0
- package/dist/convert/channels.js +18 -0
- package/dist/convert/channels.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,63 @@
|
|
|
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.123
|
|
7
|
+
|
|
8
|
+
### Fixed: the FAQ showed one recording's long layout under another recording's command
|
|
9
|
+
|
|
10
|
+
"Why did I get several signals files instead of one?" answers with `sleep-study.edf`: it lists
|
|
11
|
+
that recording's three rates, names all five of its channels, and then offers `--layout long`
|
|
12
|
+
as the one-file alternative:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
edf2csv sleep-study.edf --out ./converted --layout long
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
time_s,channel,value
|
|
20
|
+
0.00000000,EEG Fpz-Cz,0.061
|
|
21
|
+
0.00000000,ECG,0.00122
|
|
22
|
+
0.00000000,Temp rectal,37.00073
|
|
23
|
+
0.00390625,EEG Fpz-Cz,9.096
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Three channels, one of them an ECG that recording does not have, at the eight decimal places a
|
|
27
|
+
256 Hz channel needs and none of its channels ask for. It is `mixed-rates.edf` converted, and
|
|
28
|
+
captioned with somebody else's command — eight lines under the same answer's own list of what
|
|
29
|
+
the file holds.
|
|
30
|
+
|
|
31
|
+
What that command writes is five channels at the first instant and `time_s` at three places,
|
|
32
|
+
which is what the block shows now. cli-reference has had it right all along; the two pages now
|
|
33
|
+
agree because a test converts the recording and holds every `--layout long` block on the site
|
|
34
|
+
to what came out.
|
|
35
|
+
|
|
36
|
+
## 0.5.122
|
|
37
|
+
|
|
38
|
+
### Fixed: asking for the annotation channel was answered by denying it exists
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
$ edf2csv sleep-study.edf --channels "EDF Annotations"
|
|
42
|
+
error: No channel named "EDF Annotations".
|
|
43
|
+
Run with --info to list the channels in this file.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Both halves are wrong for the same reason. `EDF Annotations` is the label the specification
|
|
47
|
+
reserves, the file really carries it, and `--info` counts it two lines above on the "Channels"
|
|
48
|
+
line — but the table `--info` prints lists signal channels only, so a reader who follows the
|
|
49
|
+
advice arrives back at the same message with nothing new to try. And what they were after is
|
|
50
|
+
already on disk: any conversion of a file with this channel writes `annotations.csv` out of it.
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
error: "EDF Annotations" is this recording's annotation channel, not a signal: it holds event
|
|
54
|
+
text rather than samples, so it has no column to select.
|
|
55
|
+
Its events are already written to annotations.csv by any conversion of this file — pass
|
|
56
|
+
--annotations-only for those and no signal data.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`BDF Annotations` gets the same answer on a BDF+ file, and matching is case-insensitive like
|
|
60
|
+
every other term. A recording that genuinely has no annotation channel keeps the old message,
|
|
61
|
+
because for that file the old message is true.
|
|
62
|
+
|
|
6
63
|
## 0.5.121
|
|
7
64
|
|
|
8
65
|
### Fixed: the first conversion on the page printed a line the tool does not print
|
package/dist/convert/channels.js
CHANGED
|
@@ -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":"AAAA;;;;;;;;GAQG;AAGH,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAE3C;;;;;GAKG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,QAAQ,CAAC;AAEpC,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IAC9C,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IACtC,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAA6B;IAC5D,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,MAAM,CAAC,aAAa;YAAE,SAAS;QACnC,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC9B,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAChD,CAAC;IAED,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;IACxC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,MAAM,CAAC,aAAa;YAAE,SAAS;QACnC,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC9B,MAAM,UAAU,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC;QAC/C,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAC3E,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;MAyBE;IACF,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC;QACvC,MAAM,KAAK,GAAG,IAAI,GAAG,CAAiB,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAC1D,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,MAAM,EAAE;YAAE,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QAE/E,MAAM,QAAQ,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QAC7E,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;YAAE,MAAM;QACjC,KAAK,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,QAAQ;YAAE,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,IAAI,MAAM,KAAK,EAAE,CAAC,CAAC;IAC/E,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,QAAQ,CAAC,MAAiB;IACjC,OAAO,MAAM,CAAC,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,UAAU,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC;AACvE,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAChC,OAA6B,EAC7B,WAAwC;IAExC,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;QAC7B,IAAI,MAAM,CAAC,aAAa;YAAE,SAAS;QACnC,MAAM,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;QAC9B,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAChD,CAAC;IACD,OAAO,OAAO,CAAC,MAAM,CACnB,CAAC,MAAM,EAAE,EAAE,CACT,CAAC,MAAM,CAAC,aAAa;QACrB,CAAC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC;QACzC,WAAW,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,QAAQ,CAAC,MAAM,CAAC,CACrD,CAAC;AACJ,CAAC;AAQD;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,OAA6B,EAAE,KAAwB;IACpF,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC;IAC3D,MAAM,OAAO,GAAG,IAAI,GAAG,EAAuB,CAAC;IAC/C,KAAK,MAAM,MAAM,IAAI,UAAU,EAAE,CAAC;QAChC,MAAM,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;QACvC,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAChC,IAAI,MAAM;YAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;;YAC3B,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;IAClC,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,GAAG,EAAqB,CAAC;IAC5C,MAAM,SAAS,GAA6C,EAAE,CAAC;IAE/D,KAAK,MAAM,OAAO,IAAI,KAAK,EAAE,CAAC;QAC5B,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,IAAI,KAAK,EAAE;YAAE,SAAS;QAE1B,2EAA2E;QAC3E,+DAA+D;QAC/D,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;QAChD,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,CAAC,EAAE,CAAC;YAC/D;;;;;;;;;cASE;YACF,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YAC/B,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAC7B,MAAM,IAAI,qBAAqB,CAC7B,IAAI,IAAI,oEAAoE;oBAC1E,oCAAoC,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,GAAG,CACtF,CAAC;YACJ,CAAC;YACD,MAAM,KAAK,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;YAC/B,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,KAAK,CAAC,CAAC;YACzD,IAAI,CAAC,MAAM,EAAE,CAAC;gBACZ,MAAM,IAAI,qBAAqB,CAC7B,0BAA0B,IAAI,qCAAqC;oBACjE,GAAG,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,GAAG,CACrD,CAAC;YACJ,CAAC;YACD,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;YACjC,SAAS;QACX,CAAC;QAED,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;QAChD,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACrC;;;;;;;;;;;;;cAaE;YACF,MAAM,OAAO,GAAG,gBAAgB,CAAC,UAAU,CAAC,CAAC;YAC7C,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,CAC3B,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,KAAK,IAAI,CAAC,WAAW,EAAE,CACnF,CAAC;YACF,IAAI,KAAK,EAAE,CAAC;gBACV,oFAAoF;gBACpF,qFAAqF;gBACrF,iFAAiF;gBACjF,MAAM,IAAI,qBAAqB,CAC7B,KAAK,CAAC,KAAK,KAAK,EAAE;oBAChB,CAAC,CAAC,IAAI,IAAI,wEAAwE;wBAChF,8BAA8B;wBAC9B,SAAS,KAAK,CAAC,KAAK,gEAAgE;oBACtF,CAAC,CAAC,IAAI,IAAI,wEAAwE;wBAChF,8BAA8B,KAAK,CAAC,KAAK,MAAM;wBAC/C,SAAS,KAAK,CAAC,KAAK,kCAAkC,KAAK,CAAC,KAAK,cAAc;wBAC/E,6BAA6B,CAClC,CAAC;YACJ,CAAC;YACD;;;;;;;;;;cAUE;YACF,MAAM,aAAa,GAAG,OAAO,CAAC,IAAI,CAChC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,aAAa,IAAI,MAAM,CAAC,KAAK,CAAC,WAAW,EAAE,KAAK,IAAI,CAAC,WAAW,EAAE,CACtF,CAAC;YACF,IAAI,aAAa,EAAE,CAAC;gBAClB,MAAM,IAAI,qBAAqB,CAC7B,IAAI,IAAI,yEAAyE;oBAC/E,4DAA4D;oBAC5D,8EAA8E;oBAC9E,8DAA8D,CACjE,CAAC;YACJ,CAAC;YACD,MAAM,IAAI,qBAAqB,CAC7B,qBAAqB,IAAI,KAAK,OAAO,CAAC,IAAI,EAAE,UAAU,CAAC,IAAI;gBACzD,oDAAoD,CACvD,CAAC;QACJ,CAAC;QACD,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC;YAAE,SAAS,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC,GAAG,OAAO,CAAC,EAAE,CAAC,CAAC;QACxE,KAAK,MAAM,MAAM,IAAI,OAAO;YAAE,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;IACjE,CAAC;IAED,MAAM,QAAQ,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;IACxE,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,qBAAqB,CAAC,4BAA4B,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC;AAC1C,CAAC;AAED,SAAS,OAAO,CAAC,IAAY,EAAE,UAAgC;IAC7D,6EAA6E;IAC7E,iDAAiD;IACjD,MAAM,MAAM,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC5D,MAAM,MAAM,GAAG,MAAM;SAClB,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,KAAK,CAAC,WAAW,EAAE,CAAC,EAAE,CAAC,CAAC;SAC5F,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,KAAK,EAAE,IAAI,CAAC,CAAC,QAAQ,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC;SACvF,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,QAAQ,CAAC;SACvC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IACf,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IACnC,OAAO,iBAAiB,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC;AAC1E,CAAC;AAED,6CAA6C;AAC7C,SAAS,YAAY,CAAC,CAAS,EAAE,CAAS;IACxC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IACtB,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC,MAAM,CAAC;IACpC,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC,MAAM,CAAC;IAEpC,IAAI,QAAQ,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IACjE,IAAI,OAAO,GAAG,IAAI,KAAK,CAAS,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAEtD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACnC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;QACf,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACnC,MAAM,IAAI,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACjE,OAAO,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,GAAG,CACnB,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,EACzB,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,EACtB,CAAC,QAAQ,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,IAAI,CAC9B,CAAC;QACJ,CAAC;QACD,MAAM,IAAI,GAAG,QAAQ,CAAC;QACtB,QAAQ,GAAG,OAAO,CAAC;QACnB,OAAO,GAAG,IAAI,CAAC;IACjB,CAAC;IACD,OAAO,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;AACjC,CAAC","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.
|
|
3
|
+
"version": "0.5.123",
|
|
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",
|