edf2csv 0.4.69 → 0.4.71

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.
@@ -10,12 +10,13 @@
10
10
  */
11
11
  import { formatRate, formatRates } from '../edf/header.js';
12
12
  import { decimalsForSignal } from '../edf/scale.js';
13
- import { csvRow } from '../format/csv.js';
13
+ import { UTF8_BOM, csvRow } from '../format/csv.js';
14
14
  import { listed } from '../format/list.js';
15
15
  import { timeDecimals } from '../format/number.js';
16
16
  import { buildColumnNames, renamedByCollision, selectChannels } from './channels.js';
17
17
  import { assertOptions } from './options.js';
18
18
  import { countSamplesInRange, resolveRange } from './time-range.js';
19
+ const BOM_BYTES = Buffer.byteLength(UTF8_BOM);
19
20
  /** Excel and most spreadsheet tools stop at 1,048,576 rows including the header. */
20
21
  export const SPREADSHEET_ROW_LIMIT = 1_048_576;
21
22
  export function buildPlan(input, options = {}) {
@@ -75,7 +76,7 @@ export function buildPlan(input, options = {}) {
75
76
  const groups = writeSignals
76
77
  ? groupByRate(chosen, columnNames, options.decimals, options.gzip === true)
77
78
  : [];
78
- const estimate = estimateOutput(groups, range, input.recordDuration, input.recordStarts);
79
+ const estimate = estimateOutput(groups, range, input.recordDuration, input.recordStarts, options.bom === true);
79
80
  /*
80
81
  The mixed-rate warning describes what this conversion does, not what the file holds.
81
82
 
@@ -234,7 +235,7 @@ function widthOf(magnitude, decimals, signed = false) {
234
235
  return sign + size.toFixed(Math.min(decimals, 100)).length;
235
236
  return sign + (Math.floor(Math.log10(size)) + 1) + fraction;
236
237
  }
237
- function estimateOutput(groups, range, recordDuration, recordStarts) {
238
+ function estimateOutput(groups, range, recordDuration, recordStarts, bom) {
238
239
  let rows = 0;
239
240
  let bytes = 0;
240
241
  let exceeds = false;
@@ -290,6 +291,10 @@ function estimateOutput(groups, range, recordDuration, recordStarts) {
290
291
  else is in a position to stay correct when the quoting rules change.
291
292
  */
292
293
  bytes += Buffer.byteLength(csvRow(['time_s', ...group.channels.map((c) => c.column)])) + 1;
294
+ // Three bytes per file under --bom. Small, but the estimate promises never to read
295
+ // under what gets written, and a one-row conversion is small enough for it to matter.
296
+ if (bom)
297
+ bytes += BOM_BYTES;
293
298
  }
294
299
  return { rows, bytes, exceedsSpreadsheetLimit: exceeds };
295
300
  }
