edfcore 0.2.67 → 0.2.69

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
@@ -6,6 +6,48 @@ alone does not tell you whether you were affected.
6
6
  edfcore is pre-1.0. Patch releases have carried behaviour changes where the old behaviour was a
7
7
  defect; those are called out below.
8
8
 
9
+ ## 0.2.69
10
+
11
+ - **Documented and pinned** how an overlap is reported, after investigating whether it was reported
12
+ at all. It is: `EdfGap.durationSeconds` goes NEGATIVE, and `validateRecording` turns that into
13
+ `RECORD_ONSET_SPACING_VIOLATION` naming the segments. No new shape, no missing diagnostic — I
14
+ checked before changing anything, and there was nothing to fix.
15
+ - What was missing is that none of it was written down. Two consequences now are: summing gap
16
+ durations to get "time lost" is right only if you expect a negative term, and where two segments
17
+ cover the same instant `segmentAt` and `sampleAt` return one of them because more than one sample
18
+ genuinely exists there.
19
+ - Also pinned that a PROBED index sees none of this when a gap and an overlap cancel exactly — net
20
+ drift is zero, the file opens with no diagnostic, and `contiguityOf` answers `'unknown'`. That is
21
+ the honest answer and precisely why `buildRecordIndex` exists; the docs say so three times and
22
+ now a test does too.
23
+
24
+ ## 0.2.68
25
+
26
+ Two defects in `sample-locate.ts`, both introduced by me in 0.2.61 and found by an adversarial
27
+ sweep of it seven releases later.
28
+
29
+ - **Fixed** `sampleAt` consulting the net-drift check before the scanned index. `spanSeconds !==
30
+ coveredSeconds` is what TWO PROBES can see, and this project's own documentation says three
31
+ times that it is not a proof of contiguity: a gap that an overlap elsewhere cancels exactly
32
+ leaves span equal to coverage. On such a file — which opens with no diagnostic at all —
33
+ `sampleAt` took the nominal branch while a complete index sat on the same object reporting two
34
+ gaps, and returned a sample one whole record away from the one `readWindow` reads. It also
35
+ reported a sample inside a hole that `gapAt`, `segmentAt`, `index.locate` and `readWindow` all
36
+ report as empty. `resolveTimeWindow` has always had this precedence right; this module inverted
37
+ it. **The seventh instance of the defect this project has spent six releases on, and the first
38
+ one I introduced myself.**
39
+ - **Fixed** the discontinuous branch being unbounded. `segmentAt` compares float seconds and the
40
+ arithmetic after it compares exact ticks, so a time within half a tick of a segment end is inside
41
+ the segment for one and past it for the other: it named record 6 and sample 24 of a six-record,
42
+ 24-sample file. `sampleStartTicksOf` had the matching hole — a sample index past the end fell
43
+ through to the nominal grid and came back 6.75 s EARLIER than the last real sample. Both are now
44
+ bounded, and the second refuses rather than answering.
45
+ - **Corrected** an over-claim in the 0.2.61 entry. It said the round-trip "the sample at a sample's
46
+ start is that sample" was pinned "for every sample in the file". That is false when two records
47
+ cover the same instant — repeated onsets, which EDF+ does not forbid — because two samples exist
48
+ at that time and no function can return both. The claim holds for files whose records do not
49
+ overlap. Now stated in the source and pinned by a test that uses a genuinely overlapping fixture.
50
+
9
51
  ## 0.2.67
10
52
 
11
53
  Three defects in `formatHeader`, found by an adversarial sweep of the modules no earlier pass had
@@ -109,5 +109,5 @@ export declare const SIGNAL_FIELD_BLOCK_OFFSETS: {
109
109
  readonly reserved: 224;
110
110
  };
111
111
  /** Published package version. Kept in sync with package.json by a test. */
112
- export declare const VERSION = "0.2.67";
112
+ export declare const VERSION = "0.2.69";
113
113
  //# sourceMappingURL=constants.d.ts.map
package/dist/constants.js CHANGED
@@ -79,5 +79,5 @@ export const SIGNAL_FIELD_BLOCK_OFFSETS = {
79
79
  reserved: 224,
80
80
  };
81
81
  /** Published package version. Kept in sync with package.json by a test. */
82
- export const VERSION = '0.2.67';
82
+ export const VERSION = '0.2.69';
83
83
  //# sourceMappingURL=constants.js.map
@@ -16,6 +16,14 @@
16
16
  * `segmentAt` does: `undefined` from `sampleAt` means "no sample exists here", and an index that
