edfcore 0.1.0
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/LICENSE +21 -0
- package/README.md +319 -0
- package/dist/bytes/latin1.d.ts +43 -0
- package/dist/bytes/latin1.d.ts.map +1 -0
- package/dist/bytes/latin1.js +84 -0
- package/dist/bytes/latin1.js.map +1 -0
- package/dist/bytes/numbers.d.ts +45 -0
- package/dist/bytes/numbers.d.ts.map +1 -0
- package/dist/bytes/numbers.js +87 -0
- package/dist/bytes/numbers.js.map +1 -0
- package/dist/bytes/view.d.ts +27 -0
- package/dist/bytes/view.d.ts.map +1 -0
- package/dist/bytes/view.js +53 -0
- package/dist/bytes/view.js.map +1 -0
- package/dist/constants.d.ts +113 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +83 -0
- package/dist/constants.js.map +1 -0
- package/dist/decode/digital.d.ts +49 -0
- package/dist/decode/digital.d.ts.map +1 -0
- package/dist/decode/digital.js +180 -0
- package/dist/decode/digital.js.map +1 -0
- package/dist/decode/physical.d.ts +37 -0
- package/dist/decode/physical.d.ts.map +1 -0
- package/dist/decode/physical.js +178 -0
- package/dist/decode/physical.js.map +1 -0
- package/dist/diagnostics/codes.d.ts +38 -0
- package/dist/diagnostics/codes.d.ts.map +1 -0
- package/dist/diagnostics/codes.js +134 -0
- package/dist/diagnostics/codes.js.map +1 -0
- package/dist/diagnostics/collector.d.ts +76 -0
- package/dist/diagnostics/collector.d.ts.map +1 -0
- package/dist/diagnostics/collector.js +95 -0
- package/dist/diagnostics/collector.js.map +1 -0
- package/dist/diagnostics/format.d.ts +21 -0
- package/dist/diagnostics/format.d.ts.map +1 -0
- package/dist/diagnostics/format.js +125 -0
- package/dist/diagnostics/format.js.map +1 -0
- package/dist/errors.d.ts +143 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +135 -0
- package/dist/errors.js.map +1 -0
- package/dist/header/dates.d.ts +101 -0
- package/dist/header/dates.d.ts.map +1 -0
- package/dist/header/dates.js +378 -0
- package/dist/header/dates.js.map +1 -0
- package/dist/header/fields.d.ts +105 -0
- package/dist/header/fields.d.ts.map +1 -0
- package/dist/header/fields.js +363 -0
- package/dist/header/fields.js.map +1 -0
- package/dist/header/identification.d.ts +47 -0
- package/dist/header/identification.d.ts.map +1 -0
- package/dist/header/identification.js +156 -0
- package/dist/header/identification.js.map +1 -0
- package/dist/header/lookup.d.ts +33 -0
- package/dist/header/lookup.d.ts.map +1 -0
- package/dist/header/lookup.js +67 -0
- package/dist/header/lookup.js.map +1 -0
- package/dist/header/parse.d.ts +32 -0
- package/dist/header/parse.d.ts.map +1 -0
- package/dist/header/parse.js +319 -0
- package/dist/header/parse.js.map +1 -0
- package/dist/header/scale.d.ts +68 -0
- package/dist/header/scale.d.ts.map +1 -0
- package/dist/header/scale.js +154 -0
- package/dist/header/scale.js.map +1 -0
- package/dist/header/signals.d.ts +64 -0
- package/dist/header/signals.d.ts.map +1 -0
- package/dist/header/signals.js +374 -0
- package/dist/header/signals.js.map +1 -0
- package/dist/header/variant.d.ts +67 -0
- package/dist/header/variant.d.ts.map +1 -0
- package/dist/header/variant.js +180 -0
- package/dist/header/variant.js.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect.d.ts +31 -0
- package/dist/inspect.d.ts.map +1 -0
- package/dist/inspect.js +165 -0
- package/dist/inspect.js.map +1 -0
- package/dist/io/blob.d.ts +14 -0
- package/dist/io/blob.d.ts.map +1 -0
- package/dist/io/blob.js +29 -0
- package/dist/io/blob.js.map +1 -0
- package/dist/io/bytes.d.ts +11 -0
- package/dist/io/bytes.d.ts.map +1 -0
- package/dist/io/bytes.js +26 -0
- package/dist/io/bytes.js.map +1 -0
- package/dist/io/cached.d.ts +19 -0
- package/dist/io/cached.d.ts.map +1 -0
- package/dist/io/cached.js +129 -0
- package/dist/io/cached.js.map +1 -0
- package/dist/io/http.d.ts +20 -0
- package/dist/io/http.d.ts.map +1 -0
- package/dist/io/http.js +225 -0
- package/dist/io/http.js.map +1 -0
- package/dist/io/read.d.ts +42 -0
- package/dist/io/read.d.ts.map +1 -0
- package/dist/io/read.js +119 -0
- package/dist/io/read.js.map +1 -0
- package/dist/io/source.d.ts +33 -0
- package/dist/io/source.d.ts.map +1 -0
- package/dist/io/source.js +72 -0
- package/dist/io/source.js.map +1 -0
- package/dist/node.d.ts +70 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +109 -0
- package/dist/node.js.map +1 -0
- package/dist/record-index.d.ts +58 -0
- package/dist/record-index.d.ts.map +1 -0
- package/dist/record-index.js +290 -0
- package/dist/record-index.js.map +1 -0
- package/dist/recording.d.ts +57 -0
- package/dist/recording.d.ts.map +1 -0
- package/dist/recording.js +178 -0
- package/dist/recording.js.map +1 -0
- package/dist/tal/annotations.d.ts +36 -0
- package/dist/tal/annotations.d.ts.map +1 -0
- package/dist/tal/annotations.js +437 -0
- package/dist/tal/annotations.js.map +1 -0
- package/dist/tal/grammar.d.ts +111 -0
- package/dist/tal/grammar.d.ts.map +1 -0
- package/dist/tal/grammar.js +324 -0
- package/dist/tal/grammar.js.map +1 -0
- package/dist/tal/ticks.d.ts +69 -0
- package/dist/tal/ticks.d.ts.map +1 -0
- package/dist/tal/ticks.js +137 -0
- package/dist/tal/ticks.js.map +1 -0
- package/dist/time/segments.d.ts +33 -0
- package/dist/time/segments.d.ts.map +1 -0
- package/dist/time/segments.js +87 -0
- package/dist/time/segments.js.map +1 -0
- package/dist/time/timeline.d.ts +62 -0
- package/dist/time/timeline.d.ts.map +1 -0
- package/dist/time/timeline.js +227 -0
- package/dist/time/timeline.js.map +1 -0
- package/dist/time/window.d.ts +54 -0
- package/dist/time/window.d.ts.map +1 -0
- package/dist/time/window.js +232 -0
- package/dist/time/window.js.map +1 -0
- package/dist/types.d.ts +409 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +53 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +483 -0
- package/dist/validate.js.map +1 -0
- package/package.json +77 -0
- package/src/bytes/latin1.ts +87 -0
- package/src/bytes/numbers.ts +115 -0
- package/src/bytes/view.ts +59 -0
- package/src/constants.ts +96 -0
- package/src/decode/digital.ts +258 -0
- package/src/decode/physical.ts +235 -0
- package/src/diagnostics/codes.ts +209 -0
- package/src/diagnostics/collector.ts +129 -0
- package/src/diagnostics/format.ts +143 -0
- package/src/errors.ts +208 -0
- package/src/header/dates.ts +453 -0
- package/src/header/fields.ts +455 -0
- package/src/header/identification.ts +196 -0
- package/src/header/lookup.ts +81 -0
- package/src/header/parse.ts +398 -0
- package/src/header/scale.ts +268 -0
- package/src/header/signals.ts +550 -0
- package/src/header/variant.ts +239 -0
- package/src/index.ts +151 -0
- package/src/inspect.ts +181 -0
- package/src/io/blob.ts +31 -0
- package/src/io/bytes.ts +29 -0
- package/src/io/cached.ts +136 -0
- package/src/io/http.ts +299 -0
- package/src/io/read.ts +152 -0
- package/src/io/source.ts +87 -0
- package/src/node.ts +138 -0
- package/src/record-index.ts +389 -0
- package/src/recording.ts +234 -0
- package/src/tal/annotations.ts +574 -0
- package/src/tal/grammar.ts +540 -0
- package/src/tal/ticks.ts +172 -0
- package/src/time/segments.ts +109 -0
- package/src/time/timeline.ts +290 -0
- package/src/time/window.ts +304 -0
- package/src/types.ts +490 -0
- package/src/validate.ts +610 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Record onsets to segments and gaps.
|
|
3
|
+
*
|
|
4
|
+
* Layer 4. Pure and synchronous, and structural only: it reports the shape the onsets actually
|
|
5
|
+
* have and judges none of it. Monotonicity and the spacing rules belong to `time/timeline.ts`,
|
|
6
|
+
* which is their sole owner — run `assertMonotonicOnsetArray` on the same array first, and a gap
|
|
7
|
+
* can then only have a non-negative duration.
|
|
8
|
+
*
|
|
9
|
+
* Only `buildRecordIndex` calls this, because only a complete traversal has every onset. The
|
|
10
|
+
* boundary rule is the one edfcore states everywhere else: a new segment starts wherever
|
|
11
|
+
* `onset[r] !== onset[r - 1] + recordDurationTicks`, in exact ticks. Not "differs by more than an
|
|
12
|
+
* epsilon" — a float tolerance is how a one-sample overlap becomes invisible.
|
|
13
|
+
*
|
|
14
|
+
* Every second here is elapsed recording time, measured from record 0's start (see
|
|
15
|
+
* `time/timeline.ts`), so `segment.startTicks` is the rebased value and
|
|
16
|
+
* `ticksToSeconds(segment.startTicks) === segment.startSeconds` holds by construction.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ticksToSeconds } from '../tal/ticks.js';
|
|
20
|
+
import type { EdfGap, EdfSegment } from '../types.js';
|
|
21
|
+
|
|
22
|
+
export interface Segmentation {
|
|
23
|
+
/** In time order, contiguous within each entry, and covering every record exactly once. */
|
|
24
|
+
readonly segments: readonly EdfSegment[];
|
|
25
|
+
/** One per adjacent pair of segments, so `gaps.length === segments.length - 1` (or 0). */
|
|
26
|
+
readonly gaps: readonly EdfGap[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const EMPTY_SEGMENTATION: Segmentation = Object.freeze({
|
|
30
|
+
segments: Object.freeze([]),
|
|
31
|
+
gaps: Object.freeze([]),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** A segment's two ends in exact ticks, kept while building so gaps never re-derive them. */
|
|
35
|
+
interface SegmentBounds {
|
|
36
|
+
readonly startTicks: bigint;
|
|
37
|
+
readonly endTicks: bigint;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `onsetTicks` holds one entry per record, record 0 first — the `BigInt64Array` that
|
|
42
|
+
* `decodeAnnotations` returns for a full-file range fits directly.
|
|
43
|
+
*
|
|
44
|
+
* `originTicks` is the tick value that maps to `0` seconds. It defaults to `onsetTicks[0]`,
|
|
45
|
+
* which is record 0's own onset and therefore the recording's sub-second start offset.
|
|
46
|
+
*/
|
|
47
|
+
export function buildSegmentation(
|
|
48
|
+
onsetTicks: ArrayLike<bigint>,
|
|
49
|
+
recordDurationTicks: bigint,
|
|
50
|
+
originTicks?: bigint,
|
|
51
|
+
): Segmentation {
|
|
52
|
+
const recordCount = onsetTicks.length;
|
|
53
|
+
if (recordCount === 0) return EMPTY_SEGMENTATION;
|
|
54
|
+
|
|
55
|
+
const firstOnset = onsetTicks[0];
|
|
56
|
+
if (firstOnset === undefined) return EMPTY_SEGMENTATION;
|
|
57
|
+
const origin = originTicks ?? firstOnset;
|
|
58
|
+
|
|
59
|
+
const segments: EdfSegment[] = [];
|
|
60
|
+
const bounds: SegmentBounds[] = [];
|
|
61
|
+
|
|
62
|
+
let segmentStart = 0;
|
|
63
|
+
let segmentStartTicks = firstOnset;
|
|
64
|
+
|
|
65
|
+
const closeSegment = (endExclusive: number): void => {
|
|
66
|
+
const count = endExclusive - segmentStart;
|
|
67
|
+
const startTicks = segmentStartTicks - origin;
|
|
68
|
+
const durationTicks = BigInt(count) * recordDurationTicks;
|
|
69
|
+
const endTicks = startTicks + durationTicks;
|
|
70
|
+
segments.push({
|
|
71
|
+
index: segments.length,
|
|
72
|
+
records: { start: segmentStart, count },
|
|
73
|
+
startSeconds: ticksToSeconds(startTicks),
|
|
74
|
+
startTicks,
|
|
75
|
+
durationSeconds: ticksToSeconds(durationTicks),
|
|
76
|
+
endSeconds: ticksToSeconds(endTicks),
|
|
77
|
+
});
|
|
78
|
+
bounds.push({ startTicks, endTicks });
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
for (let record = 1; record < recordCount; record += 1) {
|
|
82
|
+
const previous = onsetTicks[record - 1];
|
|
83
|
+
const current = onsetTicks[record];
|
|
84
|
+
// A missing entry cannot happen for a BigInt64Array of this length; skipping rather than
|
|
85
|
+
// asserting keeps a hand-built ArrayLike from turning a structural walk into a throw.
|
|
86
|
+
if (previous === undefined || current === undefined) continue;
|
|
87
|
+
if (current === previous + recordDurationTicks) continue;
|
|
88
|
+
closeSegment(record);
|
|
89
|
+
segmentStart = record;
|
|
90
|
+
segmentStartTicks = current;
|
|
91
|
+
}
|
|
92
|
+
closeSegment(recordCount);
|
|
93
|
+
|
|
94
|
+
const gaps: EdfGap[] = [];
|
|
95
|
+
for (let index = 1; index < bounds.length; index += 1) {
|
|
96
|
+
const before = bounds[index - 1];
|
|
97
|
+
const after = bounds[index];
|
|
98
|
+
if (before === undefined || after === undefined) continue;
|
|
99
|
+
gaps.push({
|
|
100
|
+
beforeSegmentIndex: index - 1,
|
|
101
|
+
afterSegmentIndex: index,
|
|
102
|
+
startSeconds: ticksToSeconds(before.endTicks),
|
|
103
|
+
endSeconds: ticksToSeconds(after.startTicks),
|
|
104
|
+
durationSeconds: ticksToSeconds(after.startTicks - before.endTicks),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { segments: Object.freeze(segments), gaps: Object.freeze(gaps) };
|
|
109
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recording's time axis, built from probed record onsets.
|
|
3
|
+
*
|
|
4
|
+
* Layer 4. Pure and synchronous: it is handed onsets that someone else read, and it decides what
|
|
5
|
+
* they mean. Sole owner of what makes a timeline valid — `tal/annotations.ts` produces
|
|
6
|
+
* `recordOnsetTicks` and deliberately passes no judgement on it, so monotonicity and
|
|
7
|
+
* record-onset spacing are enforced here and nowhere else.
|
|
8
|
+
*
|
|
9
|
+
* TIME AXIS, fixed here for the whole library: `t = 0` is the START OF RECORD 0, not the header
|
|
10
|
+
* start time. Record 0's timekeeping onset is `startOffsetTicks` — in [0, 1) s — and it is the
|
|
11
|
+
* bridge back to the header clock. Every other second edfcore reports (segment, gap, chunk,
|
|
12
|
+
* window bound) is elapsed recording time. This is the EDFlib/pyEDFlib/MNE convention, and it is
|
|
13
|
+
* the one under which sample `n` of a signal sits at exactly
|
|
14
|
+
* `n * recordDuration / samplesPerRecord` with no sub-second constant to remember.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { TICKS_PER_SECOND } from '../constants.js';
|
|
18
|
+
import { DiagnosticSink, fatalError } from '../diagnostics/collector.js';
|
|
19
|
+
import { ticksToSeconds } from '../tal/ticks.js';
|
|
20
|
+
import type { EdfDiagnostic, EdfHeader, EdfTimeline, ParseOptions } from '../types.js';
|
|
21
|
+
|
|
22
|
+
const TIMEKEEPING_SPEC = 'EDF+ specification 2.2.1 (time keeping of data records)';
|
|
23
|
+
|
|
24
|
+
/** One record onset as observed on disk: which record, and its exact timekeeping value. */
|
|
25
|
+
export interface RecordOnsetProbe {
|
|
26
|
+
readonly recordIndex: number;
|
|
27
|
+
/** Verbatim, relative to the header start time — the axis rebasing has not happened yet. */
|
|
28
|
+
readonly onsetTicks: bigint;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TimelineInput {
|
|
32
|
+
readonly header: EdfHeader;
|
|
33
|
+
/**
|
|
34
|
+
* Ascending by `recordIndex`, first entry record 0 and last entry record `recordCount - 1`.
|
|
35
|
+
* In practice the two probes `buildTimeline` reads; a single-record file supplies one entry
|
|
36
|
+
* that is both. Empty exactly when the file has no records.
|
|
37
|
+
*/
|
|
38
|
+
readonly probes: readonly RecordOnsetProbe[];
|
|
39
|
+
/**
|
|
40
|
+
* Diagnostics from decoding the probed records, folded into `timeline.diagnostics` so one
|
|
41
|
+
* array explains the whole timeline. A `START_OFFSET_OUT_OF_RANGE` already present here is not
|
|
42
|
+
* repeated below.
|
|
43
|
+
*/
|
|
44
|
+
readonly probeDiagnostics?: readonly EdfDiagnostic[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fatal, at any observed pair: a later record starting earlier than an earlier one makes every
|
|
49
|
+
* time-based answer for the file wrong, so edfcore refuses to derive any of them.
|
|
50
|
+
*
|
|
51
|
+
* Equal onsets are not a violation — a file with `recordDuration = 0` has every record at the
|
|
52
|
+
* same instant legally, and an insufficient advance is spacing, which is a warning.
|
|
53
|
+
*/
|
|
54
|
+
export function assertMonotonicOnsets(earlier: RecordOnsetProbe, later: RecordOnsetProbe): void {
|
|
55
|
+
if (later.onsetTicks >= earlier.onsetTicks) return;
|
|
56
|
+
throw fatalError({
|
|
57
|
+
code: 'TIMELINE_NOT_MONOTONIC',
|
|
58
|
+
message:
|
|
59
|
+
`record ${later.recordIndex} starts at ${ticksToSeconds(later.onsetTicks)} s, before ` +
|
|
60
|
+
`record ${earlier.recordIndex} at ${ticksToSeconds(earlier.onsetTicks)} s ` +
|
|
61
|
+
`(${later.onsetTicks} against ${earlier.onsetTicks} ticks of 100 ns). ` +
|
|
62
|
+
'Rule: record onsets never decrease, in EDF+C or EDF+D — the timekeeping TAL of record r ' +
|
|
63
|
+
"is that record's start relative to the header start time. " +
|
|
64
|
+
'Next: every time-based answer for this file would be wrong, so edfcore derives none of ' +
|
|
65
|
+
'them; read by record index with readRecords(), or repair the timekeeping TALs.',
|
|
66
|
+
field: 'timekeeping TAL',
|
|
67
|
+
expected: `onset >= ${earlier.onsetTicks} ticks`,
|
|
68
|
+
actual: `${later.onsetTicks} ticks`,
|
|
69
|
+
recordIndex: later.recordIndex,
|
|
70
|
+
specReference: TIMEKEEPING_SPEC,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `assertMonotonicOnsets` across a full onset array, which is what `buildRecordIndex` has.
|
|
76
|
+
*
|
|
77
|
+
* `firstRecordIndex` is the record `onsetTicks[0]` belongs to, so the diagnostic names the record
|
|
78
|
+
* in the file rather than a position in the array.
|
|
79
|
+
*/
|
|
80
|
+
export function assertMonotonicOnsetArray(
|
|
81
|
+
onsetTicks: ArrayLike<bigint>,
|
|
82
|
+
firstRecordIndex: number = 0,
|
|
83
|
+
): void {
|
|
84
|
+
for (let position = 1; position < onsetTicks.length; position += 1) {
|
|
85
|
+
const previous = onsetTicks[position - 1];
|
|
86
|
+
const current = onsetTicks[position];
|
|
87
|
+
if (previous === undefined || current === undefined) continue;
|
|
88
|
+
assertMonotonicOnsets(
|
|
89
|
+
{ recordIndex: firstRecordIndex + position - 1, onsetTicks: previous },
|
|
90
|
+
{ recordIndex: firstRecordIndex + position, onsetTicks: current },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The probe array has to describe the whole file, because `spanSeconds` is defined by its two
|
|
97
|
+
* ends. A `RangeError` rather than an `EdfFormatError`: nothing here is the file's fault.
|
|
98
|
+
*/
|
|
99
|
+
function assertProbeShape(probes: readonly RecordOnsetProbe[], recordCount: number): void {
|
|
100
|
+
if (recordCount === 0) {
|
|
101
|
+
if (probes.length === 0) return;
|
|
102
|
+
throw new RangeError(
|
|
103
|
+
`buildTimelineFromProbes() received ${probes.length} onset probes for a file with no data ` +
|
|
104
|
+
'records. Next: pass an empty probes array when header.recordCount is 0.',
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const first = probes[0];
|
|
109
|
+
const last = probes[probes.length - 1];
|
|
110
|
+
if (first === undefined || last === undefined) {
|
|
111
|
+
throw new RangeError(
|
|
112
|
+
`buildTimelineFromProbes() received no onset probes for a file with ${recordCount} data ` +
|
|
113
|
+
'records, so neither the start offset nor the span is known. Next: probe record 0 and ' +
|
|
114
|
+
`record ${recordCount - 1} and pass both.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (first.recordIndex !== 0 || last.recordIndex !== recordCount - 1) {
|
|
118
|
+
throw new RangeError(
|
|
119
|
+
`buildTimelineFromProbes() received probes for records ${first.recordIndex}..` +
|
|
120
|
+
`${last.recordIndex}, but the start offset comes from record 0 and the span ends at ` +
|
|
121
|
+
`record ${recordCount - 1}. Next: probe both ends of the file; intermediate probes are ` +
|
|
122
|
+
'optional.',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
for (let i = 1; i < probes.length; i += 1) {
|
|
126
|
+
const previous = probes[i - 1];
|
|
127
|
+
const probe = probes[i];
|
|
128
|
+
if (previous === undefined || probe === undefined) continue;
|
|
129
|
+
if (probe.recordIndex <= previous.recordIndex) {
|
|
130
|
+
throw new RangeError(
|
|
131
|
+
`buildTimelineFromProbes() received probes out of order: record ${probe.recordIndex} ` +
|
|
132
|
+
`follows record ${previous.recordIndex}. Next: sort the probes by record index.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function reportStartOffset(sink: DiagnosticSink, startOffsetTicks: bigint): void {
|
|
139
|
+
sink.report({
|
|
140
|
+
code: 'START_OFFSET_OUT_OF_RANGE',
|
|
141
|
+
message:
|
|
142
|
+
`record 0 starts ${ticksToSeconds(startOffsetTicks)} s after the header start time ` +
|
|
143
|
+
`(${startOffsetTicks} ticks of 100 ns), but a sub-second start offset must be in [0, 1). ` +
|
|
144
|
+
"Rule: the onset of record 0's timekeeping TAL is the recording's sub-second start " +
|
|
145
|
+
'offset; the whole-second part of the start time lives in the header starttime field. ' +
|
|
146
|
+
'Next: the value was used as written, so the time axis still starts at record 0; compare ' +
|
|
147
|
+
'it with the header starttime, because a writer that encodes the start time twice ' +
|
|
148
|
+
'produces exactly this.',
|
|
149
|
+
field: 'timekeeping TAL',
|
|
150
|
+
expected: `0 <= onset < ${TICKS_PER_SECOND} ticks`,
|
|
151
|
+
actual: `${startOffsetTicks} ticks`,
|
|
152
|
+
recordIndex: 0,
|
|
153
|
+
specReference: TIMEKEEPING_SPEC,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The two-probe contiguity verdict.
|
|
159
|
+
*
|
|
160
|
+
* `onset[last] === onset[0] + (n - 1) * recordDuration`, in exact ticks, detects any NET drift of
|
|
161
|
+
* the timeline from two reads. It is not a proof of contiguity: a gap that a later overlap
|
|
162
|
+
* cancels exactly leaves the two ends where a contiguous file would put them, and only
|
|
163
|
+
* `buildRecordIndex()` or `validateRecording()` — which read every onset — can see that. The
|
|
164
|
+
* message says so, because a caller who believes this check is a proof will trust the wrong file.
|
|
165
|
+
*/
|
|
166
|
+
function reportDrift(
|
|
167
|
+
sink: DiagnosticSink,
|
|
168
|
+
header: EdfHeader,
|
|
169
|
+
lastRecordIndex: number,
|
|
170
|
+
observedTicks: bigint,
|
|
171
|
+
expectedTicks: bigint,
|
|
172
|
+
): void {
|
|
173
|
+
const driftTicks = observedTicks - expectedTicks;
|
|
174
|
+
const nominal =
|
|
175
|
+
`record ${lastRecordIndex} starts at ${ticksToSeconds(observedTicks)} s where ` +
|
|
176
|
+
`startOffset + ${lastRecordIndex} * recordDuration is ${ticksToSeconds(expectedTicks)} s, ` +
|
|
177
|
+
`a net drift of ${ticksToSeconds(driftTicks)} s (${driftTicks} ticks of 100 ns)`;
|
|
178
|
+
const probeNote =
|
|
179
|
+
'Note: two probes detect any net drift of the timeline, but not a gap that an overlap ' +
|
|
180
|
+
'elsewhere cancels exactly — buildRecordIndex() reads every onset and does.';
|
|
181
|
+
|
|
182
|
+
if (header.continuity === 'continuous') {
|
|
183
|
+
sink.report({
|
|
184
|
+
code: 'DISCONTINUITY_IN_CONTINUOUS_FILE',
|
|
185
|
+
message:
|
|
186
|
+
`the reserved field marks this file continuous, but ${nominal}. ` +
|
|
187
|
+
'Rule: in a continuous file every record onset is startOffset + recordIndex * ' +
|
|
188
|
+
'recordDuration; a recording with gaps is what EDF+D exists for. ' +
|
|
189
|
+
'Next: treat the file as discontinuous — buildRecordIndex() returns its segments and ' +
|
|
190
|
+
`gaps, and readWindow() then returns one chunk per contiguous run instead of ` +
|
|
191
|
+
'crossing a gap silently. ' +
|
|
192
|
+
probeNote,
|
|
193
|
+
field: 'timekeeping TAL',
|
|
194
|
+
expected: `${expectedTicks} ticks`,
|
|
195
|
+
actual: `${observedTicks} ticks`,
|
|
196
|
+
recordIndex: lastRecordIndex,
|
|
197
|
+
specReference: TIMEKEEPING_SPEC,
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// A discontinuous file is allowed to spread its records out; it is never allowed to pull them
|
|
203
|
+
// together, so only negative net drift is reportable here.
|
|
204
|
+
if (driftTicks >= 0n) return;
|
|
205
|
+
sink.report({
|
|
206
|
+
code: 'RECORD_ONSET_SPACING_VIOLATION',
|
|
207
|
+
message:
|
|
208
|
+
`${nominal}, so somewhere in this file a record starts before the previous one ends. ` +
|
|
209
|
+
'Rule: consecutive record onsets are spaced by at least the record duration — a ' +
|
|
210
|
+
'discontinuous file may leave gaps between records but never overlaps them. ' +
|
|
211
|
+
'Next: the onsets were used as written and nothing was reordered; buildRecordIndex() ' +
|
|
212
|
+
'reports which records overlap. ' +
|
|
213
|
+
probeNote,
|
|
214
|
+
field: 'timekeeping TAL',
|
|
215
|
+
expected: `>= ${expectedTicks} ticks`,
|
|
216
|
+
actual: `${observedTicks} ticks`,
|
|
217
|
+
recordIndex: lastRecordIndex,
|
|
218
|
+
specReference: TIMEKEEPING_SPEC,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* `EdfTimeline` from the probed onsets plus the header.
|
|
224
|
+
*
|
|
225
|
+
* `spanSeconds` and `coveredSeconds` are computed independently — last record end minus first
|
|
226
|
+
* record start, against the sum of the record durations — because their being equal is the
|
|
227
|
+
* statement "this file is contiguous as far as two reads can tell". Deriving either from the
|
|
228
|
+
* other would make that identity true by construction and worth nothing.
|
|
229
|
+
*/
|
|
230
|
+
export function buildTimelineFromProbes(input: TimelineInput, options?: ParseOptions): EdfTimeline {
|
|
231
|
+
const header = input.header;
|
|
232
|
+
const recordCount = header.recordCount;
|
|
233
|
+
const durationTicks = header.recordDurationTicks;
|
|
234
|
+
assertProbeShape(input.probes, recordCount);
|
|
235
|
+
|
|
236
|
+
// Fatal, and checked before anything is derived: a non-monotonic timeline has no valid span,
|
|
237
|
+
// no valid offset, and no valid answer to any question a caller could ask of this object.
|
|
238
|
+
for (let i = 1; i < input.probes.length; i += 1) {
|
|
239
|
+
const previous = input.probes[i - 1];
|
|
240
|
+
const probe = input.probes[i];
|
|
241
|
+
if (previous === undefined || probe === undefined) continue;
|
|
242
|
+
assertMonotonicOnsets(previous, probe);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const sink = new DiagnosticSink(options);
|
|
246
|
+
const priorDiagnostics = input.probeDiagnostics ?? [];
|
|
247
|
+
const first = input.probes[0];
|
|
248
|
+
const last = input.probes[input.probes.length - 1];
|
|
249
|
+
|
|
250
|
+
if (recordCount === 0 || first === undefined || last === undefined) {
|
|
251
|
+
return {
|
|
252
|
+
recordCount: 0,
|
|
253
|
+
recordDurationSeconds: header.recordDurationSeconds,
|
|
254
|
+
startOffsetSeconds: 0,
|
|
255
|
+
startOffsetTicks: 0n,
|
|
256
|
+
spanSeconds: 0,
|
|
257
|
+
coveredSeconds: 0,
|
|
258
|
+
diagnostics: Object.freeze(priorDiagnostics.slice()),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const startOffsetTicks = first.onsetTicks;
|
|
263
|
+
const spanTicks = last.onsetTicks + durationTicks - first.onsetTicks;
|
|
264
|
+
const coveredTicks = BigInt(recordCount) * durationTicks;
|
|
265
|
+
const expectedLastTicks = first.onsetTicks + BigInt(recordCount - 1) * durationTicks;
|
|
266
|
+
|
|
267
|
+
// decodeAnnotations already reports this for a range that began at record 0, which is exactly
|
|
268
|
+
// how record 0 is probed. Repeating it would double every strict-mode failure and every
|
|
269
|
+
// diagnostic count a test asserts.
|
|
270
|
+
const offsetAlreadyReported = priorDiagnostics.some(
|
|
271
|
+
(diagnostic) => diagnostic.code === 'START_OFFSET_OUT_OF_RANGE',
|
|
272
|
+
);
|
|
273
|
+
if (!offsetAlreadyReported && (startOffsetTicks < 0n || startOffsetTicks >= TICKS_PER_SECOND)) {
|
|
274
|
+
reportStartOffset(sink, startOffsetTicks);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (last.onsetTicks !== expectedLastTicks) {
|
|
278
|
+
reportDrift(sink, header, last.recordIndex, last.onsetTicks, expectedLastTicks);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
recordCount,
|
|
283
|
+
recordDurationSeconds: header.recordDurationSeconds,
|
|
284
|
+
startOffsetSeconds: ticksToSeconds(startOffsetTicks),
|
|
285
|
+
startOffsetTicks,
|
|
286
|
+
spanSeconds: ticksToSeconds(spanTicks),
|
|
287
|
+
coveredSeconds: ticksToSeconds(coveredTicks),
|
|
288
|
+
diagnostics: Object.freeze([...priorDiagnostics, ...sink.diagnostics]),
|
|
289
|
+
};
|
|
290
|
+
}
|