@@ -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 { decimalsForSignal } from '../edf/scale.js';\nimport { csvRow } from '../format/csv.js';\nimport { listed } from '../format/list.js';\nimport { timeDecimals } from '../format/number.js';\nimport { 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}\n\nexport interface ConversionPlan {\n groups: RateGroup[];\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\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 another channel's disambiguating suffix. The\n // duplicate-label warning is about the labels that collided; this is about the channel\n // that lost its name to them, which is the one whose column no longer matches the file.\n for (const signal of renamedByCollision(input.signals, columnNames)) {\n diagnostics.push({\n code: 'DUPLICATE_LABEL',\n severity: 'warning',\n message:\n `Signal ${signal.index} is labelled \"${signal.label}\", which is also the column name ` +\n `another channel's \"_ch\" suffix produces, so its column is \"${columnNames.get(signal.index)}\".`,\n hint: '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 groups = writeSignals\n ? groupByRate(chosen, columnNames, options.decimals, options.gzip === true)\n : [];\n const estimate = estimateOutput(\n groups,\n range,\n input.recordDuration,\n input.recordStarts,\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: '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 nine decimal places, which separates everything up to\n a gigahertz. Below that the column repeats: a recording of 1 ns records holding ten\n samples each writes twenty rows carrying three distinct times, so joining or plotting on\n `time_s` silently collapses them. Nothing is lost from the file — every sample is there,\n in order — but the column stops being an identifier, and that is worth saying rather than\n leaving to be discovered.\n */\n for (const group of groups) {\n const step = group.rate > 0 ? 1 / group.rate : 0;\n 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 hint:\n 'Every sample is written, in order. Use the row number rather than time_s to tell ' +\n 'them apart, or convert one rate at a time with --channels.',\n });\n }\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, 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): 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 const single = rates.length === 1;\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 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/** 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\nfunction estimateOutput(\n groups: readonly RateGroup[],\n range: ResolvedRange,\n recordDuration: number,\n recordStarts: Float64Array | null | undefined,\n): OutputEstimate {\n let rows = 0;\n let bytes = 0;\n let exceeds = false;\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 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 = widthOf(range.endSeconds, group.timeDecimals);\n const cellWidth = group.channels.reduce(\n (sum, c) =>\n sum +\n widthOf(\n Math.max(Math.abs(c.signal.physicalMin), Math.abs(c.signal.physicalMax)),\n c.decimals,\n c.signal.physicalMin < 0 || c.signal.physicalMax < 0,\n ),\n 0,\n );\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 newline or a leading or\n trailing space, 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 }\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 { decimalsForSignal } from '../edf/scale.js';\nimport { UTF8_BOM, csvRow } from '../format/csv.js';\nimport { listed } from '../format/list.js';\nimport { timeDecimals } from '../format/number.js';\nimport { 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\nexport interface ConversionPlan {\n groups: RateGroup[];\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 another channel's disambiguating suffix. The\n // duplicate-label warning is about the labels that collided; this is about the channel\n // that lost its name to them, which is the one whose column no longer matches the file.\n for (const signal of renamedByCollision(input.signals, columnNames)) {\n diagnostics.push({\n code: 'DUPLICATE_LABEL',\n severity: 'warning',\n message:\n `Signal ${signal.index} is labelled \"${signal.label}\", which is also the column name ` +\n `another channel's \"_ch\" suffix produces, so its column is \"${columnNames.get(signal.index)}\".`,\n hint: '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 groups = writeSignals\n ? groupByRate(chosen, columnNames, options.decimals, options.gzip === true)\n : [];\n const estimate = estimateOutput(\n groups,\n range,\n input.recordDuration,\n input.recordStarts,\n options.bom === true,\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: '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 nine decimal places, which separates everything up to\n a gigahertz. Below that the column repeats: a recording of 1 ns records holding ten\n samples each writes twenty rows carrying three distinct times, so joining or plotting on\n `time_s` silently collapses them. Nothing is lost from the file — every sample is there,\n in order — but the column stops being an identifier, and that is worth saying rather than\n leaving to be discovered.\n */\n for (const group of groups) {\n const step = group.rate > 0 ? 1 / group.rate : 0;\n 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 hint:\n 'Every sample is written, in order. Use the row number rather than time_s to tell ' +\n 'them apart, or convert one rate at a time with --channels.',\n });\n }\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, 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): 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 const single = rates.length === 1;\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 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/** 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\nfunction estimateOutput(\n groups: readonly RateGroup[],\n range: ResolvedRange,\n recordDuration: number,\n recordStarts: Float64Array | null | undefined,\n bom: boolean,\n): OutputEstimate {\n let rows = 0;\n let bytes = 0;\n let exceeds = false;\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 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 = widthOf(range.endSeconds, group.timeDecimals);\n const cellWidth = group.channels.reduce(\n (sum, c) =>\n sum +\n widthOf(\n Math.max(Math.abs(c.signal.physicalMin), Math.abs(c.signal.physicalMax)),\n c.decimals,\n c.signal.physicalMin < 0 || c.signal.physicalMax < 0,\n ),\n 0,\n );\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 newline or a leading or\n trailing space, 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 return { rows, bytes, exceedsSpreadsheetLimit: exceeds };\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/convert/run.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAWH,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAE3C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAanD,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAa,MAAM,WAAW,CAAC;AAGxE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,eAAe,CAAC;AAExD,OAAO,EAAE,YAAY,EAAE,CAAC;AAExB,MAAM,MAAM,mBAAmB,GAC3B,eAAe,GACf,mBAAmB,GACnB,wBAAwB,GACxB,kBAAkB,GAClB,qBAAqB,GACrB,iBAAiB,GACjB,cAAc,CAAC;AAEnB;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,EAAE,WAAW,CAAC,mBAAmB,CAAoC,CAAC;AAEpG,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;gBACtB,IAAI,EAAE,mBAAmB,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;CAM9F;AAED,MAAM,WAAW,cAAe,SAAQ,WAAW;IACjD,sFAAsF;IACtF,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,8CAA8C;IAC9C,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,wFAAwF;IACxF,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B,UAAU,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,kBAAkB,KAAK,IAAI,CAAC,GAAG,SAAS,CAAC;CACnE;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,IAAI,EAAE,cAAc,CAAC;IACrB,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAYD,wBAAsB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,aAAa,CAAC,CA6KrG;AAsDD,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAG1D"}
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/convert/run.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAWH,OAAO,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAE3C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAanD,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAa,MAAM,WAAW,CAAC;AAGxE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,eAAe,CAAC;AAExD,OAAO,EAAE,YAAY,EAAE,CAAC;AAExB,MAAM,MAAM,mBAAmB,GAC3B,eAAe,GACf,mBAAmB,GACnB,wBAAwB,GACxB,kBAAkB,GAClB,qBAAqB,GACrB,iBAAiB,GACjB,cAAc,CAAC;AAEnB;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,EAAE,WAAW,CAAC,mBAAmB,CAAoC,CAAC;AAEpG,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;gBACtB,IAAI,EAAE,mBAAmB,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;CAM9F;AAED,MAAM,WAAW,cAAe,SAAQ,WAAW;IACjD,sFAAsF;IACtF,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,8CAA8C;IAC9C,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,wFAAwF;IACxF,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B,UAAU,CAAC,EAAE,CAAC,CAAC,QAAQ,EAAE,kBAAkB,KAAK,IAAI,CAAC,GAAG,SAAS,CAAC;CACnE;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,WAAW,EAAE,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,IAAI,EAAE,cAAc,CAAC;IACrB,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAYD,wBAAsB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,aAAa,CAAC,CAgLrG;AAsDD,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAG1D"}
@@ -15,7 +15,7 @@ import path from 'node:path';
15
15
  import { EdfFile } from '../edf/reader.js';
