edfcore 0.1.5 → 0.1.7
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/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/diagnostics/codes.d.ts +1 -1
- package/dist/diagnostics/codes.d.ts.map +1 -1
- package/dist/diagnostics/codes.js +1 -0
- package/dist/diagnostics/codes.js.map +1 -1
- package/dist/diagnostics/collector.d.ts +14 -0
- package/dist/diagnostics/collector.d.ts.map +1 -1
- package/dist/diagnostics/collector.js +17 -0
- package/dist/diagnostics/collector.js.map +1 -1
- package/dist/envelope.d.ts +48 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/envelope.js +263 -0
- package/dist/envelope.js.map +1 -0
- package/dist/header/parse.d.ts.map +1 -1
- package/dist/header/parse.js +33 -0
- package/dist/header/parse.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/record-index.d.ts.map +1 -1
- package/dist/record-index.js +10 -3
- package/dist/record-index.js.map +1 -1
- package/dist/tal/annotations.d.ts.map +1 -1
- package/dist/tal/annotations.js +1 -15
- package/dist/tal/annotations.js.map +1 -1
- package/dist/tal/ticks.d.ts +15 -0
- package/dist/tal/ticks.d.ts.map +1 -1
- package/dist/tal/ticks.js +23 -0
- package/dist/tal/ticks.js.map +1 -1
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate.js +3 -3
- package/dist/validate.js.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +1 -1
- package/src/diagnostics/codes.ts +2 -0
- package/src/diagnostics/collector.ts +17 -0
- package/src/envelope.ts +352 -0
- package/src/header/parse.ts +36 -0
- package/src/index.ts +5 -0
- package/src/record-index.ts +10 -3
- package/src/tal/annotations.ts +1 -15
- package/src/tal/ticks.ts +23 -0
- package/src/types.ts +53 -0
- package/src/validate.ts +3 -3
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Min/max envelope decimation.
|
|
3
|
+
*
|
|
4
|
+
* Layer 7. A twelve-hour recording at 256 Hz is eleven million samples per channel, and a plot is
|
|
5
|
+
* a thousand pixels wide. Something has to reduce eleven million numbers to a thousand, and which
|
|
6
|
+
* reduction you pick decides whether the picture is true.
|
|
7
|
+
*
|
|
8
|
+
* Taking every 11,000th sample is the obvious choice and the wrong one: a spike, a spindle or an
|
|
9
|
+
* artifact is a handful of samples wide, so subsampling hits it with probability near zero and
|
|
10
|
+
* the trace looks calm exactly where a reader most needs it not to. Keeping the MINIMUM and
|
|
11
|
+
* MAXIMUM of each bucket keeps every extreme, at two numbers per pixel. That is the reduction a
|
|
12
|
+
* waveform viewer wants, and it is why this exists as its own function rather than as an option
|
|
13
|
+
* on `readWindow`: the return type is different, so an option would have to change it.
|
|
14
|
+
*
|
|
15
|
+
* This is decimation for display, not analysis. There is no filtering and no anti-aliasing —
|
|
16
|
+
* an envelope is a faithful summary of the samples that are there, not a resampled signal, and
|
|
17
|
+
* resampling remains a permanent non-goal.
|
|
18
|
+
*
|
|
19
|
+
* Memory is bounded by the record chunk, never by the window: a run of a million records is
|
|
20
|
+
* folded into the buckets a chunk at a time, so an envelope over a whole recording costs the
|
|
21
|
+
* buckets plus one chunk.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { decodeDigitalCounted } from './decode/digital.js';
|
|
25
|
+
import { EdfScalingError } from './errors.js';
|
|
26
|
+
import { readRecordBytes } from './io/read.js';
|
|
27
|
+
import { scanChunkRecords } from './record-index.js';
|
|
28
|
+
import { decodeAnnotations } from './tal/annotations.js';
|
|
29
|
+
import { ticksToSeconds } from './tal/ticks.js';
|
|
30
|
+
import { resolveTimeWindow } from './time/window.js';
|
|
31
|
+
import type {
|
|
32
|
+
EdfChunkSignal,
|
|
33
|
+
EdfDiagnostic,
|
|
34
|
+
EdfEnvelopeChunk,
|
|
35
|
+
EdfEnvelopeSignal,
|
|
36
|
+
EdfHeader,
|
|
37
|
+
EdfPhysicalEnvelope,
|
|
38
|
+
EdfRecording,
|
|
39
|
+
EdfSignal,
|
|
40
|
+
EnvelopeSelection,
|
|
41
|
+
ReadOptions,
|
|
42
|
+
RecordRange,
|
|
43
|
+
} from './types.js';
|
|
44
|
+
|
|
45
|
+
/** Per-signal accumulator, reused across every chunk of one contiguous run. */
|
|
46
|
+
interface Accumulator {
|
|
47
|
+
readonly signal: EdfSignal;
|
|
48
|
+
readonly min: Int32Array;
|
|
49
|
+
readonly max: Int32Array;
|
|
50
|
+
readonly counts: Int32Array;
|
|
51
|
+
/** Samples of this signal already folded in, i.e. its position on the run's sample grid. */
|
|
52
|
+
consumed: number;
|
|
53
|
+
outOfRange: number;
|
|
54
|
+
scratch: Int32Array | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function assertPositiveInteger(value: number, name: string): void {
|
|
58
|
+
if (Number.isSafeInteger(value) && value > 0) return;
|
|
59
|
+
throw new RangeError(
|
|
60
|
+
`readEnvelope(): ${name} must be a positive whole number, received ${value}. ` +
|
|
61
|
+
'Next: pass the pixel width of the plot you are drawing into.',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reduces a time window to per-bucket minima and maxima, one chunk per contiguous run.
|
|
67
|
+
*
|
|
68
|
+
* The shape mirrors `readWindow` exactly — an array of chunks, one per run, empty when the window
|
|
69
|
+
* selects nothing — so a caller that already handles gaps handles envelopes for free.
|
|
70
|
+
*/
|
|
71
|
+
export async function readEnvelope(
|
|
72
|
+
recording: EdfRecording,
|
|
73
|
+
selection: EnvelopeSelection,
|
|
74
|
+
options?: ReadOptions,
|
|
75
|
+
): Promise<readonly EdfEnvelopeChunk[]> {
|
|
76
|
+
assertPositiveInteger(selection.buckets, 'buckets');
|
|
77
|
+
// Validated before the window is resolved, for the same reason readWindow does it: a bad
|
|
78
|
+
// signalIndices must not read back as an empty stretch of recording.
|
|
79
|
+
resolveEnvelopeSignals(recording.header, selection.signalIndices);
|
|
80
|
+
|
|
81
|
+
const ranges = resolveTimeWindow(
|
|
82
|
+
recording.timeline,
|
|
83
|
+
recording.index,
|
|
84
|
+
selection.startSeconds,
|
|
85
|
+
selection.durationSeconds,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const chunks: EdfEnvelopeChunk[] = [];
|
|
89
|
+
for (const records of ranges) {
|
|
90
|
+
chunks.push(await reduceRange(recording, records, selection, options));
|
|
91
|
+
}
|
|
92
|
+
return Object.freeze(chunks);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveEnvelopeSignals(
|
|
96
|
+
header: EdfHeader,
|
|
97
|
+
signalIndices: readonly number[],
|
|
98
|
+
): readonly EdfSignal[] {
|
|
99
|
+
const seen = new Set<number>();
|
|
100
|
+
const signals: EdfSignal[] = [];
|
|
101
|
+
for (const signalIndex of signalIndices) {
|
|
102
|
+
if (seen.has(signalIndex)) continue;
|
|
103
|
+
seen.add(signalIndex);
|
|
104
|
+
const signal = header.signals[signalIndex];
|
|
105
|
+
if (signal === undefined) {
|
|
106
|
+
throw new RangeError(
|
|
107
|
+
`readEnvelope(): signalIndex ${signalIndex} is outside the ${header.signals.length} ` +
|
|
108
|
+
'signals this file declares. Next: pass an index from header.dataSignalIndices.',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (signal.kind === 'annotations') {
|
|
112
|
+
throw new RangeError(
|
|
113
|
+
`readEnvelope(): signal ${signalIndex} (${JSON.stringify(signal.label)}) is this file's ` +
|
|
114
|
+
'annotations channel; its bytes are TAL text, not samples, so an envelope over them ' +
|
|
115
|
+
'would be an envelope over ASCII. Next: call readAnnotations() instead.',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
signals.push(signal);
|
|
119
|
+
}
|
|
120
|
+
return signals;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function reduceRange(
|
|
124
|
+
recording: EdfRecording,
|
|
125
|
+
records: RecordRange,
|
|
126
|
+
selection: EnvelopeSelection,
|
|
127
|
+
options?: ReadOptions,
|
|
128
|
+
): Promise<EdfEnvelopeChunk> {
|
|
129
|
+
const { source, header, timeline } = recording;
|
|
130
|
+
const signals = resolveEnvelopeSignals(header, selection.signalIndices);
|
|
131
|
+
const diagnostics: EdfDiagnostic[] = [];
|
|
132
|
+
|
|
133
|
+
// More buckets than the densest signal has samples in this run would leave holes that mean
|
|
134
|
+
// nothing, so the request is clamped rather than honoured literally.
|
|
135
|
+
const densestSamples = signals.reduce(
|
|
136
|
+
(most, signal) => Math.max(most, signal.samplesPerRecord * records.count),
|
|
137
|
+
0,
|
|
138
|
+
);
|
|
139
|
+
const bucketCount = Math.max(1, Math.min(selection.buckets, densestSamples));
|
|
140
|
+
|
|
141
|
+
const accumulators: Accumulator[] = signals.map((signal) => ({
|
|
142
|
+
signal,
|
|
143
|
+
// Int32Array zero-fills, so the sentinels have to be written explicitly: a bucket nothing
|
|
144
|
+
// landed in must be distinguishable from a bucket whose samples were all zero.
|
|
145
|
+
min: new Int32Array(bucketCount).fill(0),
|
|
146
|
+
max: new Int32Array(bucketCount).fill(0),
|
|
147
|
+
counts: new Int32Array(bucketCount),
|
|
148
|
+
consumed: 0,
|
|
149
|
+
outOfRange: 0,
|
|
150
|
+
scratch: undefined,
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
const chunkRecords = scanChunkRecords(header, options?.maxMaterializeBytes);
|
|
154
|
+
let byteLength = 0;
|
|
155
|
+
let firstOnsetTicks: bigint | undefined;
|
|
156
|
+
let lastOnsetTicks: bigint | undefined;
|
|
157
|
+
let scanned = 0;
|
|
158
|
+
|
|
159
|
+
while (scanned < records.count) {
|
|
160
|
+
const slice: RecordRange = {
|
|
161
|
+
start: records.start + scanned,
|
|
162
|
+
count: Math.min(chunkRecords, records.count - scanned),
|
|
163
|
+
};
|
|
164
|
+
const bytes = await readRecordBytes(source, header, slice, options);
|
|
165
|
+
byteLength += bytes.length;
|
|
166
|
+
|
|
167
|
+
// Never strict: a defect in one record must not cost the caller the whole picture.
|
|
168
|
+
const annotations = decodeAnnotations(header, bytes, slice, {
|
|
169
|
+
originTicks: timeline.startOffsetTicks,
|
|
170
|
+
});
|
|
171
|
+
for (const diagnostic of annotations.diagnostics) diagnostics.push(diagnostic);
|
|
172
|
+
const onsets = annotations.recordOnsetTicks;
|
|
173
|
+
if (firstOnsetTicks === undefined) firstOnsetTicks = onsets[0];
|
|
174
|
+
const lastInSlice = onsets[slice.count - 1];
|
|
175
|
+
if (lastInSlice !== undefined) lastOnsetTicks = lastInSlice;
|
|
176
|
+
|
|
177
|
+
for (const accumulator of accumulators) {
|
|
178
|
+
foldChunk(accumulator, header, bytes, slice, records, bucketCount, options);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
scanned += slice.count;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const durationTicks = header.recordDurationTicks;
|
|
185
|
+
const startTicks = (firstOnsetTicks ?? timeline.startOffsetTicks) - timeline.startOffsetTicks;
|
|
186
|
+
const spanTicks =
|
|
187
|
+
firstOnsetTicks !== undefined && lastOnsetTicks !== undefined
|
|
188
|
+
? lastOnsetTicks + durationTicks - firstOnsetTicks
|
|
189
|
+
: durationTicks * BigInt(records.count);
|
|
190
|
+
const startSeconds = ticksToSeconds(startTicks);
|
|
191
|
+
const durationSeconds = ticksToSeconds(spanTicks);
|
|
192
|
+
|
|
193
|
+
const envelopeSignals: EdfEnvelopeSignal[] = accumulators.map((accumulator) => ({
|
|
194
|
+
signalIndex: accumulator.signal.index,
|
|
195
|
+
min: accumulator.min,
|
|
196
|
+
max: accumulator.max,
|
|
197
|
+
counts: accumulator.counts,
|
|
198
|
+
sampleCount: accumulator.consumed,
|
|
199
|
+
firstSampleIndex: records.start * accumulator.signal.samplesPerRecord,
|
|
200
|
+
startSeconds,
|
|
201
|
+
outOfDigitalRangeCount: accumulator.outOfRange,
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
return Object.freeze({
|
|
205
|
+
records,
|
|
206
|
+
startSeconds,
|
|
207
|
+
durationSeconds,
|
|
208
|
+
bucketCount,
|
|
209
|
+
secondsPerBucket: bucketCount > 0 ? durationSeconds / bucketCount : 0,
|
|
210
|
+
byteLength,
|
|
211
|
+
signals: Object.freeze(envelopeSignals),
|
|
212
|
+
precededByGap: undefined,
|
|
213
|
+
diagnostics: Object.freeze(diagnostics),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Folds one chunk of one signal into the buckets.
|
|
219
|
+
*
|
|
220
|
+
* The bucket of a sample is decided by its position on the WHOLE run's grid, not the chunk's, so
|
|
221
|
+
* the chunk size cannot move a sample from one bucket to another. That is the same rule the
|
|
222
|
+
* record scan learned the hard way: chunking bounds memory and must never change the answer.
|
|
223
|
+
*/
|
|
224
|
+
function foldChunk(
|
|
225
|
+
accumulator: Accumulator,
|
|
226
|
+
header: EdfHeader,
|
|
227
|
+
bytes: Uint8Array,
|
|
228
|
+
slice: RecordRange,
|
|
229
|
+
run: RecordRange,
|
|
230
|
+
bucketCount: number,
|
|
231
|
+
options?: ReadOptions,
|
|
232
|
+
): void {
|
|
233
|
+
const totalSamples = accumulator.signal.samplesPerRecord * run.count;
|
|
234
|
+
if (totalSamples === 0) return;
|
|
235
|
+
|
|
236
|
+
const decoded = decodeDigitalCounted(
|
|
237
|
+
header,
|
|
238
|
+
bytes,
|
|
239
|
+
slice,
|
|
240
|
+
accumulator.signal.index,
|
|
241
|
+
accumulator.scratch,
|
|
242
|
+
options,
|
|
243
|
+
);
|
|
244
|
+
// decodeDigitalCounted reuses the buffer when it is large enough, so the allocation happens
|
|
245
|
+
// once per signal per run rather than once per chunk.
|
|
246
|
+
accumulator.scratch = decoded.digital;
|
|
247
|
+
accumulator.outOfRange += decoded.outOfDigitalRangeCount;
|
|
248
|
+
|
|
249
|
+
const samples = decoded.digital;
|
|
250
|
+
// The buffer is reused across chunks, so it can be LONGER than this slice. The slice's own
|
|
251
|
+
// sample count is what must be folded, or the tail of a previous, larger chunk is counted again.
|
|
252
|
+
const sampleCount = accumulator.signal.samplesPerRecord * slice.count;
|
|
253
|
+
const { min, max, counts } = accumulator;
|
|
254
|
+
let position = accumulator.consumed;
|
|
255
|
+
|
|
256
|
+
for (let i = 0; i < sampleCount; i += 1) {
|
|
257
|
+
const value = samples[i] as number;
|
|
258
|
+
// Integer arithmetic on the run grid: floor(position * buckets / totalSamples).
|
|
259
|
+
const bucket = Math.min(bucketCount - 1, Math.floor((position * bucketCount) / totalSamples));
|
|
260
|
+
const seen = counts[bucket] as number;
|
|
261
|
+
if (seen === 0) {
|
|
262
|
+
min[bucket] = value;
|
|
263
|
+
max[bucket] = value;
|
|
264
|
+
} else {
|
|
265
|
+
if (value < (min[bucket] as number)) min[bucket] = value;
|
|
266
|
+
if (value > (max[bucket] as number)) max[bucket] = value;
|
|
267
|
+
}
|
|
268
|
+
counts[bucket] = seen + 1;
|
|
269
|
+
position += 1;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
accumulator.consumed = position;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Converts a digital envelope to physical units.
|
|
277
|
+
*
|
|
278
|
+
* Not `toPhysical` applied twice, and the reason is the sign of the gain. The affine transform
|
|
279
|
+
* `bitValue * (offset + digital)` is DECREASING when `bitValue` is negative — a spec-sanctioned
|
|
280
|
+
* arrangement that edfcore reports rather than rejects — and a decreasing map sends the smallest
|
|
281
|
+
* digital value to the largest physical one. Mapping `min` to `min` would then produce an
|
|
282
|
+
* envelope whose lower bound is above its upper bound, and a viewer would draw it inside out.
|
|
283
|
+
*/
|
|
284
|
+
export function toPhysicalEnvelope(
|
|
285
|
+
signal: EdfSignal,
|
|
286
|
+
envelope: EdfEnvelopeSignal,
|
|
287
|
+
): EdfPhysicalEnvelope {
|
|
288
|
+
const scale = signal.scale;
|
|
289
|
+
if (scale === undefined) {
|
|
290
|
+
throw new EdfScalingError(
|
|
291
|
+
`signal ${signal.index} (${JSON.stringify(signal.label)}) has no usable scale, so its ` +
|
|
292
|
+
'envelope has no physical units. Next: check signal.scale before converting, or plot ' +
|
|
293
|
+
'the digital envelope as it is.',
|
|
294
|
+
{ code: 'SCALE_UNAVAILABLE', signalIndex: signal.index, label: signal.label },
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const length = envelope.min.length;
|
|
299
|
+
const low = new Float64Array(length);
|
|
300
|
+
const high = new Float64Array(length);
|
|
301
|
+
const decreasing = scale.bitValue < 0;
|
|
302
|
+
|
|
303
|
+
for (let i = 0; i < length; i += 1) {
|
|
304
|
+
const a = scale.bitValue * (scale.offset + (envelope.min[i] as number));
|
|
305
|
+
const b = scale.bitValue * (scale.offset + (envelope.max[i] as number));
|
|
306
|
+
low[i] = decreasing ? b : a;
|
|
307
|
+
high[i] = decreasing ? a : b;
|
|
308
|
+
}
|
|
309
|
+
return { min: low, max: high };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The envelope of an already-decoded chunk signal, without another read.
|
|
314
|
+
*
|
|
315
|
+
* For a caller who has samples in hand and wants them plotted: same reduction, same bucket rule,
|
|
316
|
+
* no I/O.
|
|
317
|
+
*/
|
|
318
|
+
export function envelopeOfSamples(chunkSignal: EdfChunkSignal, buckets: number): EdfEnvelopeSignal {
|
|
319
|
+
assertPositiveInteger(buckets, 'buckets');
|
|
320
|
+
const samples = chunkSignal.digital;
|
|
321
|
+
const total = samples.length;
|
|
322
|
+
const bucketCount = Math.max(1, Math.min(buckets, total));
|
|
323
|
+
|
|
324
|
+
const min = new Int32Array(bucketCount);
|
|
325
|
+
const max = new Int32Array(bucketCount);
|
|
326
|
+
const counts = new Int32Array(bucketCount);
|
|
327
|
+
|
|
328
|
+
for (let i = 0; i < total; i += 1) {
|
|
329
|
+
const value = samples[i] as number;
|
|
330
|
+
const bucket = Math.min(bucketCount - 1, Math.floor((i * bucketCount) / total));
|
|
331
|
+
const seen = counts[bucket] as number;
|
|
332
|
+
if (seen === 0) {
|
|
333
|
+
min[bucket] = value;
|
|
334
|
+
max[bucket] = value;
|
|
335
|
+
} else {
|
|
336
|
+
if (value < (min[bucket] as number)) min[bucket] = value;
|
|
337
|
+
if (value > (max[bucket] as number)) max[bucket] = value;
|
|
338
|
+
}
|
|
339
|
+
counts[bucket] = seen + 1;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
signalIndex: chunkSignal.signalIndex,
|
|
344
|
+
min,
|
|
345
|
+
max,
|
|
346
|
+
counts,
|
|
347
|
+
sampleCount: total,
|
|
348
|
+
firstSampleIndex: chunkSignal.firstSampleIndex,
|
|
349
|
+
startSeconds: chunkSignal.startSeconds,
|
|
350
|
+
outOfDigitalRangeCount: chunkSignal.outOfDigitalRangeCount,
|
|
351
|
+
};
|
|
352
|
+
}
|
package/src/header/parse.ts
CHANGED
|
@@ -47,6 +47,9 @@ import { parsePatientId, parseRecordingId } from './identification.js';
|
|
|
47
47
|
import { buildSignals, parseSignalHeaders, signalFieldOffset } from './signals.js';
|
|
48
48
|
import { detectVariant } from './variant.js';
|
|
49
49
|
|
|
50
|
+
/** The largest value a `BigInt64Array` element holds: about 29,000 years of 100 ns ticks. */
|
|
51
|
+
const MAX_REPRESENTABLE_TICKS: bigint = 2n ** 63n - 1n;
|
|
52
|
+
|
|
50
53
|
interface RecordCountResolution {
|
|
51
54
|
readonly recordCount: number;
|
|
52
55
|
readonly recordCountSource: EdfHeader['recordCountSource'];
|
|
@@ -333,6 +336,39 @@ export function parseHeader(
|
|
|
333
336
|
sink,
|
|
334
337
|
);
|
|
335
338
|
|
|
339
|
+
// ---- 9b. A declared span no tick count can hold. ----------------------------------------
|
|
340
|
+
//
|
|
341
|
+
// Every onset array in edfcore is a BigInt64Array, and assignment to one wraps modulo 2^64
|
|
342
|
+
// rather than throwing. Saturating keeps such an array non-decreasing, but it cannot make it
|
|
343
|
+
// meaningful: clamping collapses the spacing between records, so a file declaring a span past
|
|
344
|
+
// the tick range indexes as one segment per record either way. There is no honest onset to
|
|
345
|
+
// report, which is what makes this fatal rather than a warning.
|
|
346
|
+
//
|
|
347
|
+
// Unreachable for a real recording — the bound is over 29,000 years — but `recordDuration` is
|
|
348
|
+
// free-form ASCII and `parseEdfNumber` accepts exponent notation, so `1E30` in an 8-byte field
|
|
349
|
+
// is enough to ask for it.
|
|
350
|
+
const declaredSpanTicks = recordDurationTicks * BigInt(recordCount);
|
|
351
|
+
if (declaredSpanTicks > MAX_REPRESENTABLE_TICKS) {
|
|
352
|
+
throw fatalError({
|
|
353
|
+
code: 'RECORDING_SPAN_UNREPRESENTABLE',
|
|
354
|
+
message:
|
|
355
|
+
`the header declares ${recordCount} records of ${recordDurationSeconds} s, a total span ` +
|
|
356
|
+
`of ${declaredSpanTicks} ticks of 100 ns. edfcore stores every record onset as a signed ` +
|
|
357
|
+
`64-bit tick count, whose largest value is ${MAX_REPRESENTABLE_TICKS} — about 29,000 ` +
|
|
358
|
+
'years — so the later onsets in this file have no representable value and every time it ' +
|
|
359
|
+
'reported would be invented. EDF specification, header (duration of a data record). ' +
|
|
360
|
+
`Next: the record duration lives at offset ${HEADER_FIELDS.recordDuration.offset} and ` +
|
|
361
|
+
`is written as ${JSON.stringify(raw.recordDuration)}; header.raw keeps it as written.`,
|
|
362
|
+
field: 'recordDuration',
|
|
363
|
+
byteOffset: HEADER_FIELDS.recordDuration.offset,
|
|
364
|
+
byteLength: HEADER_FIELDS.recordDuration.length,
|
|
365
|
+
raw: raw.recordDuration,
|
|
366
|
+
expected: `a span of at most ${MAX_REPRESENTABLE_TICKS} ticks`,
|
|
367
|
+
actual: `${declaredSpanTicks} ticks`,
|
|
368
|
+
specReference: 'EDF specification, header (duration of a data record)',
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
336
372
|
// ---- 10. EDF+ without an annotations signal has no per-record timing to report. ---------
|
|
337
373
|
if (variant.isPlus && parsed.annotationSignalIndices.length === 0) {
|
|
338
374
|
throw fatalError({
|
package/src/index.ts
CHANGED
|
@@ -43,12 +43,15 @@ export type {
|
|
|
43
43
|
EdfClockTime,
|
|
44
44
|
EdfDiagnostic,
|
|
45
45
|
EdfDiagnosticCode,
|
|
46
|
+
EdfEnvelopeChunk,
|
|
47
|
+
EdfEnvelopeSignal,
|
|
46
48
|
EdfGap,
|
|
47
49
|
EdfHeader,
|
|
48
50
|
EdfInspection,
|
|
49
51
|
EdfKnownDiagnosticCode,
|
|
50
52
|
EdfLocation,
|
|
51
53
|
EdfPatientId,
|
|
54
|
+
EdfPhysicalEnvelope,
|
|
52
55
|
EdfRawHeaderFields,
|
|
53
56
|
EdfRawSignalFields,
|
|
54
57
|
EdfRecordIndex,
|
|
@@ -61,6 +64,7 @@ export type {
|
|
|
61
64
|
EdfStartTime,
|
|
62
65
|
EdfTimeline,
|
|
63
66
|
EdfVariant,
|
|
67
|
+
EnvelopeSelection,
|
|
64
68
|
FetchLike,
|
|
65
69
|
HttpResponseLike,
|
|
66
70
|
HttpSourceOptions,
|
|
@@ -147,5 +151,6 @@ export { buildRecordIndex, buildTimeline } from './record-index.js';
|
|
|
147
151
|
// Convenience layer
|
|
148
152
|
// ===========================================================================
|
|
149
153
|
|
|
154
|
+
export { envelopeOfSamples, readEnvelope, toPhysicalEnvelope } from './envelope.js';
|
|
150
155
|
export { inspectEdf } from './inspect.js';
|
|
151
156
|
export { openEdf, readAnnotations, readRecords, readWindow } from './recording.js';
|
package/src/record-index.ts
CHANGED
|
@@ -27,10 +27,11 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { DEFAULT_MAX_MATERIALIZE_BYTES } from './constants.js';
|
|
30
|
+
import { appendDiagnostics } from './diagnostics/collector.js';
|
|
30
31
|
import { EdfRangeError } from './errors.js';
|
|
31
32
|
import { readRecordBytes } from './io/read.js';
|
|
32
33
|
import { decodeAnnotations } from './tal/annotations.js';
|
|
33
|
-
import { secondsToTicks, ticksToSeconds } from './tal/ticks.js';
|
|
34
|
+
import { saturateToInt64, secondsToTicks, ticksToSeconds } from './tal/ticks.js';
|
|
34
35
|
import { buildSegmentation } from './time/segments.js';
|
|
35
36
|
import {
|
|
36
37
|
assertMonotonicOnsetArray,
|
|
@@ -81,7 +82,13 @@ export function scanChunkRecords(header: EdfHeader, maxMaterializeBytes?: number
|
|
|
81
82
|
* definition for a plain EDF or BDF file, where record onsets are not stored at all.
|
|
82
83
|
*/
|
|
83
84
|
function nominalOnsetTicks(header: EdfHeader, recordIndex: number): bigint {
|
|
84
|
-
|
|
85
|
+
// Saturated, because every onset array here is a BigInt64Array and assignment to one wraps
|
|
86
|
+
// rather than throwing. A file with no annotation channel gets its onsets purely from this
|
|
87
|
+
// arithmetic, so an overflowing recordDuration produced an array that jumped backwards — read
|
|
88
|
+
// downstream as one segment per record with negative gaps, reported as a 'complete' index with
|
|
89
|
+
// no diagnostic at all. decodeAnnotations already saturated its own derived onsets; this path
|
|
90
|
+
// was the one that did not.
|
|
91
|
+
return saturateToInt64(BigInt(recordIndex) * header.recordDurationTicks);
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
/** True when the file stores per-record onsets, i.e. when probing can learn anything. */
|
|
@@ -280,7 +287,7 @@ export async function buildTimeline(
|
|
|
280
287
|
// fake a discontinuity in a conforming file and make readWindow refuse every window in it.
|
|
281
288
|
const probe = await probeOnset(source, header, recordIndex, probeOptions(options, memo.get(0)));
|
|
282
289
|
memo.set(recordIndex, probe.ticks);
|
|
283
|
-
probeDiagnostics
|
|
290
|
+
appendDiagnostics(probeDiagnostics, probe.diagnostics);
|
|
284
291
|
probes.push({ recordIndex, onsetTicks: probe.ticks });
|
|
285
292
|
}
|
|
286
293
|
|
package/src/tal/annotations.ts
CHANGED
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
type TalIssueCode,
|
|
55
55
|
type TalTextEncoding,
|
|
56
56
|
} from './grammar.js';
|
|
57
|
-
import { ticksToSeconds } from './ticks.js';
|
|
57
|
+
import { saturateToInt64, ticksToSeconds } from './ticks.js';
|
|
58
58
|
|
|
59
59
|
const ANNOTATIONS_SPEC = "EDF+ specification 2.2 (the 'EDF Annotations' signal)";
|
|
60
60
|
const TIMEKEEPING_SPEC = 'EDF+ specification 2.2.1 (time keeping of data records)';
|
|
@@ -133,20 +133,6 @@ interface ObservedOnset {
|
|
|
133
133
|
readonly raw: string;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
/**
|
|
137
|
-
* A `BigInt64Array` wraps silently on assignment, so a derived onset is saturated rather than
|
|
138
|
-
* wrapped. Reaching either bound needs a declared geometry that is already impossible (~29,000
|
|
139
|
-
* years of records); saturating keeps the array non-decreasing where wrapping would invert it.
|
|
140
|
-
*/
|
|
141
|
-
const INT64_MIN: bigint = -(2n ** 63n);
|
|
142
|
-
const INT64_MAX: bigint = 2n ** 63n - 1n;
|
|
143
|
-
|
|
144
|
-
function saturateToInt64(ticks: bigint): bigint {
|
|
145
|
-
if (ticks > INT64_MAX) return INT64_MAX;
|
|
146
|
-
if (ticks < INT64_MIN) return INT64_MIN;
|
|
147
|
-
return ticks;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
136
|
function describeRange(range: RecordRange): string {
|
|
151
137
|
return `{ start: ${range.start}, count: ${range.count} }`;
|
|
152
138
|
}
|
package/src/tal/ticks.ts
CHANGED
|
@@ -150,6 +150,29 @@ export function ticksToSeconds(ticks: bigint): number {
|
|
|
150
150
|
return Number(wholeSeconds) + Number(remainder) / TICKS_PER_SECOND_FLOAT;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
const INT64_MIN: bigint = -(2n ** 63n);
|
|
154
|
+
const INT64_MAX: bigint = 2n ** 63n - 1n;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Clamps a tick count to what a `BigInt64Array` element can hold.
|
|
158
|
+
*
|
|
159
|
+
* Assignment to a `BigInt64Array` wraps modulo 2^64 rather than throwing, and every onset array
|
|
160
|
+
* in edfcore is one. Wrapping turns a monotonically increasing series into one that jumps
|
|
161
|
+
* backwards, which downstream code reads as a genuine discontinuity: a file whose declared
|
|
162
|
+
* record duration overflows the range then indexes as one segment per record, with negative
|
|
163
|
+
* gaps between them and no diagnostic anywhere.
|
|
164
|
+
*
|
|
165
|
+
* Saturating keeps the array non-decreasing, so an absurd geometry stays visibly absurd instead
|
|
166
|
+
* of becoming plausibly wrong. Reaching either bound needs a declared geometry that is already
|
|
167
|
+
* impossible — over 29,000 years of records — but `recordDuration` is a free-form ASCII field
|
|
168
|
+
* that accepts exponent notation, so three bytes are enough to ask for it.
|
|
169
|
+
*/
|
|
170
|
+
export function saturateToInt64(ticks: bigint): bigint {
|
|
171
|
+
if (ticks > INT64_MAX) return INT64_MAX;
|
|
172
|
+
if (ticks < INT64_MIN) return INT64_MIN;
|
|
173
|
+
return ticks;
|
|
174
|
+
}
|
|
175
|
+
|
|
153
176
|
/**
|
|
154
177
|
* Seconds to ticks, rounded to the NEAREST tick (ties away from zero is not required; ties go
|
|
155
178
|
* toward +Infinity, as `Math.round` does).
|
package/src/types.ts
CHANGED
|
@@ -367,6 +367,59 @@ export interface EdfChunk {
|
|
|
367
367
|
readonly diagnostics: readonly EdfDiagnostic[];
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
+
/**
|
|
371
|
+
* One signal's min/max envelope over a window, at a resolution the caller chose.
|
|
372
|
+
*
|
|
373
|
+
* The unit is the bucket, not the sample: `min[i]` and `max[i]` are the extremes of every sample
|
|
374
|
+
* that fell in bucket `i`. Drawing a twelve-hour recording into a thousand pixels needs exactly
|
|
375
|
+
* this and nothing else — the peaks are what a reader of an EEG trace is looking at, and they
|
|
376
|
+
* are the first thing naive subsampling throws away.
|
|
377
|
+
*/
|
|
378
|
+
export interface EdfEnvelopeSignal {
|
|
379
|
+
readonly signalIndex: number;
|
|
380
|
+
/** Digital extremes per bucket. Convert with `toPhysicalEnvelope`, never with `toPhysical`. */
|
|
381
|
+
readonly min: Int32Array;
|
|
382
|
+
readonly max: Int32Array;
|
|
383
|
+
/** Samples that landed in each bucket. Zero where the window had no samples to cover it. */
|
|
384
|
+
readonly counts: Int32Array;
|
|
385
|
+
/** Total samples reduced, i.e. the sum of `counts`. */
|
|
386
|
+
readonly sampleCount: number;
|
|
387
|
+
readonly firstSampleIndex: number;
|
|
388
|
+
readonly startSeconds: number;
|
|
389
|
+
readonly outOfDigitalRangeCount: number;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** A contiguous run of records, reduced to buckets. One per run, exactly as `readWindow` splits. */
|
|
393
|
+
export interface EdfEnvelopeChunk {
|
|
394
|
+
readonly records: RecordRange;
|
|
395
|
+
readonly startSeconds: number;
|
|
396
|
+
readonly durationSeconds: number;
|
|
397
|
+
/** Buckets actually filled. Never more than requested, and fewer for a short run. */
|
|
398
|
+
readonly bucketCount: number;
|
|
399
|
+
readonly secondsPerBucket: number;
|
|
400
|
+
readonly byteLength: number;
|
|
401
|
+
readonly signals: readonly EdfEnvelopeSignal[];
|
|
402
|
+
readonly precededByGap: EdfGap | undefined;
|
|
403
|
+
readonly diagnostics: readonly EdfDiagnostic[];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export interface EnvelopeSelection extends WindowSelection {
|
|
407
|
+
/**
|
|
408
|
+
* How many buckets to reduce the window into — in a viewer, the pixel width of the plot.
|
|
409
|
+
*
|
|
410
|
+
* A bucket per pixel is the point: asking for more buckets than the window has samples wastes
|
|
411
|
+
* work and yields empty buckets, so the count is clamped to the sample count of the densest
|
|
412
|
+
* signal in the run.
|
|
413
|
+
*/
|
|
414
|
+
readonly buckets: number;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** A physical-unit envelope. Separate from the digital one for the same reason `toPhysical` is. */
|
|
418
|
+
export interface EdfPhysicalEnvelope {
|
|
419
|
+
readonly min: Float64Array;
|
|
420
|
+
readonly max: Float64Array;
|
|
421
|
+
}
|
|
422
|
+
|
|
370
423
|
export interface EdfAnnotation {
|
|
371
424
|
/** Verbatim on-disk value, relative to the header startdate/starttime (EDF+ 2.2.4). */
|
|
372
425
|
readonly onsetSecondsFromHeaderStart: number;
|
package/src/validate.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { trimEdfField } from './bytes/latin1.js';
|
|
22
22
|
import { DEFAULT_MAX_MATERIALIZE_BYTES, EDF_RECOMMENDED_MAX_RECORD_BYTES } from './constants.js';
|
|
23
23
|
import { decodeDigitalCounted } from './decode/digital.js';
|
|
24
|
-
import { createDiagnostic } from './diagnostics/collector.js';
|
|
24
|
+
import { appendDiagnostics, createDiagnostic } from './diagnostics/collector.js';
|
|
25
25
|
import { EdfBudgetError } from './errors.js';
|
|
26
26
|
import { calendarDatesEqual, formatCalendarDate, isValidCalendarDate } from './header/dates.js';
|
|
27
27
|
import { signalFieldOffset } from './header/signals.js';
|
|
@@ -534,7 +534,7 @@ async function traverse(
|
|
|
534
534
|
originTicks: recording.timeline.startOffsetTicks,
|
|
535
535
|
});
|
|
536
536
|
onsets.set(decoded.recordOnsetTicks, records.start);
|
|
537
|
-
diagnostics
|
|
537
|
+
appendDiagnostics(diagnostics, decoded.diagnostics);
|
|
538
538
|
|
|
539
539
|
for (const accumulator of accumulators) {
|
|
540
540
|
const digital = decodeDigitalCounted(
|
|
@@ -604,7 +604,7 @@ export async function validateRecording(
|
|
|
604
604
|
const mustReadOnsets = supplied === undefined && !onsetsAreArithmetic;
|
|
605
605
|
const traversal =
|
|
606
606
|
scanSamples || mustReadOnsets ? await traverse(recording, options, scanSamples) : undefined;
|
|
607
|
-
if (traversal !== undefined) diagnostics
|
|
607
|
+
if (traversal !== undefined) appendDiagnostics(diagnostics, traversal.diagnostics);
|
|
608
608
|
|
|
609
609
|
let segmentCount: number;
|
|
610
610
|
let gaps: readonly EdfGap[];
|