edf2csv 0.5.24 → 0.5.26

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,46 @@
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.26
7
+
8
+ ### Fixed: 0.5.20's memory test asserted a heap size only one machine has
9
+
10
+ It converted 200 output tables under a 48 MB cap, which the fixed code does on macOS with
11
+ Node 24 and does not on Linux with Node 22. So the release workflow failed and 0.5.25 did not
12
+ reach npm — a test turning a garbage-collector difference into a red build, which is worse
13
+ than no test.
14
+
15
+ How much heap a conversion needs is not portable, and the separating cap is not either: the
16
+ pre-fix code dies at 64 MB here and needs considerably more on the runner. The test runs both
17
+ counts at 96 MB now — 40 tables, the size 0.5.6 was measured at and which always fitted, and
18
+ 200, the size that did not — so what it asserts is the property, that the second costs no more
19
+ than the first. The exact figures are in the 0.5.20 entry, where a measurement belongs, rather
20
+ than in an assertion that is only true on one machine.
21
+
22
+ Verified the way it should have been: a fresh clone, `npm ci`, all 261 tests passing.
23
+
24
+ ## 0.5.25
25
+
26
+ ### Fixed: records that overlap in time went unreported, because the check looked for reversal
27
+
28
+ There are two ways a discontinuous recording can make `time_s` step backwards, and only one
29
+ was being looked for.
30
+
31
+ The obvious one is a record that starts before the record before it. The other is a record
32
+ that starts before the record before it *ends*. Starts of 0, 0.5 and 1.0 on one-second
33
+ records are strictly increasing, so nothing fired — and the rows still came out 0.25, 0.5,
34
+ 0.75, 0.5, because the first record's samples run to 0.75 while the second begins at 0.5. A
35
+ device re-sending a buffer produces exactly this.
36
+
37
+ Both are reported now, with the same advice, since the reader has no more to say about one
38
+ than the other: every sample is written, in file order, with the time the file gives it.
39
+ Contiguity is not overlap — a continuous recording has `starts[i] === starts[i-1] + duration`
40
+ exactly — so the comparison is made strict by a fraction of the finest interval the recording
41
+ can express, and no ordinary file is called overlapping.
42
+
43
+ `DISCONTINUOUS` now covers five conditions; the page said four, having said three until
44
+ 0.5.17.
45
+
6
46
  ## 0.5.24
7
47
 
8
48
  ### Fixed: the correctness page described a method it says two hundred lines later was abandoned