16
16
  import { describeFormat, formatRates, formatWallClock } from '../edf/header.js';
17
17
  import { EdfError } from '../edf/errors.js';
18
- import { BufferedLineWriter, csvRow } from '../format/csv.js';
18
+ import { BufferedLineWriter, UTF8_BOM, csvRow } from '../format/csv.js';
19
19
  import { listed } from '../format/list.js';
20
20
  import { fixed, makeSampleFormatter, makeTimeFormatter, newOffsetBudget, } from '../format/number.js';
21
21
  import { buildPlan, withoutFileRateWarning } from './plan.js';
@@ -149,11 +149,11 @@ export async function convert(inputPath, options = {}) {
149
149
  }
150
150
  let annotationsWritten = 0;
151
151
  if (file.annotationSignals.length > 0) {
152
- const result = await writeAnnotationsCsv(outputDir, annotationData.annotations, requestedAnnotationWindow(options, plan.range.recordingStartSeconds), options.gzip === true);
152
+ const result = await writeAnnotationsCsv(outputDir, annotationData.annotations, requestedAnnotationWindow(options, plan.range.recordingStartSeconds), options.gzip === true, options.bom === true);
153
153
  written.push(result);
154
154
  annotationsWritten = result.rows;
155
155
  }
156
- written.push(await writeChannelsCsv(outputDir, file, plan, options.gzip === true));
156
+ written.push(await writeChannelsCsv(outputDir, file, plan, options.gzip === true, options.bom === true));
157
157
  /*
158
158
  The file moved under the conversion. Said out loud, because nothing else shows it.
159
159
 
@@ -348,6 +348,8 @@ async function writeSignalFiles(file, plan, outputDir, recordStarts, options) {
348
348
  if (audit && stream !== target)
349
349
  stream.on('data', (chunk) => audit.count(chunk.length));
350
350
  const writer = new BufferedLineWriter(stream);
351
+ if (options.bom === true)
352
+ writer.push(UTF8_BOM);
351
353
  writer.pushLine(csvRow(['time_s', ...group.channels.map((c) => c.column)]));
352
354
  return {
353
355
  group,
@@ -551,7 +553,11 @@ function compressed(target, gzip) {
551
553
  * and, more importantly, no mention that the signal files had already been written. The
552
554
  * conversion stopped half-done and the message gave no sign of it.
553
555
  */
