edf2csv 0.5.15 → 0.5.17

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,62 @@
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.17
7
+
8
+ ### Fixed: a recording timestamped far in the negative direction lost two thirds of its rows, silently
9
+
10
+ A double spaces its values further apart the larger they get. Near 1e16 seconds the gap
11
+ between representable numbers is two seconds, so adding a one-second sample interval leaves
12
+ the number unchanged and every sample in a record lands on one instant. The tool has a guard
13
+ for that, and a warning, and times the recording from zero instead so every row survives.
14
+
15
+ The guard took the signed maximum of the record start times, seeded with zero. An
16
+ all-negative recording therefore never got past the seed: the check ran on an origin of 0,
17
+ which any interval can carry, and passed. The collapse happened anyway, because the spacing
18
+ of doubles grows with magnitude and not with value — -1e16 defeats a one-second interval
19
+ exactly as +1e16 does.
20
+
21
+ A twelve-row discontinuous recording wrote four rows, exit 0, nothing said. Its byte-for-byte
22
+ positive mirror wrote all twelve and explained why. Same file, same arithmetic, opposite
23
+ sign, and the silent one was the one losing data — which is the precise failure `unusableOrigin`
24
+ was written to prevent, described in its own comment.
25
+
26
+ Measured by distance from zero now. A fixture holds it, and it took two attempts to write:
27
+ the first was continuous, and a continuous file takes its record times from continuity, so
28
+ its declared onsets never reach the guard at all. Discontinuity is what makes them
29
+ load-bearing.
30
+
31
+ Also documents the condition, which was not on the warnings page. That page said
32
+ `DISCONTINUOUS` "covers three related conditions" and listed three; the code raises four.
33
+
34
+ ## 0.5.16
35
+
36
+ ### Added: `npm run layouts`, which checks the claim `--layout long` is built on
37
+
38
+ Every page says the same thing about it: a different shape, not different data. That is what
39
+ makes it an honest answer to a mixed-rate recording rather than a second way to be wrong, and
40
+ for thirteen versions nothing ran it. In those thirteen versions the long layout shipped four
41
+ defects — a byte audit counting one writer per rate group, then the same again for the
42
+ compressed stream, then a channel order taken from the sampling rate rather than from the
43
+ file, then a promise of sorted rows a discontinuous recording can break. Three of the four
44
+ were found by reading code, not by converting anything.
45
+
46
+ So this converts. Every fixture crossed with six option sets that move the window and the
47
+ precision, both layouts, compared per channel: the wide column read down its rows against
48
+ that channel's rows in the long table, as an ordered sequence of value cells.
49
+
50
+ Deliberately not joined on time. The two layouts write `time_s` at different precisions by
51
+ design — the long one shares the finest any rate needs, since one column cannot mean three
52
+ things — so a time-keyed comparison compares the formatting rather than the data, and at nine
53
+ decimal places it collapses distinct sub-nanosecond samples into one key. The first version
54
+ of this harness did exactly that and reported 42 disagreements that were all its own.
55
+
56
+ Confirmed capable of failing before being kept: making the long layout skip one sample per
57
+ record is caught on the first recording, as a channel holding 8 values one way and 6 the
58
+ other. It is the correctness page's eighth claim, and the page states the sweep's shape
59
+ rather than a total, since the totals move with the fixture set — a test holds the shape
60
+ against the harness.
61
+
6
62
  ## 0.5.15
7
63
 
8
64
  ### Fixed: `--stdout` on a recording with no signal table, which both layouts got wrong
@@ -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,CAmM5D"}
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"}
@@ -162,9 +162,23 @@ export function deriveRecordStarts(file, annotationData) {
162
162
  hint: 'Those records are timed as if they were contiguous; treat their timestamps as unreliable.',
163
163
  });
164
164
  }
165
+ /*
166
+ Furthest from zero, in either direction.
167
+
168
+ This took the signed maximum and seeded it with 0, so a recording whose records all sit
169
+ at negative onsets never got past the seed: `furthest` stayed 0, which any interval can
170
+ carry. Then the samples collapsed anyway, because the arithmetic that defeats a large
171
+ positive origin defeats a large negative one identically — at -1e16 seconds, adding a
172
+ 1-second sample interval leaves the double unchanged.
173
+
174
+ A four-record recording of eight samples wrote two rows, exit 0, no warning. Its
175
+ byte-for-byte positive mirror wrote all eight and explained why it had to time them from
176
+ zero. Same file, same failure, opposite sign, opposite outcome — and the silent one is
177
+ the one that loses data, which is exactly what unusableOrigin exists to prevent.
178
+ */
165
179
  let furthest = 0;
166
180
  for (const start of starts)
167
- if (start > furthest)
181
+ if (Math.abs(start) > Math.abs(furthest))
168
182
  furthest = start;
169
183
  if (!canCarry(furthest, file)) {
170
184
  diagnostics.push(unusableOrigin(furthest, file));
@@ -216,6 +230,8 @@ function originOf(recordStarts, recordDuration) {
216
230
  function canCarry(origin, file) {
217
231
  if (!Number.isFinite(origin))
218
232
  return false;
233
+ // Asked of the origin furthest from zero, whichever side it is on: the spacing of doubles
234
+ // grows with magnitude, not with value, so -1e16 and +1e16 fail this identically.
219
235
  return origin + finestInterval(file) > origin;
220
236
  }
221
237
  /**
@@ -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 let furthest = 0;\n for (const start of starts) if (start > 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 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 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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edf2csv",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
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",
@@ -65,7 +65,8 @@
65
65
  "fuzz": "npm run build && npm run fixtures && node test/fuzz/mutate.mjs",
66
66
  "fuzz:batch": "npm run build && npm run fixtures && node test/fuzz/trees.mjs",
67
67
  "estimate": "npm run build && npm run fixtures && node test/fuzz/estimate.mjs",
68
- "roundtrip": "npm run build && node test/fuzz/roundtrip.mjs"
68
+ "roundtrip": "npm run build && node test/fuzz/roundtrip.mjs",
69
+ "layouts": "npm run build && npm run fixtures && node test/fuzz/layouts.mjs"
69
70
  },
70
71
  "devDependencies": {
71
72
  "@types/node": "^22.10.0",