17
17
  * has read record 0 and the last record cannot say that about anything in between. Merging "there
18
18
  * is a gap here" with "nobody looked" is the confusion this whole area of the API avoids.
19
+ *
20
+ * ONE LIMIT, and it belongs to the file rather than to these functions. If two records cover the
21
+ * same instant — a timeline whose onsets repeat, which EDF+ does not forbid and which edfcore
22
+ * reports without a diagnostic — then more than one sample exists at that time and no function can
23
+ * return both. `sampleAt` returns the one whose segment `segmentAt` finds. The round-trip
24
+ * "the sample at a sample's start is that sample" therefore holds for files whose records do not
25
+ * overlap, which is every file anyone is likely to have; it is not a universal law and 0.2.60
26
+ * claimed it as one.
19
27
  */
20
28
  import type { EdfRecording, EdfSampleLocation } from './types.js';
21
29
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"sample-locate.d.ts","sourceRoot":"","sources":["../src/sample-locate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAyB,MAAM,YAAY,CAAC;AAkFzF;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CACtB,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,GACd,iBAAiB,GAAG,SAAS,CAqC/B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,MAAM,CA0BR;AAED,wFAAwF;AACxF,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,MAAM,CAER"}
1
+ {"version":3,"file":"sample-locate.d.ts","sourceRoot":"","sources":["../src/sample-locate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAyB,MAAM,YAAY,CAAC;AAwFzF;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CACtB,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,GACd,iBAAiB,GAAG,SAAS,CAyD/B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,MAAM,CAsCR;AAED,wFAAwF;AACxF,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,YAAY,EACvB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,MAAM,CAER"}
@@ -16,6 +16,14 @@
16
16
  * `segmentAt` does: `undefined` from `sampleAt` means "no sample exists here", and an index that
17
17
  * has read record 0 and the last record cannot say that about anything in between. Merging "there
18
18
  * is a gap here" with "nobody looked" is the confusion this whole area of the API avoids.
19
+ *
20
+ * ONE LIMIT, and it belongs to the file rather than to these functions. If two records cover the
21
+ * same instant — a timeline whose onsets repeat, which EDF+ does not forbid and which edfcore
22
+ * reports without a diagnostic — then more than one sample exists at that time and no function can
23
+ * return both. `sampleAt` returns the one whose segment `segmentAt` finds. The round-trip
24
+ * "the sample at a sample's start is that sample" therefore holds for files whose records do not
25
+ * overlap, which is every file anyone is likely to have; it is not a universal law and 0.2.60
26
+ * claimed it as one.
19
27
  */
20
28
  import { EdfChannelNotFoundError } from './errors.js';
21
29
  import { segmentAt } from './record-index.js';
@@ -77,12 +85,18 @@ function segmentOfRecord(recording, recordIndex) {
77
85
  return undefined;
78
86
  }
79
87
  /**
80
- * Whether this recording needs a scanned index before a time can be located at all.
88
+ * Whether a PROBED recording needs a scan before a time can be located.
89
+ *
90
+ * This is a statement about a probed index and nothing else. It compares net drift — span against
91
+ * coverage — which is what two probes can see, and edfcore's own documentation says three times
92
+ * that net drift is not a proof of contiguity: a gap that an overlap elsewhere cancels exactly
93
+ * leaves span equal to coverage and is still a gap.
81
94
  *
82
- * A file whose records cover exactly its span is contiguous, and the nominal grid IS the true one
83
- * so a probed index is enough and no scan is demanded of the caller for nothing.
95
+ * So it must never be consulted while a COMPLETE index is available; `resolveTimeWindow` gets that
96
+ * precedence right and this module originally did not, which made `sampleAt` take the nominal
97
+ * branch on a file whose scanned index reported gaps (fixed in 0.2.68).
84
98
  */
85
- function requiresScan(recording) {
99
+ function probedIndexNeedsScan(recording) {
86
100
  return recording.timeline.spanSeconds !== recording.timeline.coveredSeconds;
87
101
  }
88
102
  /**
@@ -105,12 +119,27 @@ export function sampleAt(recording, signalIndex, seconds) {
105
119
  const duration = recording.header.recordDurationTicks;
106
120
  const perRecord = BigInt(signal.samplesPerRecord);
107
121
  const ticks = secondsToTicks(seconds);
108
- if (!requiresScan(recording)) {
109
- // Contiguous: the nominal grid is the true one, but the answer is still bounded by the file.
110
- const recordIndex = Number(floorDiv(ticks, duration));
111
- if (recordIndex < 0 || recordIndex >= recording.header.recordCount)
122
+ // A SCANNED index takes precedence over any statement about net drift, the same order
123
+ // `resolveTimeWindow` uses. Asking `probedIndexNeedsScan` first meant a file whose gap and
124
+ // overlap cancel — span equal to coverage, no diagnostic on open — took the nominal branch while
125
+ // a complete index sat on the same object reporting two gaps.
126
+ if (recording.index.segments !== undefined) {
127
+ // `segmentAt` owns "is there data here at all" and throws for a probed index rather than
128
+ // guessing; it cannot be reached with one here, because segments only exist on a scanned index.
129
+ const segment = segmentAt(recording.index, seconds);
130
+ if (segment === undefined)
131
+ return undefined;
132
+ const offsetTicks = ticks - segment.startTicks;
133
+ const recordOffset = floorDiv(offsetTicks, duration);
134
+ // Bounded by the SEGMENT, as the nominal branch is bounded by the record count. `segmentAt`
135
+ // compares float seconds while this compares exact ticks, and `secondsToTicks` rounds to the
136
+ // nearest tick — so a time within half a tick of `segment.endSeconds` is inside the segment
137
+ // for one and at its end for the other, and without this it walked into the next segment or
138
+ // off the end of the file.
139
+ if (recordOffset < 0n || recordOffset >= BigInt(segment.records.count))
112
140
  return undefined;
113
- const withinTicks = ticks - BigInt(recordIndex) * duration;
141
+ const recordIndex = segment.records.start + Number(recordOffset);
142
+ const withinTicks = offsetTicks - recordOffset * duration;
114
143
  const sampleWithinRecord = Number(floorDiv(withinTicks * perRecord, duration));
115
144
  return {
116
145
  sampleIndex: recordIndex * signal.samplesPerRecord + sampleWithinRecord,
@@ -118,14 +147,16 @@ export function sampleAt(recording, signalIndex, seconds) {
118
147
  sampleWithinRecord,
119
148
  };
120
149
  }
121
- // Discontinuous: `segmentAt` owns the "is there data here at all" question, and throws for a
122
- // probed index rather than guessing.
123
- const segment = segmentAt(recording.index, seconds);
124
- if (segment === undefined)
150
+ if (probedIndexNeedsScan(recording)) {
151
+ throw new RangeError('sampleAt(): this file has gaps and its index has not been scanned, so which records cover ' +
152
+ 'a time is not known. Next: await buildRecordIndex(recording) and read the result into ' +
153
+ 'the recording.');
154
+ }
155
+ // Contiguous, probed: the nominal grid is the true one, and the answer is bounded by the file.
156
+ const recordIndex = Number(floorDiv(ticks, duration));
157
+ if (recordIndex < 0 || recordIndex >= recording.header.recordCount)
125
158
  return undefined;
126
- const offsetTicks = ticks - segment.startTicks;
127
- const recordIndex = segment.records.start + Number(floorDiv(offsetTicks, duration));
128
- const withinTicks = offsetTicks - floorDiv(offsetTicks, duration) * duration;
159
+ const withinTicks = ticks - BigInt(recordIndex) * duration;
129
160
  const sampleWithinRecord = Number(floorDiv(withinTicks * perRecord, duration));
130
161
  return {
131
162
  sampleIndex: recordIndex * signal.samplesPerRecord + sampleWithinRecord,
@@ -154,7 +185,16 @@ export function sampleStartTicksOf(recording, signalIndex, sampleIndex) {
154
185
  const duration = recording.header.recordDurationTicks;
155
186
  const recordIndex = Math.floor(sampleIndex / perRecord);
156
187
  const within = BigInt(sampleIndex - recordIndex * perRecord);
157
- if (requiresScan(recording) && recording.index.segments === undefined) {
188
+ // Bounded by the file. An index past the end used to fall through `segmentOfRecord` to the
189
+ // nominal grid and come back EARLIER than the last real sample — on a file with a 7 s gap,
190
+ // sample 24 of a 24-sample file reported 6.75 s before sample 23. Refusing is the only honest
191
+ // answer for a sample that does not exist.
192
+ if (sampleIndex < 0 || recordIndex >= recording.header.recordCount) {
193
+ throw new RangeError(`sampleStartTicksOf(): sample ${sampleIndex} is outside the ` +
194
+ `${recording.header.recordCount * perRecord} samples signal ${signalIndex} has. ` +
195
+ 'Next: clamp the index, or read signal.sampleCount first.');
196
+ }
197
+ if (recording.index.segments === undefined && probedIndexNeedsScan(recording)) {
158
198
  throw new RangeError('sampleStartTicksOf(): this file has gaps and its index has not been scanned, so the true ' +
159
199
  'start of a record after a gap is not known. Next: await buildRecordIndex(recording) and ' +
160
200
  'read the result into the recording.');
@@ -1 +1 @@
1
- {"version":3,"file":"sample-locate.js","sourceRoot":"","sources":["../src/sample-locate.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sample-locate.js","sourceRoot":"","sources":["../src/sample-locate.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edfcore",
3
- "version": "0.2.67",
3
+ "version": "0.2.69",
4
4
  "description": "Modern, typed, zero-dependency reader for EDF, EDF+, BDF and BDF+ biosignal files. Works in browsers and Node with true random access.",
5
5
  "keywords": [
6
6
  "edf",
package/src/constants.ts CHANGED
@@ -93,4 +93,4 @@ export const SIGNAL_FIELD_BLOCK_OFFSETS = {
93
93
  } as const;
94
94
 
95
95
  /** Published package version. Kept in sync with package.json by a test. */
96
- export const VERSION = '0.2.67';
96
+ export const VERSION = '0.2.69';
@@ -16,6 +16,14 @@
16
16
  * `segmentAt` does: `undefined` from `sampleAt` means "no sample exists here", and an index that
17
17
  * has read record 0 and the last record cannot say that about anything in between. Merging "there
18
18
  * is a gap here" with "nobody looked" is the confusion this whole area of the API avoids.
19
+ *
20
+ * ONE LIMIT, and it belongs to the file rather than to these functions. If two records cover the
21
+ * same instant — a timeline whose onsets repeat, which EDF+ does not forbid and which edfcore
22
+ * reports without a diagnostic — then more than one sample exists at that time and no function can
23
+ * return both. `sampleAt` returns the one whose segment `segmentAt` finds. The round-trip
24
+ * "the sample at a sample's start is that sample" therefore holds for files whose records do not
25
+ * overlap, which is every file anyone is likely to have; it is not a universal law and 0.2.60
26
+ * claimed it as one.
19
27
  */
20
28
 
21
29
  import { EdfChannelNotFoundError } from './errors.js';
@@ -94,12 +102,18 @@ function segmentOfRecord(recording: EdfRecording, recordIndex: number): EdfSegme
94
102
  }
95
103
 
96
104
  /**
97
- * Whether this recording needs a scanned index before a time can be located at all.
105
+ * Whether a PROBED recording needs a scan before a time can be located.
106
+ *
107
+ * This is a statement about a probed index and nothing else. It compares net drift — span against
108
+ * coverage — which is what two probes can see, and edfcore's own documentation says three times
109
+ * that net drift is not a proof of contiguity: a gap that an overlap elsewhere cancels exactly
110
+ * leaves span equal to coverage and is still a gap.
98
111
  *
99
- * A file whose records cover exactly its span is contiguous, and the nominal grid IS the true one
100
- * so a probed index is enough and no scan is demanded of the caller for nothing.
112
+ * So it must never be consulted while a COMPLETE index is available; `resolveTimeWindow` gets that
113
+ * precedence right and this module originally did not, which made `sampleAt` take the nominal
114
+ * branch on a file whose scanned index reported gaps (fixed in 0.2.68).
101
115
  */
102
- function requiresScan(recording: EdfRecording): boolean {
116
+ function probedIndexNeedsScan(recording: EdfRecording): boolean {
103
117
  return recording.timeline.spanSeconds !== recording.timeline.coveredSeconds;
104
118
  }
105
119
 
@@ -129,11 +143,27 @@ export function sampleAt(
129
143
  const perRecord = BigInt(signal.samplesPerRecord);
130
144
  const ticks = secondsToTicks(seconds);
131
145
 
132
- if (!requiresScan(recording)) {
133
- // Contiguous: the nominal grid is the true one, but the answer is still bounded by the file.
134
- const recordIndex = Number(floorDiv(ticks, duration));
135
- if (recordIndex < 0 || recordIndex >= recording.header.recordCount) return undefined;
136
- const withinTicks = ticks - BigInt(recordIndex) * duration;
146
+ // A SCANNED index takes precedence over any statement about net drift, the same order
147
+ // `resolveTimeWindow` uses. Asking `probedIndexNeedsScan` first meant a file whose gap and
148
+ // overlap cancel — span equal to coverage, no diagnostic on open — took the nominal branch while
149
+ // a complete index sat on the same object reporting two gaps.
150
+ if (recording.index.segments !== undefined) {
151
+ // `segmentAt` owns "is there data here at all" and throws for a probed index rather than
152
+ // guessing; it cannot be reached with one here, because segments only exist on a scanned index.
153
+ const segment = segmentAt(recording.index, seconds);
154
+ if (segment === undefined) return undefined;
155
+
156
+ const offsetTicks = ticks - segment.startTicks;
157
+ const recordOffset = floorDiv(offsetTicks, duration);
158
+ // Bounded by the SEGMENT, as the nominal branch is bounded by the record count. `segmentAt`
159
+ // compares float seconds while this compares exact ticks, and `secondsToTicks` rounds to the
160
+ // nearest tick — so a time within half a tick of `segment.endSeconds` is inside the segment
161
+ // for one and at its end for the other, and without this it walked into the next segment or
162
+ // off the end of the file.
163
+ if (recordOffset < 0n || recordOffset >= BigInt(segment.records.count)) return undefined;
164
+
165
+ const recordIndex = segment.records.start + Number(recordOffset);
166
+ const withinTicks = offsetTicks - recordOffset * duration;
137
167
  const sampleWithinRecord = Number(floorDiv(withinTicks * perRecord, duration));
138
168
  return {
139
169
  sampleIndex: recordIndex * signal.samplesPerRecord + sampleWithinRecord,
@@ -142,14 +172,18 @@ export function sampleAt(
142
172
  };
143
173
  }
144
174
 
145
- // Discontinuous: `segmentAt` owns the "is there data here at all" question, and throws for a
146
- // probed index rather than guessing.
147
- const segment = segmentAt(recording.index, seconds);
148
- if (segment === undefined) return undefined;
175
+ if (probedIndexNeedsScan(recording)) {
176
+ throw new RangeError(
177
+ 'sampleAt(): this file has gaps and its index has not been scanned, so which records cover ' +
178
+ 'a time is not known. Next: await buildRecordIndex(recording) and read the result into ' +
179
+ 'the recording.',
180
+ );
181
+ }
149
182
 
150
- const offsetTicks = ticks - segment.startTicks;
151
- const recordIndex = segment.records.start + Number(floorDiv(offsetTicks, duration));
152
- const withinTicks = offsetTicks - floorDiv(offsetTicks, duration) * duration;
183
+ // Contiguous, probed: the nominal grid is the true one, and the answer is bounded by the file.
184
+ const recordIndex = Number(floorDiv(ticks, duration));
185
+ if (recordIndex < 0 || recordIndex >= recording.header.recordCount) return undefined;
186
+ const withinTicks = ticks - BigInt(recordIndex) * duration;
153
187
  const sampleWithinRecord = Number(floorDiv(withinTicks * perRecord, duration));
154
188
  return {
155
189
  sampleIndex: recordIndex * signal.samplesPerRecord + sampleWithinRecord,
@@ -187,7 +221,19 @@ export function sampleStartTicksOf(
187
221
  const recordIndex = Math.floor(sampleIndex / perRecord);
188
222
  const within = BigInt(sampleIndex - recordIndex * perRecord);
189
223
 
190
- if (requiresScan(recording) && recording.index.segments === undefined) {
224
+ // Bounded by the file. An index past the end used to fall through `segmentOfRecord` to the
225
+ // nominal grid and come back EARLIER than the last real sample — on a file with a 7 s gap,
226
+ // sample 24 of a 24-sample file reported 6.75 s before sample 23. Refusing is the only honest
227
+ // answer for a sample that does not exist.
228
+ if (sampleIndex < 0 || recordIndex >= recording.header.recordCount) {
229
+ throw new RangeError(
230
+ `sampleStartTicksOf(): sample ${sampleIndex} is outside the ` +
231
+ `${recording.header.recordCount * perRecord} samples signal ${signalIndex} has. ` +
232
+ 'Next: clamp the index, or read signal.sampleCount first.',
233
+ );
234
+ }
235
+
236
+ if (recording.index.segments === undefined && probedIndexNeedsScan(recording)) {
191
237
  throw new RangeError(
192
238
  'sampleStartTicksOf(): this file has gaps and its index has not been scanned, so the true ' +
193
239
  'start of a record after a gap is not known. Next: await buildRecordIndex(recording) and ' +