554
- async function writeOutputFile(outputDir, name, contents, gzip = false) {
556
+ async function writeOutputFile(outputDir, name, contents, gzip = false, bom = false) {
557
+ // metadata.json never gets one: JSON.parse rejects a leading U+FEFF, so a mark there
558
+ // would break every reader of the file to help a spreadsheet that will not open it.
559
+ if (bom)
560
+ contents = UTF8_BOM + contents;
555
561
  try {
556
562
  // The sidecars are built in memory before being written, so compressing them in memory
557
563
  // costs nothing extra. Only the signal tables are large enough to need a stream.
@@ -562,7 +568,7 @@ async function writeOutputFile(outputDir, name, contents, gzip = false) {
562
568
  throw new ConversionError('WRITE_FAILED', `Writing "${name}" to "${outputDir}" failed: ${detail}`, writeHint(cause));
563
569
  }
564
570
  }
565
- async function writeChannelsCsv(outputDir, file, plan, gzip) {
571
+ async function writeChannelsCsv(outputDir, file, plan, gzip, bom) {
566
572
  const includedColumns = new Set(plan.groups.flatMap((g) => g.channels.map((c) => c.signal.index)));
567
573
  const fileFor = new Map();
568
574
  for (const group of plan.groups) {
@@ -609,7 +615,7 @@ async function writeChannelsCsv(outputDir, file, plan, gzip) {
609
615
  ]));
610
616
  }
611
617
  const name = gzip ? 'channels.csv.gz' : 'channels.csv';
612
- await writeOutputFile(outputDir, name, lines.join('\n') + '\n', gzip);
618
+ await writeOutputFile(outputDir, name, lines.join('\n') + '\n', gzip, bom);
613
619
  return { name, rows: lines.length - 1 };
614
620
  }
615
621
  /*
@@ -641,7 +647,7 @@ function requestedAnnotationWindow(options, recordingStart) {
641
647
  : Infinity;
642
648
  return { from, to };
643
649
  }
644
- async function writeAnnotationsCsv(outputDir, annotations, window, gzip) {
650
+ async function writeAnnotationsCsv(outputDir, annotations, window, gzip, bom) {
645
651
  const inWindow = annotations
646
652
  .filter((a) => a.onset >= window.from && a.onset < window.to)
647
653
  .sort((a, b) => a.onset - b.onset || a.recordIndex - b.recordIndex);
@@ -655,7 +661,7 @@ async function writeAnnotationsCsv(outputDir, annotations, window, gzip) {
655
661
  ]));
656
662
  }
657
663
  const name = gzip ? 'annotations.csv.gz' : 'annotations.csv';
658
- await writeOutputFile(outputDir, name, lines.join('\n') + '\n', gzip);
664
+ await writeOutputFile(outputDir, name, lines.join('\n') + '\n', gzip, bom);
659
665
  return { name, rows: inWindow.length };
660
666
  }
661
667
  /**