@@ -1 +1 @@
1
- {"version":3,"file":"timing.d.ts","sourceRoot":"","sources":["../../src/convert/timing.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAEhD,MAAM,WAAW,oBAAoB;IACnC,YAAY,EAAE,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,OAAO,EACb,cAAc,EAAE,oBAAoB,GACnC;IAAE,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAA;CAAE,CAiN5D"}
1
+ {"version":3,"file":"timing.d.ts","sourceRoot":"","sources":["../../src/convert/timing.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAEhD,MAAM,WAAW,oBAAoB;IACnC,YAAY,EAAE,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,OAAO,EACb,cAAc,EAAE,oBAAoB,GACnC;IAAE,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC;IAAC,WAAW,EAAE,UAAU,EAAE,CAAA;CAAE,CA+O5D"}
@@ -184,10 +184,31 @@ export function deriveRecordStarts(file, annotationData) {
184
184
  diagnostics.push(unusableOrigin(furthest, file));
185
185
  return { starts: null, diagnostics };
186
186
  }
187
+ /*
188
+ Two ways a record can put the time column out of order, and only one was being looked for.
189
+
190
+ A record starting before the one before it is the obvious case. The other is a record
191
+ starting before the one before it *ends*: starts of 0, 0.5 and 1.0 on one-second records
192
+ are strictly increasing, so nothing fired, and the rows still came out 0.25, 0.5, 0.75,
193
+ 0.5 — because record 0's samples run to 0.75 while record 1 begins at 0.5. Overlapping
194
+ acquisition is what a device does when it re-sends a buffer, and the reader has no more
195
+ to say about it than about the reversed case: every sample is written, in file order,
196
+ with the time the file gives it.
197
+
198
+ Contiguity is not overlap. A continuous recording has `starts[i] === starts[i-1] +
199
+ duration` exactly, so the comparison is made strict by a fraction of the finest interval
200
+ the recording can express — the same measure the origin check uses.
201
+ */
202
+ const slack = finestInterval(file) / 2;
187
203
  let outOfOrder = 0;
204
+ let overlapping = 0;
188
205
  for (let i = 1; i < starts.length; i++) {
189
- if (starts[i] < starts[i - 1])
206
+ const previous = starts[i - 1];
207
+ const current = starts[i];
208
+ if (current < previous)
190
209
  outOfOrder++;
210
+ else if (current + slack < previous + file.header.recordDuration)
211
+ overlapping++;
191
212
  }
192
213
  if (outOfOrder > 0) {
193
214
  diagnostics.push({
@@ -197,6 +218,15 @@ export function deriveRecordStarts(file, annotationData) {
197
218
  hint: 'Rows are written in file order, so the time column will not increase monotonically.',
198
219
  });
199
220
  }
221
+ if (overlapping > 0) {
222
+ diagnostics.push({
223
+ code: 'DISCONTINUOUS',
224
+ severity: 'warning',
225
+ message: `${overlapping} data record${overlapping === 1 ? '' : 's'} start before the record ` +
226
+ `before ${overlapping === 1 ? 'it' : 'them'} ends, so their samples overlap in time.`,
227
+ hint: 'Rows are written in file order, so the time column will not increase monotonically.',
228
+ });
229
+ }
200
230
  return { starts, diagnostics };
201
231
  }
202
232
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"timing.js","sourceRoot":"","sources":["../../src/convert/timing.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Diagnostic } from '../edf/errors.js';\nimport type { EdfFile } from '../edf/reader.js';\n\nexport interface AnnotationTimingData {\n recordStarts: (number | null)[];\n malformed: number;\n /** Unreadable TALs in first position, which carry timing rather than an event. */\n malformedTimekeeping?: number;\n}\n\n/**\n * Resolve the true start time of every data record.\n *\n * Continuous recordings need no table because their record positions are\n * arithmetic. EDF+D recordings carry their positions in the annotation channel;\n * missing or malformed timekeeping entries are reported before falling back.\n */\nexport function deriveRecordStarts(\n file: EdfFile,\n annotationData: AnnotationTimingData,\n): { starts: Float64Array | null; diagnostics: Diagnostic[] } {\n const diagnostics: Diagnostic[] = [];\n\n if (annotationData.malformed > 0) {\n diagnostics.push({\n code: 'ANNOTATION_DECODE_FAILED',\n severity: 'warning',\n message:\n `${annotationData.malformed} annotation entr${annotationData.malformed === 1 ? 'y was' : 'ies were'} ` +\n `unreadable and could not be exported.`,\n hint: 'The rest were exported normally. The file may have been written by a non-conforming tool.',\n });\n }\n\n /*\n A timekeeping TAL is not an event, and saying it \"could not be exported\" describes the\n wrong loss twice over.\n\n These were counted among the annotations, so a file with one unreadable timekeeping TAL\n and three good events announced \"1 annotation entry was unreadable and could not be\n exported\" — while exporting all three. Nothing was missing from annotations.csv; what\n went missing was a record's position in time, which the message never mentioned.\n */\n const lostTimekeeping = annotationData.malformedTimekeeping ?? 0;\n // The EDF+D branch below raises its own, which names the records and is more specific.\n // Saying both would report one problem twice.\n if (lostTimekeeping > 0 && file.header.continuity !== 'EDF+D') {\n const one = lostTimekeeping === 1;\n diagnostics.push({\n code: 'ANNOTATION_DECODE_FAILED',\n severity: 'warning',\n message:\n `${lostTimekeeping} data record${one ? '' : 's'} carr${one ? 'ies' : 'y'} a timekeeping ` +\n `annotation that could not be read, so ${one ? 'it does' : 'they do'} not say where in ` +\n `time ${one ? 'it sits' : 'they sit'}.`,\n hint:\n 'No event was lost — a timekeeping annotation states a record\\'s start time and is ' +\n 'never exported. Times are derived from the records that could be read.',\n });\n }\n\n /*\n A continuous recording's records are contiguous, but the first one need not sit at zero.\n\n EDF+ puts the header's start time and every annotation onset on one origin, and says the\n first data record's timekeeping TAL \"always starts with +0.X\", stating the fraction of a\n second by which that record follows it. Ignoring that fraction timed the samples from 0\n while the events kept their true onsets, so the two ended up on origins half a second\n apart — an event at +0.75 in a 4 Hz recording whose first TAL reads +0.5 landed on sample\n 3 instead of sample 1. The same file marked EDF+D, byte-identical but for the reserved\n field, placed it correctly, which is what gives the omission away.\n\n Records stay contiguous, which is what continuous means: only the origin moves. A first\n TAL of +0 needs no table at all, and that is nearly every file.\n */\n if (file.header.continuity !== 'EDF+D') {\n if (file.header.continuity !== 'EDF+C') return { starts: null, diagnostics };\n\n /*\n The origin comes from whichever record first states one, not from record 0 alone.\n\n Reading only `recordStarts[0]` meant a single unreadable timekeeping TAL threw the\n origin away and timed the whole file from zero — while records 1 and 2, saying plainly\n that they start at 1.5s and 2.5s, went unread. A recording whose records sit at 0.5s,\n 1.5s and 2.5s came out with every sample 0.5s earlier than the file states, against\n annotation onsets that kept their true values. That is precisely the mismatch 0.4.9\n fixed, arriving through the one hole left in it, and the byte-identical EDF+D twin\n timed it correctly, which is what gives it away.\n\n Continuity is what makes this recoverable: record i sits at `origin + i * duration`,\n so any readable record determines the origin for all of them.\n */\n const origin = originOf(annotationData.recordStarts, file.header.recordDuration);\n if (origin === null || origin === 0) return { starts: null, diagnostics };\n\n const contiguous = new Float64Array(file.recordCount);\n for (let i = 0; i < file.recordCount; i++) {\n contiguous[i] = origin + i * file.header.recordDuration;\n }\n\n /*\n A file marked continuous whose own records disagree about it.\n\n Nothing looked at records past the first, so an EDF+C file whose records are in fact\n spread out was timed as though they were contiguous and said nothing. The records are\n being read here anyway, so the contradiction costs nothing to notice — and it is the\n file, not the reader, that has to be wrong for this to fire.\n\n Compared against what the file can express, not for equality. 0.4.41 asked whether the\n two doubles were the same, which they are not: a recording of 0.1s records sitting at\n 0.1, 0.2, 0.3 ... is contiguous by construction, and 0.1 + 2 * 0.1 is\n 0.30000000000000004. Two of its eight records were reported as contradicting\n continuity, on an ordinary file — and under --strict that was a failed run. The\n smallest interval the recording distinguishes is one sample of its fastest channel;\n anything below half of that is arithmetic, not a gap. `canCarry` has already refused\n origins where the double spacing swamps that interval, so the representation error is\n under the tolerance by construction rather than by hope.\n */\n const tolerance = finestInterval(file) / 2;\n const contradicting = annotationData.recordStarts.filter(\n (declared, i) =>\n typeof declared === 'number' && Math.abs(declared - (contiguous[i] as number)) > tolerance,\n ).length;\n if (contradicting > 0) {\n diagnostics.push({\n code: 'DISCONTINUOUS',\n severity: 'warning',\n message:\n `This file is marked continuous (EDF+C), but ${contradicting} of its ` +\n `${file.recordCount} data records say they start somewhere other than where ` +\n `continuity puts them.`,\n hint:\n 'Times are written as if the records were contiguous, which is what EDF+C means. ' +\n 'If the recording really has gaps, the file should have been marked EDF+D.',\n });\n }\n const first = origin;\n const last = contiguous[file.recordCount - 1] ?? first;\n if (!canCarry(last, file)) {\n diagnostics.push(unusableOrigin(first, file));\n return { starts: null, diagnostics };\n }\n return { starts: contiguous, diagnostics };\n }\n\n if (file.annotationSignals.length === 0) {\n diagnostics.push({\n code: 'DISCONTINUOUS',\n severity: 'warning',\n message:\n 'This file is marked discontinuous but has no annotation channel, so where its ' +\n 'records sit in time is not recorded anywhere.',\n hint: 'Times are written as if the records were contiguous. Any gaps are lost.',\n });\n return { starts: null, diagnostics };\n }\n\n /*\n A record with no readable time is placed from the origin the other records establish,\n not from zero.\n\n `i * recordDuration` assumed the recording began at zero, which is the one thing the\n other records are in a position to contradict: a file starting at 0.5s put its\n unreadable record at 0.000 while its neighbours sat at 1.5s and 2.5s. The guess is still\n a guess — a discontinuous file may have a gap exactly there — and it is still reported\n below, but starting it from where the recording actually begins is strictly closer, and\n it makes an EDF+D file agree with its byte-identical EDF+C twin about record 0.\n */\n const base = originOf(annotationData.recordStarts, file.header.recordDuration) ?? 0;\n const starts = new Float64Array(file.recordCount);\n const missing: number[] = [];\n for (let i = 0; i < file.recordCount; i++) {\n const declared = annotationData.recordStarts[i];\n if (declared === null || declared === undefined) {\n missing.push(i);\n starts[i] = base + i * file.header.recordDuration;\n } else {\n starts[i] = declared;\n }\n }\n\n if (missing.length > 0) {\n const shown = missing.slice(0, 5).join(', ');\n diagnostics.push({\n code: 'ANNOTATION_DECODE_FAILED',\n severity: 'warning',\n message:\n `${missing.length} of ${file.recordCount} data records carry no readable timekeeping ` +\n `annotation (record${missing.length === 1 ? '' : 's'} ${shown}` +\n `${missing.length > 5 ? ', …' : ''}), so their true position in time is unknown.`,\n hint: 'Those records are timed as if they were contiguous; treat their timestamps as unreliable.',\n });\n }\n\n /*\n Furthest from zero, in either direction.\n\n This took the signed maximum and seeded it with 0, so a recording whose records all sit\n at negative onsets never got past the seed: `furthest` stayed 0, which any interval can\n carry. Then the samples collapsed anyway, because the arithmetic that defeats a large\n positive origin defeats a large negative one identically — at -1e16 seconds, adding a\n 1-second sample interval leaves the double unchanged.\n\n A four-record recording of eight samples wrote two rows, exit 0, no warning. Its\n byte-for-byte positive mirror wrote all eight and explained why it had to time them from\n zero. Same file, same failure, opposite sign, opposite outcome — and the silent one is\n the one that loses data, which is exactly what unusableOrigin exists to prevent.\n */\n let furthest = 0;\n for (const start of starts) if (Math.abs(start) > Math.abs(furthest)) furthest = start;\n if (!canCarry(furthest, file)) {\n diagnostics.push(unusableOrigin(furthest, file));\n return { starts: null, diagnostics };\n }\n\n let outOfOrder = 0;\n for (let i = 1; i < starts.length; i++) {\n if ((starts[i] as number) < (starts[i - 1] as number)) outOfOrder++;\n }\n if (outOfOrder > 0) {\n diagnostics.push({\n code: 'DISCONTINUOUS',\n severity: 'warning',\n message: `${outOfOrder} data record${outOfOrder === 1 ? '' : 's'} start earlier than the record before it.`,\n hint: 'Rows are written in file order, so the time column will not increase monotonically.',\n });\n }\n\n return { starts, diagnostics };\n}\n\n/**\n * The recording's origin, from the first record that states where it is.\n *\n * Records of a continuous recording sit end to end, so record `i` beginning at `t` puts the\n * origin at `t - i * duration`. Any one readable timekeeping TAL is therefore enough, which\n * is what stops one unreadable entry from costing the whole file its position in time.\n */\nfunction originOf(recordStarts: readonly (number | null)[], recordDuration: number): number | null {\n for (const [index, declared] of recordStarts.entries()) {\n if (typeof declared !== 'number') continue;\n const origin = declared - index * recordDuration;\n return Number.isFinite(origin) ? origin : null;\n }\n return null;\n}\n\n/**\n * Whether times this far out can still tell one sample from the next.\n *\n * A double spaces its values further apart the larger they get: at 1e16 the gap is 2\n * seconds, so `t + 1` is `t`. Past that point a recording's declared origin stops being a\n * position and becomes a wall — the arithmetic that places records and samples returns the\n * origin itself, whatever is added to it.\n *\n * The finest thing that has to survive is the gap between two consecutive samples of the\n * fastest channel, since that is what the time column is made of. If that survives, so does\n * a whole record.\n */\nfunction canCarry(origin: number, file: EdfFile): boolean {\n if (!Number.isFinite(origin)) return false;\n // Asked of the origin furthest from zero, whichever side it is on: the spacing of doubles\n // grows with magnitude, not with value, so -1e16 and +1e16 fail this identically.\n return origin + finestInterval(file) > origin;\n}\n\n/**\n * The shortest span this recording can tell apart: one sample of its fastest channel.\n *\n * The time column is made of these, so nothing below one is a distinction the file is in a\n * position to make — which is what makes it the right size for both the \"can this origin\n * still separate two samples\" question and the \"is this record really somewhere else\"\n * question.\n */\nfunction finestInterval(file: EdfFile): number {\n let interval = file.header.recordDuration;\n for (const signal of file.header.signals) {\n if (signal.isAnnotations || !(signal.samplesPerRecord > 0)) continue;\n const step = file.header.recordDuration / signal.samplesPerRecord;\n if (step > 0 && step < interval) interval = step;\n }\n return interval;\n}\n\n/**\n * An origin the file's own arithmetic cannot express, reported rather than acted on.\n *\n * Two things went wrong when this was taken at face value, both of them quiet. A file whose\n * records all collapsed onto one instant made the recording zero seconds long, and the\n * window resolver — which had no reason to suspect the recording rather than the request —\n * blamed a flag nobody had passed:\n *\n * error: --start 100000000000000000s is at or past the end of this\n * 100000000000000000s recording.\n *\n * Slightly below that, the collapse is partial: `records[i].start + recordDuration` equals\n * the start again, so the test for \"does this record overlap the window\" fails for every\n * record whose neighbour rounded onto it. A twelve-row recording wrote four rows, exit 0,\n * no warning — the eight that vanished looked exactly like a file that never had them.\n *\n * Timing from zero is what the file did before 0.4.9 taught it to honour the first\n * timekeeping TAL, and at this magnitude it is the only column that can hold distinct\n * values. The origin is lost, so this says so.\n */\nfunction unusableOrigin(origin: number, file: EdfFile): Diagnostic {\n return {\n code: 'DISCONTINUOUS',\n severity: 'warning',\n message:\n `This recording's timekeeping annotations place it ${origin}s from its own start ` +\n `date, which is too far out for its ${file.header.recordDuration}s records to be told ` +\n `apart: at that magnitude adding a sample interval leaves the number unchanged.`,\n hint:\n 'Sample times are written from zero instead, so every row is present and the column ' +\n 'increases. Add the onsets in annotations.csv to recover absolute times if you need them.',\n };\n}\n"]}
1
+ {"version":3,"file":"timing.js","sourceRoot":"","sources":["../../src/convert/timing.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Diagnostic } from '../edf/errors.js';\nimport type { EdfFile } from '../edf/reader.js';\n\nexport interface AnnotationTimingData {\n recordStarts: (number | null)[];\n malformed: number;\n /** Unreadable TALs in first position, which carry timing rather than an event. */\n malformedTimekeeping?: number;\n}\n\n/**\n * Resolve the true start time of every data record.\n *\n * Continuous recordings need no table because their record positions are\n * arithmetic. EDF+D recordings carry their positions in the annotation channel;\n * missing or malformed timekeeping entries are reported before falling back.\n */\nexport function deriveRecordStarts(\n file: EdfFile,\n annotationData: AnnotationTimingData,\n): { starts: Float64Array | null; diagnostics: Diagnostic[] } {\n const diagnostics: Diagnostic[] = [];\n\n if (annotationData.malformed > 0) {\n diagnostics.push({\n code: 'ANNOTATION_DECODE_FAILED',\n severity: 'warning',\n message:\n `${annotationData.malformed} annotation entr${annotationData.malformed === 1 ? 'y was' : 'ies were'} ` +\n `unreadable and could not be exported.`,\n hint: 'The rest were exported normally. The file may have been written by a non-conforming tool.',\n });\n }\n\n /*\n A timekeeping TAL is not an event, and saying it \"could not be exported\" describes the\n wrong loss twice over.\n\n These were counted among the annotations, so a file with one unreadable timekeeping TAL\n and three good events announced \"1 annotation entry was unreadable and could not be\n exported\" — while exporting all three. Nothing was missing from annotations.csv; what\n went missing was a record's position in time, which the message never mentioned.\n */\n const lostTimekeeping = annotationData.malformedTimekeeping ?? 0;\n // The EDF+D branch below raises its own, which names the records and is more specific.\n // Saying both would report one problem twice.\n if (lostTimekeeping > 0 && file.header.continuity !== 'EDF+D') {\n const one = lostTimekeeping === 1;\n diagnostics.push({\n code: 'ANNOTATION_DECODE_FAILED',\n severity: 'warning',\n message:\n `${lostTimekeeping} data record${one ? '' : 's'} carr${one ? 'ies' : 'y'} a timekeeping ` +\n `annotation that could not be read, so ${one ? 'it does' : 'they do'} not say where in ` +\n `time ${one ? 'it sits' : 'they sit'}.`,\n hint:\n 'No event was lost — a timekeeping annotation states a record\\'s start time and is ' +\n 'never exported. Times are derived from the records that could be read.',\n });\n }\n\n /*\n A continuous recording's records are contiguous, but the first one need not sit at zero.\n\n EDF+ puts the header's start time and every annotation onset on one origin, and says the\n first data record's timekeeping TAL \"always starts with +0.X\", stating the fraction of a\n second by which that record follows it. Ignoring that fraction timed the samples from 0\n while the events kept their true onsets, so the two ended up on origins half a second\n apart — an event at +0.75 in a 4 Hz recording whose first TAL reads +0.5 landed on sample\n 3 instead of sample 1. The same file marked EDF+D, byte-identical but for the reserved\n field, placed it correctly, which is what gives the omission away.\n\n Records stay contiguous, which is what continuous means: only the origin moves. A first\n TAL of +0 needs no table at all, and that is nearly every file.\n */\n if (file.header.continuity !== 'EDF+D') {\n if (file.header.continuity !== 'EDF+C') return { starts: null, diagnostics };\n\n /*\n The origin comes from whichever record first states one, not from record 0 alone.\n\n Reading only `recordStarts[0]` meant a single unreadable timekeeping TAL threw the\n origin away and timed the whole file from zero — while records 1 and 2, saying plainly\n that they start at 1.5s and 2.5s, went unread. A recording whose records sit at 0.5s,\n 1.5s and 2.5s came out with every sample 0.5s earlier than the file states, against\n annotation onsets that kept their true values. That is precisely the mismatch 0.4.9\n fixed, arriving through the one hole left in it, and the byte-identical EDF+D twin\n timed it correctly, which is what gives it away.\n\n Continuity is what makes this recoverable: record i sits at `origin + i * duration`,\n so any readable record determines the origin for all of them.\n */\n const origin = originOf(annotationData.recordStarts, file.header.recordDuration);\n if (origin === null || origin === 0) return { starts: null, diagnostics };\n\n const contiguous = new Float64Array(file.recordCount);\n for (let i = 0; i < file.recordCount; i++) {\n contiguous[i] = origin + i * file.header.recordDuration;\n }\n\n /*\n A file marked continuous whose own records disagree about it.\n\n Nothing looked at records past the first, so an EDF+C file whose records are in fact\n spread out was timed as though they were contiguous and said nothing. The records are\n being read here anyway, so the contradiction costs nothing to notice — and it is the\n file, not the reader, that has to be wrong for this to fire.\n\n Compared against what the file can express, not for equality. 0.4.41 asked whether the\n two doubles were the same, which they are not: a recording of 0.1s records sitting at\n 0.1, 0.2, 0.3 ... is contiguous by construction, and 0.1 + 2 * 0.1 is\n 0.30000000000000004. Two of its eight records were reported as contradicting\n continuity, on an ordinary file — and under --strict that was a failed run. The\n smallest interval the recording distinguishes is one sample of its fastest channel;\n anything below half of that is arithmetic, not a gap. `canCarry` has already refused\n origins where the double spacing swamps that interval, so the representation error is\n under the tolerance by construction rather than by hope.\n */\n const tolerance = finestInterval(file) / 2;\n const contradicting = annotationData.recordStarts.filter(\n (declared, i) =>\n typeof declared === 'number' && Math.abs(declared - (contiguous[i] as number)) > tolerance,\n ).length;\n if (contradicting > 0) {\n diagnostics.push({\n code: 'DISCONTINUOUS',\n severity: 'warning',\n message:\n `This file is marked continuous (EDF+C), but ${contradicting} of its ` +\n `${file.recordCount} data records say they start somewhere other than where ` +\n `continuity puts them.`,\n hint:\n 'Times are written as if the records were contiguous, which is what EDF+C means. ' +\n 'If the recording really has gaps, the file should have been marked EDF+D.',\n });\n }\n const first = origin;\n const last = contiguous[file.recordCount - 1] ?? first;\n if (!canCarry(last, file)) {\n diagnostics.push(unusableOrigin(first, file));\n return { starts: null, diagnostics };\n }\n return { starts: contiguous, diagnostics };\n }\n\n if (file.annotationSignals.length === 0) {\n diagnostics.push({\n code: 'DISCONTINUOUS',\n severity: 'warning',\n message:\n 'This file is marked discontinuous but has no annotation channel, so where its ' +\n 'records sit in time is not recorded anywhere.',\n hint: 'Times are written as if the records were contiguous. Any gaps are lost.',\n });\n return { starts: null, diagnostics };\n }\n\n /*\n A record with no readable time is placed from the origin the other records establish,\n not from zero.\n\n `i * recordDuration` assumed the recording began at zero, which is the one thing the\n other records are in a position to contradict: a file starting at 0.5s put its\n unreadable record at 0.000 while its neighbours sat at 1.5s and 2.5s. The guess is still\n a guess — a discontinuous file may have a gap exactly there — and it is still reported\n below, but starting it from where the recording actually begins is strictly closer, and\n it makes an EDF+D file agree with its byte-identical EDF+C twin about record 0.\n */\n const base = originOf(annotationData.recordStarts, file.header.recordDuration) ?? 0;\n const starts = new Float64Array(file.recordCount);\n const missing: number[] = [];\n for (let i = 0; i < file.recordCount; i++) {\n const declared = annotationData.recordStarts[i];\n if (declared === null || declared === undefined) {\n missing.push(i);\n starts[i] = base + i * file.header.recordDuration;\n } else {\n starts[i] = declared;\n }\n }\n\n if (missing.length > 0) {\n const shown = missing.slice(0, 5).join(', ');\n diagnostics.push({\n code: 'ANNOTATION_DECODE_FAILED',\n severity: 'warning',\n message:\n `${missing.length} of ${file.recordCount} data records carry no readable timekeeping ` +\n `annotation (record${missing.length === 1 ? '' : 's'} ${shown}` +\n `${missing.length > 5 ? ', …' : ''}), so their true position in time is unknown.`,\n hint: 'Those records are timed as if they were contiguous; treat their timestamps as unreliable.',\n });\n }\n\n /*\n Furthest from zero, in either direction.\n\n This took the signed maximum and seeded it with 0, so a recording whose records all sit\n at negative onsets never got past the seed: `furthest` stayed 0, which any interval can\n carry. Then the samples collapsed anyway, because the arithmetic that defeats a large\n positive origin defeats a large negative one identically — at -1e16 seconds, adding a\n 1-second sample interval leaves the double unchanged.\n\n A four-record recording of eight samples wrote two rows, exit 0, no warning. Its\n byte-for-byte positive mirror wrote all eight and explained why it had to time them from\n zero. Same file, same failure, opposite sign, opposite outcome — and the silent one is\n the one that loses data, which is exactly what unusableOrigin exists to prevent.\n */\n let furthest = 0;\n for (const start of starts) if (Math.abs(start) > Math.abs(furthest)) furthest = start;\n if (!canCarry(furthest, file)) {\n diagnostics.push(unusableOrigin(furthest, file));\n return { starts: null, diagnostics };\n }\n\n /*\n Two ways a record can put the time column out of order, and only one was being looked for.\n\n A record starting before the one before it is the obvious case. The other is a record\n starting before the one before it *ends*: starts of 0, 0.5 and 1.0 on one-second records\n are strictly increasing, so nothing fired, and the rows still came out 0.25, 0.5, 0.75,\n 0.5 — because record 0's samples run to 0.75 while record 1 begins at 0.5. Overlapping\n acquisition is what a device does when it re-sends a buffer, and the reader has no more\n to say about it than about the reversed case: every sample is written, in file order,\n with the time the file gives it.\n\n Contiguity is not overlap. A continuous recording has `starts[i] === starts[i-1] +\n duration` exactly, so the comparison is made strict by a fraction of the finest interval\n the recording can express — the same measure the origin check uses.\n */\n const slack = finestInterval(file) / 2;\n let outOfOrder = 0;\n let overlapping = 0;\n for (let i = 1; i < starts.length; i++) {\n const previous = starts[i - 1] as number;\n const current = starts[i] as number;\n if (current < previous) outOfOrder++;\n else if (current + slack < previous + file.header.recordDuration) overlapping++;\n }\n if (outOfOrder > 0) {\n diagnostics.push({\n code: 'DISCONTINUOUS',\n severity: 'warning',\n message: `${outOfOrder} data record${outOfOrder === 1 ? '' : 's'} start earlier than the record before it.`,\n hint: 'Rows are written in file order, so the time column will not increase monotonically.',\n });\n }\n if (overlapping > 0) {\n diagnostics.push({\n code: 'DISCONTINUOUS',\n severity: 'warning',\n message:\n `${overlapping} data record${overlapping === 1 ? '' : 's'} start before the record ` +\n `before ${overlapping === 1 ? 'it' : 'them'} ends, so their samples overlap in time.`,\n hint: 'Rows are written in file order, so the time column will not increase monotonically.',\n });\n }\n\n return { starts, diagnostics };\n}\n\n/**\n * The recording's origin, from the first record that states where it is.\n *\n * Records of a continuous recording sit end to end, so record `i` beginning at `t` puts the\n * origin at `t - i * duration`. Any one readable timekeeping TAL is therefore enough, which\n * is what stops one unreadable entry from costing the whole file its position in time.\n */\nfunction originOf(recordStarts: readonly (number | null)[], recordDuration: number): number | null {\n for (const [index, declared] of recordStarts.entries()) {\n if (typeof declared !== 'number') continue;\n const origin = declared - index * recordDuration;\n return Number.isFinite(origin) ? origin : null;\n }\n return null;\n}\n\n/**\n * Whether times this far out can still tell one sample from the next.\n *\n * A double spaces its values further apart the larger they get: at 1e16 the gap is 2\n * seconds, so `t + 1` is `t`. Past that point a recording's declared origin stops being a\n * position and becomes a wall — the arithmetic that places records and samples returns the\n * origin itself, whatever is added to it.\n *\n * The finest thing that has to survive is the gap between two consecutive samples of the\n * fastest channel, since that is what the time column is made of. If that survives, so does\n * a whole record.\n */\nfunction canCarry(origin: number, file: EdfFile): boolean {\n if (!Number.isFinite(origin)) return false;\n // Asked of the origin furthest from zero, whichever side it is on: the spacing of doubles\n // grows with magnitude, not with value, so -1e16 and +1e16 fail this identically.\n return origin + finestInterval(file) > origin;\n}\n\n/**\n * The shortest span this recording can tell apart: one sample of its fastest channel.\n *\n * The time column is made of these, so nothing below one is a distinction the file is in a\n * position to make — which is what makes it the right size for both the \"can this origin\n * still separate two samples\" question and the \"is this record really somewhere else\"\n * question.\n */\nfunction finestInterval(file: EdfFile): number {\n let interval = file.header.recordDuration;\n for (const signal of file.header.signals) {\n if (signal.isAnnotations || !(signal.samplesPerRecord > 0)) continue;\n const step = file.header.recordDuration / signal.samplesPerRecord;\n if (step > 0 && step < interval) interval = step;\n }\n return interval;\n}\n\n/**\n * An origin the file's own arithmetic cannot express, reported rather than acted on.\n *\n * Two things went wrong when this was taken at face value, both of them quiet. A file whose\n * records all collapsed onto one instant made the recording zero seconds long, and the\n * window resolver — which had no reason to suspect the recording rather than the request —\n * blamed a flag nobody had passed:\n *\n * error: --start 100000000000000000s is at or past the end of this\n * 100000000000000000s recording.\n *\n * Slightly below that, the collapse is partial: `records[i].start + recordDuration` equals\n * the start again, so the test for \"does this record overlap the window\" fails for every\n * record whose neighbour rounded onto it. A twelve-row recording wrote four rows, exit 0,\n * no warning — the eight that vanished looked exactly like a file that never had them.\n *\n * Timing from zero is what the file did before 0.4.9 taught it to honour the first\n * timekeeping TAL, and at this magnitude it is the only column that can hold distinct\n * values. The origin is lost, so this says so.\n */\nfunction unusableOrigin(origin: number, file: EdfFile): Diagnostic {\n return {\n code: 'DISCONTINUOUS',\n severity: 'warning',\n message:\n `This recording's timekeeping annotations place it ${origin}s from its own start ` +\n `date, which is too far out for its ${file.header.recordDuration}s records to be told ` +\n `apart: at that magnitude adding a sample interval leaves the number unchanged.`,\n hint:\n 'Sample times are written from zero instead, so every row is present and the column ' +\n 'increases. Add the onsets in annotations.csv to recover absolute times if you need them.',\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edf2csv",
3
- "version": "0.5.24",
3
+ "version": "0.5.26",
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",